feat(XL13): 归档工作流对话运行界面

- 接入发布快照优先与未发布草稿受控运行

- 支持文本和思考流式输出、循环多输出及实时运行详情

- 完成聊天分享、图片输入、中止与清空重来

- 补充后端与前端定向回归测试
This commit is contained in:
2026-07-31 09:40:54 +08:00
parent 048aa9bc1e
commit 615092f4f7
43 changed files with 6128 additions and 242 deletions

View File

@@ -113,6 +113,12 @@ function getMessageParts(item: ChatTimelineMessageItem) {
];
}
function hasMessageBubble(item: ChatTimelineMessageItem) {
return (
getMessageParts(item).length > 0 || Boolean(item.knowledgeItems?.length)
);
}
function updateThinkingExpanded(partId: string, expanded: boolean) {
const item = messageItem.value;
if (!item) {
@@ -170,26 +176,32 @@ function handleCopyAction() {
:document-loader="documentLoader"
compact
/>
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
<ChatThinkingBlock
v-if="part.type === 'thinking'"
:content="part.content"
:expanded="resolveThinkingExpanded(part)"
:status="part.status"
class="chat-timeline-item__thinking"
readonly
@update:expanded="updateThinkingExpanded(part.id, $event)"
<div
v-if="hasMessageBubble(messageItem)"
class="chat-timeline-item__bubble"
:class="`is-${messageItem.role}`"
>
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
<ChatThinkingBlock
v-if="part.type === 'thinking'"
:content="part.content"
:expanded="resolveThinkingExpanded(part)"
:status="part.status"
class="chat-timeline-item__thinking"
readonly
@update:expanded="updateThinkingExpanded(part.id, $event)"
/>
<ChatTextBlock
v-else
:content="part.content"
:streaming="messageItem.status === 'streaming'"
/>
</template>
<ChatKnowledgeCard
v-if="messageItem.knowledgeItems?.length"
:items="messageItem.knowledgeItems"
/>
<ChatTextBlock
v-else
:content="part.content"
:streaming="messageItem.status === 'streaming'"
/>
</template>
<ChatKnowledgeCard
v-if="messageItem.knowledgeItems?.length"
:items="messageItem.knowledgeItems"
/>
</div>
<ChatMessageToolbar
v-if="showToolbar && messageItem"
:align="messageItem.role === 'user' ? 'end' : 'start'"
@@ -246,18 +258,29 @@ function handleCopyAction() {
display: flex;
flex-direction: column;
gap: 8px;
width: fit-content;
min-width: 0;
max-width: min(78%, 680px);
max-width: 100%;
}
.chat-timeline-item__message {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0;
min-width: 0;
max-width: min(78%, 680px);
}
.chat-timeline-item__message.is-user {
align-items: flex-end;
color: var(--el-text-color-primary);
}
.chat-timeline-item__message.is-assistant,
.chat-timeline-item__message.is-system {
align-items: flex-start;
}
.chat-timeline-item__message :deep(.chat-text-block),
.chat-timeline-item__message :deep(.chat-thinking-block) {
padding: 0;
@@ -271,10 +294,6 @@ function handleCopyAction() {
line-height: 22px;
}
.chat-timeline-item__message.is-user {
color: var(--el-text-color-primary);
}
.chat-timeline-item__message.is-assistant.has-variant-navigator {
width: min(78%, 680px);
}

View File

@@ -40,6 +40,7 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
>
<template #icon>
<BookOpenText
v-if="item.icon !== 'none'"
class="chat-timeline-status-row__icon"
aria-hidden="true"
/>

View File

@@ -73,4 +73,22 @@ describe('ChatTimelineStatusRow', () => {
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
});
it('can render a plain inline status without the context icon', () => {
const item: ChatTimelineStatusItem = {
icon: 'none',
id: 'workflow-run',
label: '运行完成',
status: 'done',
statusKey: 'workflow-run',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.text()).toContain('运行完成');
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(false);
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false);
});
});

View File

@@ -1,8 +1,8 @@
import type {ChatTimelineItem} from '../types';
import type { ChatTimelineItem } from '../types';
import {flushPromises, mount} from '@vue/test-utils';
import { flushPromises, mount } from '@vue/test-utils';
import {afterEach, describe, expect, it, vi} from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ChatTimeline from '../ChatTimeline.vue';
@@ -27,7 +27,7 @@ function textMessage(
};
}
describe('ChatTimeline toolbar', () => {
describe('chat timeline toolbar', () => {
afterEach(() => {
vi.useRealTimers();
});
@@ -49,6 +49,86 @@ describe('ChatTimeline toolbar', () => {
expect(wrapper.emitted('copyMessage')?.[0]?.[0]).toEqual(userMessage);
});
it('places the user toolbar outside the message bubble', () => {
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => true,
items: [textMessage('user', '用户问题')],
},
});
const message = wrapper.get('.chat-timeline-item__message');
const bubble = message.get('.chat-timeline-item__bubble');
const toolbar = message.get('.chat-message-toolbar');
expect(bubble.find('[aria-label="复制消息"]').exists()).toBe(false);
expect(toolbar.element.parentElement).toBe(message.element);
});
it('renders image and document attachments outside the message bubble', () => {
const wrapper = mount(ChatTimeline, {
props: {
items: [
{
documents: [
{
name: '需求说明.pdf',
status: 'ready',
},
],
id: 'user-attachments',
images: [
{
name: '示例图片.png',
previewUrl: 'data:image/png;base64,AA==',
status: 'ready',
},
],
parts: [],
role: 'user',
status: 'done',
type: 'message',
},
],
},
});
const message = wrapper.get('.chat-timeline-item__message');
expect(message.find('.chat-timeline-item__bubble').exists()).toBe(false);
expect(message.get('.chat-image-attachments').element.parentElement).toBe(
message.element,
);
expect(
message.get('.chat-document-attachments').element.parentElement,
).toBe(message.element);
});
it('keeps only text inside the bubble when attachments accompany text', () => {
const wrapper = mount(ChatTimeline, {
props: {
items: [
textMessage('user', '请识别这张图片', {
images: [
{
name: '示例图片.png',
previewUrl: 'data:image/png;base64,AA==',
status: 'ready',
},
],
}),
],
},
});
const message = wrapper.get('.chat-timeline-item__message');
const bubble = message.get('.chat-timeline-item__bubble');
const images = message.get('.chat-image-attachments');
expect(bubble.text()).toContain('请识别这张图片');
expect(bubble.find('.chat-image-attachments').exists()).toBe(false);
expect(images.element.parentElement).toBe(message.element);
});
it('shows copy and regenerate buttons for assistant messages', async () => {
const assistantMessage = textMessage('assistant', '助手回答', {
regenerable: true,

View File

@@ -128,6 +128,7 @@ export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase {
}
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
icon?: 'book' | 'none';
label: string;
presentation?: 'inline' | 'separator';
status: ChatTimelineStatusStatus;