From 5ee6065017f62f19c2f1f9c62854a3a06b784d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 29 Jul 2026 00:45:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=B1=95=E5=BC=80=E7=8A=B6=E6=80=81=E8=A2=AB?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留智能体思考内容的手动展开与关闭选择 - 工作流试运行默认跟随当前节点,并允许用户自由多开或关闭 --- .../ai/workflow/components/WorkflowSteps.vue | 37 +++-- .../__tests__/WorkflowSteps.test.ts | 137 ++++++++++++++++++ .../chat-timeline/ChatTimelineItem.vue | 20 ++- .../__tests__/ChatTimelineThinking.test.ts | 91 ++++++++++++ 4 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/WorkflowSteps.test.ts create mode 100644 easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineThinking.test.ts diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue index 0120e811..3ca6194f 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowSteps.vue @@ -36,7 +36,8 @@ const emit = defineEmits(['resume']); const nodes = ref([]); const nodeStatusMap = ref>({}); const isChainError = ref(false); -const activeName = ref(props.expandAll ? [] : ''); +const activeNames = ref([]); +const userControlledExpansion = ref(false); const confirmParams = ref({}); // 定义一个对象来存储所有的 form 实例,key 为 node.key const formRefs = ref>({}); @@ -47,11 +48,14 @@ function shouldAutoExpandStatus(status: unknown) { return [1, 5, 20, 21].includes(Number(status)); } -function isExpandedNode(nodeKey: string) { - if (Array.isArray(activeName.value)) { - return activeName.value.includes(nodeKey); +function handleManualExpansionChange() { + if (!props.expandAll) { + userControlledExpansion.value = true; } - return activeName.value === nodeKey; +} + +function isExpandedNode(nodeKey: string) { + return activeNames.value.includes(nodeKey); } function hasNodePayloadChanged(previous: any, current: any) { @@ -104,11 +108,12 @@ watch( } nodeStatusMap.value[nodeId] = currentNodeState; if ( + !userControlledExpansion.value && !props.expandAll && previousStatus !== currentStatus && shouldAutoExpandStatus(currentStatus) ) { - activeName.value = nodeId; + activeNames.value = [nodeId]; } } }, @@ -121,7 +126,8 @@ watch( isChainError.value = false; confirmBtnLoading.value = false; chainErrMsg.value = ''; - activeName.value = props.expandAll ? [] : ''; + userControlledExpansion.value = false; + activeNames.value = []; }, ); watch( @@ -129,9 +135,16 @@ watch( (newVal) => { const nextNodes = Array.isArray(newVal) ? [...newVal] : []; 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 }, ); @@ -181,9 +194,9 @@ function handleConfirm(node: any) { ) { + 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'); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue index 3647b765..93a2b5e4 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue @@ -7,7 +7,7 @@ import type { ChatTimelineToolApprovalPayload, } from './types'; -import { computed } from 'vue'; +import { computed, ref } from 'vue'; import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue'; import ChatErrorNotice from './ChatErrorNotice.vue'; @@ -43,6 +43,8 @@ const emit = defineEmits<{ const messageItem = computed(() => props.item.type === 'message' ? props.item : undefined, ); +// 流式快照会替换消息对象,用户手动选择需要由稳定的组件实例单独保留。 +const thinkingExpandedOverrides = ref>({}); const alignmentClass = computed(() => { 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); if (part?.type === 'thinking') { + thinkingExpandedOverrides.value = { + ...thinkingExpandedOverrides.value, + [partId]: expanded, + }; emit('thinkingToggle'); part.expanded = expanded; } } +function resolveThinkingExpanded( + part: Extract, +) { + return ( + thinkingExpandedOverrides.value[part.id] ?? + part.expanded ?? + part.status === 'thinking' + ); +} + function handleCopyAction() { const item = messageItem.value; if (!item || !props.copyAction) { @@ -149,7 +165,7 @@ function handleCopyAction() { { + 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); + }); +});