feat(XL13): 归档工作流对话运行界面
- 接入发布快照优先与未发布草稿受控运行 - 支持文本和思考流式输出、循环多输出及实时运行详情 - 完成聊天分享、图片输入、中止与清空重来 - 补充后端与前端定向回归测试
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase {
|
||||
}
|
||||
|
||||
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
|
||||
icon?: 'book' | 'none';
|
||||
label: string;
|
||||
presentation?: 'inline' | 'separator';
|
||||
status: ChatTimelineStatusStatus;
|
||||
|
||||
Reference in New Issue
Block a user