发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
4 changed files with 271 additions and 14 deletions
Showing only changes of commit 5ee6065017 - Show all commits

View File

@@ -36,7 +36,8 @@ const emit = defineEmits(['resume']);
const nodes = ref<any[]>([]); const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({}); const nodeStatusMap = ref<Record<string, any>>({});
const isChainError = ref(false); const isChainError = ref(false);
const activeName = ref<any>(props.expandAll ? [] : ''); const activeNames = ref<string[]>([]);
const userControlledExpansion = ref(false);
const confirmParams = ref<any>({}); const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key // 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({}); const formRefs = ref<Record<string, FormInstance>>({});
@@ -47,11 +48,14 @@ function shouldAutoExpandStatus(status: unknown) {
return [1, 5, 20, 21].includes(Number(status)); return [1, 5, 20, 21].includes(Number(status));
} }
function isExpandedNode(nodeKey: string) { function handleManualExpansionChange() {
if (Array.isArray(activeName.value)) { if (!props.expandAll) {
return activeName.value.includes(nodeKey); userControlledExpansion.value = true;
} }
return activeName.value === nodeKey; }
function isExpandedNode(nodeKey: string) {
return activeNames.value.includes(nodeKey);
} }
function hasNodePayloadChanged(previous: any, current: any) { function hasNodePayloadChanged(previous: any, current: any) {
@@ -104,11 +108,12 @@ watch(
} }
nodeStatusMap.value[nodeId] = currentNodeState; nodeStatusMap.value[nodeId] = currentNodeState;
if ( if (
!userControlledExpansion.value &&
!props.expandAll && !props.expandAll &&
previousStatus !== currentStatus && previousStatus !== currentStatus &&
shouldAutoExpandStatus(currentStatus) shouldAutoExpandStatus(currentStatus)
) { ) {
activeName.value = nodeId; activeNames.value = [nodeId];
} }
} }
}, },
@@ -121,7 +126,8 @@ watch(
isChainError.value = false; isChainError.value = false;
confirmBtnLoading.value = false; confirmBtnLoading.value = false;
chainErrMsg.value = ''; chainErrMsg.value = '';
activeName.value = props.expandAll ? [] : ''; userControlledExpansion.value = false;
activeNames.value = [];
}, },
); );
watch( watch(
@@ -129,9 +135,16 @@ watch(
(newVal) => { (newVal) => {
const nextNodes = Array.isArray(newVal) ? [...newVal] : []; const nextNodes = Array.isArray(newVal) ? [...newVal] : [];
nodes.value = nextNodes; nodes.value = nextNodes;
activeName.value = props.expandAll
? nextNodes.map((node: any) => node.key) if (props.expandAll) {
: ''; activeNames.value = nextNodes.map((node: any) => node.key);
return;
}
const nodeKeys = new Set(nextNodes.map((node: any) => node.key));
activeNames.value = activeNames.value.filter((nodeKey) =>
nodeKeys.has(nodeKey),
);
}, },
{ immediate: true }, { immediate: true },
); );
@@ -181,9 +194,9 @@ function handleConfirm(node: any) {
<ElAlert v-if="chainErrMsg" :title="chainErrMsg" type="error" /> <ElAlert v-if="chainErrMsg" :title="chainErrMsg" type="error" />
</div> </div>
<ElCollapse <ElCollapse
v-model="activeName" v-model="activeNames"
:accordion="!props.expandAll"
expand-icon-position="left" expand-icon-position="left"
@change="handleManualExpansionChange"
> >
<ElCollapseItem <ElCollapseItem
v-for="node in displayNodes" v-for="node in displayNodes"

View File

@@ -0,0 +1,137 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import WorkflowSteps from '../WorkflowSteps.vue';
function createWorkflowNodes() {
return [
{
key: 'node-a',
label: '节点 A',
original: {
data: {},
type: 'llmNode',
},
},
{
key: 'node-b',
label: '节点 B',
original: {
data: {},
type: 'llmNode',
},
},
];
}
function mountWorkflowSteps() {
return mount(WorkflowSteps, {
props: {
initSignal: false,
nodeJson: createWorkflowNodes(),
pollingData: undefined,
workflowId: 'workflow-1',
},
global: {
stubs: {
ConfirmItem: true,
ConfirmItemMulti: true,
ShowJson: true,
},
},
});
}
function getCollapseItems(wrapper: ReturnType<typeof mountWorkflowSteps>) {
return wrapper.findAll('.el-collapse-item');
}
describe('workflowSteps', () => {
it('用户手动展开或关闭后,节点刷新和轮询不会覆盖当前选择', async () => {
const wrapper = mountWorkflowSteps();
await wrapper.setProps({
pollingData: {
nodes: {
'node-a': { status: 1 },
},
status: 1,
},
});
expect(getCollapseItems(wrapper)[0]?.classes()).toContain('is-active');
await wrapper.setProps({
pollingData: {
nodes: {
'node-a': { result: { text: 'A 完成' }, status: 20 },
'node-b': { status: 1 },
},
status: 1,
},
});
expect(getCollapseItems(wrapper)[0]?.classes()).not.toContain('is-active');
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
await getCollapseItems(wrapper)[0]
?.get('.el-collapse-item__header')
.trigger('click');
expect(getCollapseItems(wrapper)[0]?.classes()).toContain('is-active');
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
await wrapper.setProps({
nodeJson: createWorkflowNodes(),
pollingData: {
nodes: {
'node-a': { result: { text: 'A 完成' }, status: 20 },
'node-b': { result: { text: 'B 完成' }, status: 20 },
},
status: 20,
},
});
expect(getCollapseItems(wrapper)[0]?.classes()).toContain('is-active');
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
await getCollapseItems(wrapper)[0]
?.get('.el-collapse-item__header')
.trigger('click');
expect(getCollapseItems(wrapper)[0]?.classes()).not.toContain('is-active');
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
await wrapper.setProps({
pollingData: {
nodes: {
'node-a': { message: '节点执行失败', status: 21 },
'node-b': { result: { text: 'B 完成' }, status: 20 },
},
status: 21,
},
});
expect(getCollapseItems(wrapper)[0]?.classes()).not.toContain('is-active');
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
});
it('新一轮试运行会清除手动选择并恢复自动跟随', async () => {
const wrapper = mountWorkflowSteps();
await getCollapseItems(wrapper)[1]
?.get('.el-collapse-item__header')
.trigger('click');
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
await wrapper.setProps({ initSignal: true });
expect(
getCollapseItems(wrapper).every((item) => !item.classes('is-active')),
).toBe(true);
await wrapper.setProps({
pollingData: {
nodes: {
'node-a': { status: 1 },
},
status: 1,
},
});
expect(getCollapseItems(wrapper)[0]?.classes()).toContain('is-active');
});
});

View File

@@ -7,7 +7,7 @@ import type {
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
} from './types'; } from './types';
import { computed } from 'vue'; import { computed, ref } from 'vue';
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue'; import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
import ChatErrorNotice from './ChatErrorNotice.vue'; import ChatErrorNotice from './ChatErrorNotice.vue';
@@ -43,6 +43,8 @@ const emit = defineEmits<{
const messageItem = computed(() => const messageItem = computed(() =>
props.item.type === 'message' ? props.item : undefined, props.item.type === 'message' ? props.item : undefined,
); );
// 流式快照会替换消息对象,用户手动选择需要由稳定的组件实例单独保留。
const thinkingExpandedOverrides = ref<Record<string, boolean>>({});
const alignmentClass = computed(() => { const alignmentClass = computed(() => {
if (props.item.type === 'message' && props.item.role === 'user') { if (props.item.type === 'message' && props.item.role === 'user') {
@@ -115,11 +117,25 @@ function updateThinkingExpanded(partId: string, expanded: boolean) {
} }
const part = item.parts.find((current) => current.id === partId); const part = item.parts.find((current) => current.id === partId);
if (part?.type === 'thinking') { if (part?.type === 'thinking') {
thinkingExpandedOverrides.value = {
...thinkingExpandedOverrides.value,
[partId]: expanded,
};
emit('thinkingToggle'); emit('thinkingToggle');
part.expanded = expanded; part.expanded = expanded;
} }
} }
function resolveThinkingExpanded(
part: Extract<ChatTimelineMessagePart, { type: 'thinking' }>,
) {
return (
thinkingExpandedOverrides.value[part.id] ??
part.expanded ??
part.status === 'thinking'
);
}
function handleCopyAction() { function handleCopyAction() {
const item = messageItem.value; const item = messageItem.value;
if (!item || !props.copyAction) { if (!item || !props.copyAction) {
@@ -149,7 +165,7 @@ function handleCopyAction() {
<ChatThinkingBlock <ChatThinkingBlock
v-if="part.type === 'thinking'" v-if="part.type === 'thinking'"
:content="part.content" :content="part.content"
:expanded="part.expanded ?? part.status === 'thinking'" :expanded="resolveThinkingExpanded(part)"
:status="part.status" :status="part.status"
class="chat-timeline-item__thinking" class="chat-timeline-item__thinking"
readonly readonly

View File

@@ -0,0 +1,91 @@
import type { ChatTimelineItem } from '../types';
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ChatTimeline from '../ChatTimeline.vue';
function thinkingMessage(options?: {
content?: string;
expanded?: boolean;
messageStatus?: 'done' | 'streaming';
thinkingStatus?: 'end' | 'thinking';
}): Extract<ChatTimelineItem, { type: 'message' }> {
return {
id: 'assistant-thinking',
parts: [
{
id: 'thinking-part',
content: options?.content ?? '分析问题',
expanded: options?.expanded ?? true,
status: options?.thinkingStatus ?? 'thinking',
type: 'thinking',
},
],
role: 'assistant',
status: options?.messageStatus ?? 'streaming',
type: 'message',
};
}
describe('chat timeline thinking interaction', () => {
it('keeps the user expansion choice across streaming snapshots', async () => {
const wrapper = mount(ChatTimeline, {
props: {
items: [thinkingMessage()],
},
});
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(true);
await wrapper.get('.chat-thinking-block__trigger').trigger('click');
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(false);
await wrapper.setProps({
items: [
thinkingMessage({
content: '分析问题并继续接收思考分片',
expanded: true,
}),
],
});
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(false);
await wrapper.get('.chat-thinking-block__trigger').trigger('click');
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(true);
await wrapper.setProps({
items: [
thinkingMessage({
content: '分析问题并继续接收正文分片',
expanded: false,
thinkingStatus: 'end',
}),
],
});
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(true);
});
it('uses the stream default when the user has not toggled thinking', async () => {
const wrapper = mount(ChatTimeline, {
props: {
items: [thinkingMessage()],
},
});
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(true);
await wrapper.setProps({
items: [
thinkingMessage({
expanded: false,
messageStatus: 'done',
thinkingStatus: 'end',
}),
],
});
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(false);
});
});