fix: 修复流式输出展开状态被覆盖
- 保留智能体思考内容的手动展开与关闭选择 - 工作流试运行默认跟随当前节点,并允许用户自由多开或关闭
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user