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); + }); +});