feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import type { ChatArtifactLoader, ChatTimelineArtifactItem } from './types';
import { computed, ref } from 'vue';
const props = defineProps<{
artifactLoader?: ChatArtifactLoader;
item: ChatTimelineArtifactItem;
}>();
const downloading = ref(false);
const downloadError = ref('');
const downloadable = computed(
() =>
props.item.status === 'available' &&
Boolean(props.item.downloadUrl) &&
Boolean(props.artifactLoader),
);
function formatSize(value?: number) {
const bytes = Number(value || 0);
if (bytes <= 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function fileType() {
const extension = props.item.fileName.trim().split('.').pop()?.toUpperCase();
if (extension && extension !== props.item.fileName.toUpperCase()) {
return extension;
}
const mime = String(props.item.mimeType || '')
.split('/')
.pop();
return mime ? mime.toUpperCase() : '文件';
}
const statusText = computed(() => {
if (downloading.value) return '下载中';
if (downloadError.value) return `${downloadError.value},点击重试`;
if (props.item.status === 'expired') return '已过期';
if (props.item.status === 'delete_failed') return '删除失败';
if (props.item.status === 'unavailable') return '不可用';
return [fileType(), formatSize(props.item.size)].filter(Boolean).join(' · ');
});
function downloadTitle() {
const hash = props.item.sha256 ? `\nSHA-256: ${props.item.sha256}` : '';
if (downloadable.value) {
return `${downloadError.value ? '重试下载' : '下载'} ${props.item.fileName}${hash}`;
}
return `${props.item.fileName}${statusText.value}${hash}`;
}
function errorMessage(error: unknown) {
return error instanceof Error && error.message.trim()
? error.message
: '下载失败,请重试';
}
async function download() {
if (!downloadable.value || downloading.value || !props.artifactLoader) {
return;
}
downloading.value = true;
downloadError.value = '';
try {
await props.artifactLoader(props.item);
} catch (error) {
downloadError.value = errorMessage(error);
} finally {
downloading.value = false;
}
}
</script>
<template>
<article
class="chat-artifact"
:class="[
`is-${item.status.replaceAll('_', '-')}`,
{ 'has-download-error': Boolean(downloadError) },
]"
>
<button
type="button"
class="chat-artifact__main"
:aria-label="
downloadable
? `${downloadError ? '重试下载' : '下载'} ${item.fileName}`
: `${item.fileName} ${statusText}`
"
:disabled="!downloadable || downloading"
:title="downloadTitle()"
@click="download"
>
<span class="chat-artifact__icon-box" aria-hidden="true">
<span v-if="downloading" class="chat-artifact__spinner"></span>
<svg v-else viewBox="0 0 24 24">
<path d="M6.5 3.5h7l4 4v13h-11v-17Zm7 0v4h4" />
<path d="M9 16.5h6M12 10v4m0 0 2-2m-2 2-2-2" />
</svg>
</span>
<span class="chat-artifact__meta">
<span class="chat-artifact__name">{{ item.fileName }}</span>
<span class="chat-artifact__state">{{ statusText }}</span>
</span>
<span
v-if="downloadable && !downloading"
class="chat-artifact__action"
aria-hidden="true"
>
{{ downloadError ? '重试' : '下载' }}
</span>
</button>
</article>
</template>
<style scoped>
.chat-artifact {
width: min(320px, 100%);
min-width: 0;
overflow: hidden;
background: hsl(var(--surface-elevated));
border-radius: var(--radius-toolbar);
box-shadow: inset 0 0 0 1px hsl(var(--line-subtle));
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
}
.chat-artifact.is-available:hover {
background: hsl(var(--surface-subtle));
box-shadow: inset 0 0 0 1px hsl(var(--border));
}
.chat-artifact.is-delete-failed,
.chat-artifact.has-download-error {
box-shadow: inset 0 0 0 1px hsl(var(--destructive) / 46%);
}
.chat-artifact__main {
display: flex;
gap: var(--space-2);
align-items: center;
width: 100%;
min-width: 0;
min-height: 64px;
padding: var(--space-2);
color: inherit;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.chat-artifact__main:disabled {
cursor: default;
}
.chat-artifact__main:active:not(:disabled) {
background: hsl(var(--surface-contrast-soft));
}
.chat-artifact__main:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: -2px;
}
.chat-artifact__icon-box {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: hsl(var(--document-icon-foreground));
background: hsl(var(--document-icon-generic));
border-radius: var(--radius-control);
}
.chat-artifact__icon-box svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentcolor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-artifact__spinner {
width: 18px;
height: 18px;
border: 2px solid hsl(var(--document-icon-foreground) / 38%);
border-top-color: hsl(var(--document-icon-foreground));
border-radius: 50%;
animation: chat-artifact-spin 0.9s linear infinite;
}
.chat-artifact__meta {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.chat-artifact__name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-artifact__state {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 18px;
color: hsl(var(--text-muted));
white-space: nowrap;
}
.is-expired .chat-artifact__state,
.is-delete-failed .chat-artifact__state,
.has-download-error .chat-artifact__state {
color: hsl(var(--destructive));
}
.chat-artifact__action {
flex: 0 0 auto;
min-width: 48px;
padding-inline: var(--space-2);
font-size: 13px;
font-weight: 500;
line-height: 32px;
color: hsl(var(--primary));
text-align: center;
}
@keyframes chat-artifact-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.chat-artifact {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-artifact,
.chat-artifact__spinner {
transition: none;
animation: none;
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem as ChatTimelineItemType,
@@ -11,9 +12,11 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
import ChatTimelineTurn from './ChatTimelineTurn.vue';
const props = defineProps<{
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantAvatar?: string;
copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
@@ -39,18 +42,59 @@ const emit = defineEmits<{
const containerRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false);
let preservedScrollTop: number | undefined;
let preservedAnchor: undefined | { element: HTMLElement; relativeTop: number };
const bottomThreshold = 24;
let scrollFrame = 0;
const assistantActionAnchorIds = computed(() => {
const assistantActionAnchorByRound = computed(() => {
const latestAssistantByRound = new Map<string, string>();
for (const item of props.items) {
if (item.type === 'message' && item.role === 'assistant' && item.roundId) {
latestAssistantByRound.set(item.roundId, item.id);
}
}
return new Set(latestAssistantByRound.values());
return latestAssistantByRound;
});
const assistantActionAnchorIds = computed(
() => new Set(assistantActionAnchorByRound.value.values()),
);
type TimelineDisplayEntry =
| { id: string; item: ChatTimelineItemType; type: 'item' }
| {
id: string;
items: ChatTimelineItemType[];
roundId: string;
type: 'turn';
};
const displayEntries = computed<TimelineDisplayEntry[]>(() => {
const entries: TimelineDisplayEntry[] = [];
const turns = new Map<
string,
Extract<TimelineDisplayEntry, { type: 'turn' }>
>();
for (const item of props.items) {
if (!item.roundId || (item.type === 'message' && item.role === 'user')) {
entries.push({ id: item.id, item, type: 'item' });
continue;
}
const existing = turns.get(item.roundId);
if (existing) {
existing.items.push(item);
continue;
}
const turn: Extract<TimelineDisplayEntry, { type: 'turn' }> = {
id: `turn-${item.roundId}`,
items: [item],
roundId: item.roundId,
type: 'turn',
};
turns.set(item.roundId, turn);
entries.push(turn);
}
return entries;
});
function isNearBottom(container: HTMLElement) {
@@ -65,6 +109,9 @@ function updatePinnedState() {
if (!container) {
return;
}
if (suppressNextAutoScroll.value && preservedAnchor) {
return;
}
isPinnedToBottom.value = isNearBottom(container);
}
@@ -86,11 +133,75 @@ function handleTimelineScroll() {
updatePinnedState();
}
function handleThinkingToggle() {
preservedScrollTop = containerRef.value?.scrollTop;
function handleLayoutToggle(roundId: string) {
const container = containerRef.value;
if (!container) {
return;
}
if (suppressNextAutoScroll.value && preservedAnchor) {
return;
}
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
scrollFrame = 0;
}
const turn = [
...container.querySelectorAll<HTMLElement>('[data-round-id]'),
].find((element) => element.dataset.roundId === roundId);
const header = turn?.querySelector<HTMLElement>(
'.chat-timeline-turn__header',
);
const contentAnchor = turn?.querySelector<HTMLElement>(
'[data-chat-turn-final], [data-chat-turn-live-anchor]',
);
const containerRect = container.getBoundingClientRect();
const headerRect = header?.getBoundingClientRect();
const anchor =
headerRect &&
headerRect.bottom >= containerRect.top &&
headerRect.top <= containerRect.bottom
? header
: (contentAnchor ?? header ?? turn);
if (anchor) {
preservedAnchor = {
element: anchor,
relativeTop: anchor.getBoundingClientRect().top - containerRect.top,
};
}
suppressNextAutoScroll.value = true;
}
async function handleLayoutChanged() {
await nextTick();
const container = containerRef.value;
const anchor = preservedAnchor;
if (container && anchor?.element.isConnected) {
const relativeTop =
anchor.element.getBoundingClientRect().top -
container.getBoundingClientRect().top;
container.scrollTop += relativeTop - anchor.relativeTop;
}
preservedAnchor = undefined;
suppressNextAutoScroll.value = false;
updatePinnedState();
}
function handleLegacyLayoutToggle() {
const container = containerRef.value;
if (!container) {
return;
}
const scrollTop = container.scrollTop;
suppressNextAutoScroll.value = true;
void nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = scrollTop;
}
suppressNextAutoScroll.value = false;
updatePinnedState();
});
}
function canCopyMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
@@ -122,13 +233,6 @@ watch(
() => props.items,
async () => {
if (suppressNextAutoScroll.value) {
suppressNextAutoScroll.value = false;
await nextTick();
if (preservedScrollTop !== undefined && containerRef.value) {
containerRef.value.scrollTop = preservedScrollTop;
}
preservedScrollTop = undefined;
updatePinnedState();
return;
}
if (isPinnedToBottom.value) {
@@ -161,32 +265,61 @@ watch(
</div>
</div>
<template v-else>
<template v-for="item in items" :key="item.id">
<template v-for="entry in displayEntries" :key="entry.id">
<ChatTimelineTurn
v-if="entry.type === 'turn'"
:action-anchor-id="assistantActionAnchorByRound.get(entry.roundId)"
:artifact-loader="artifactLoader"
:approval-loading="approvalLoading"
:assistant-avatar="assistantAvatar"
:copy-action="copyAction"
:copyable="copyable"
:document-loader="documentLoader"
:image-loader="imageLoader"
:items="entry.items"
:regenerable="regenerable"
:regenerate-disabled="regenerateDisabled"
:round-id="entry.roundId"
:variant-loading="variantLoading"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@layout-changed="handleLayoutChanged"
@layout-toggle="handleLayoutToggle"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
>
<template #custom-item="{ item }">
<slot name="custom-item" :item="item"></slot>
</template>
</ChatTimelineTurn>
<slot
v-if="item.type === 'custom'"
v-else-if="entry.item.type === 'custom'"
name="custom-item"
:item="item"
:item="entry.item"
></slot>
<ChatTimelineItem
v-else
:assistant-actions-visible="isAssistantActionAnchor(item)"
:assistant-actions-visible="isAssistantActionAnchor(entry.item)"
:artifact-loader="artifactLoader"
:assistant-avatar="assistantAvatar"
:item="item"
:item="entry.item"
:document-loader="documentLoader"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"
:copyable="canCopyMessage(item)"
:regenerable="canRegenerateMessage(item)"
:copyable="canCopyMessage(entry.item)"
:regenerable="canRegenerateMessage(entry.item)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(item)"
:variant-loading="isVariantLoading(entry.item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleThinkingToggle"
@thinking-toggle="handleLegacyLayoutToggle"
/>
</template>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem,
@@ -11,6 +12,7 @@ import type {
import { computed, ref } from 'vue';
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
import ChatArtifactAttachment from './ChatArtifactAttachment.vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatDocumentAttachments from './ChatDocumentAttachments.vue';
import ChatErrorNotice from './ChatErrorNotice.vue';
@@ -23,6 +25,7 @@ import ChatToolCard from './ChatToolCard.vue';
const props = defineProps<{
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantActionsVisible?: boolean;
assistantAvatar?: string;
copyable?: boolean;
@@ -256,6 +259,11 @@ function handleCopyAction() {
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
<ChatArtifactAttachment
v-else-if="item.type === 'artifact'"
:artifact-loader="artifactLoader"
:item="item"
/>
<ChatKnowledgeCard
v-else-if="item.type === 'knowledge'"
:items="item.items"

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import type {ChatTimelineStatusItem} from './types';
import type { ChatTimelineStatusItem } from './types';
import {computed} from 'vue';
import { computed } from 'vue';
import {BookOpenText} from '@easyflow/icons';
import { KnowledgeIcon, SkillIcon } from '@easyflow/icons';
import {ChatEventLabel} from '../chat-status';
import { ChatEventLabel } from '../chat-status';
defineOptions({
name: 'ChatTimelineStatusRow',
@@ -27,20 +27,27 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
`is-${item.tone || 'muted'}`,
{ 'is-separator': isSeparator },
]"
:aria-label="item.label"
:title="item.label"
>
<span
v-if="isSeparator"
class="chat-timeline-status-row__line"
aria-hidden="true"
/>
></span>
<ChatEventLabel
class="chat-timeline-status-row__content"
:active="isRunning"
:text="item.label"
>
<template #icon>
<BookOpenText
v-if="item.icon !== 'none'"
<SkillIcon
v-if="item.icon === 'skill'"
class="chat-timeline-status-row__icon"
aria-hidden="true"
/>
<KnowledgeIcon
v-else-if="item.icon !== 'none'"
class="chat-timeline-status-row__icon"
aria-hidden="true"
/>
@@ -50,7 +57,7 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
v-if="isSeparator"
class="chat-timeline-status-row__line"
aria-hidden="true"
/>
></span>
</div>
</template>
@@ -59,14 +66,31 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
display: inline-flex;
gap: 4px;
align-items: center;
max-width: min(78%, 680px);
min-width: 0;
max-width: 100%;
padding: 2px 0;
color: var(--el-text-color-placeholder);
}
.chat-timeline-status-row__content {
min-width: 0;
max-width: 100%;
}
.chat-timeline-status-row__content :deep(.chat-event-label) {
align-items: flex-start;
white-space: normal;
}
.chat-timeline-status-row__content :deep(.chat-event-label__icon) {
margin-top: var(--space-1);
}
.chat-timeline-status-row__content :deep(.chat-shimmer-text) {
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
white-space: normal;
}
.chat-timeline-status-row__icon {
@@ -75,16 +99,36 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
color: var(--el-text-color-placeholder);
}
.chat-timeline-status-row.is-done {
.chat-timeline-status-row.is-cancelled,
.chat-timeline-status-row.is-done,
.chat-timeline-status-row.is-error,
.chat-timeline-status-row.is-incomplete {
opacity: 0.84;
}
.chat-timeline-status-row.is-error,
.chat-timeline-status-row.is-danger {
color: var(--el-color-danger);
}
.chat-timeline-status-row.is-error .chat-timeline-status-row__icon,
.chat-timeline-status-row.is-danger .chat-timeline-status-row__icon {
color: var(--el-color-danger);
}
.chat-timeline-status-row.is-error :deep(.chat-event-label),
.chat-timeline-status-row.is-danger :deep(.chat-event-label),
.chat-timeline-status-row.is-error :deep(.chat-shimmer-text),
.chat-timeline-status-row.is-danger :deep(.chat-shimmer-text) {
color: var(--el-color-danger);
}
.chat-timeline-status-row.is-separator {
display: flex;
width: 100%;
max-width: 100%;
gap: 8px;
justify-content: center;
width: 100%;
max-width: 100%;
}
.chat-timeline-status-row.is-separator .chat-timeline-status-row__line {

View File

@@ -0,0 +1,523 @@
<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineStatusItem,
ChatTimelineToolApprovalPayload,
} from './types';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItemView from './ChatTimelineItem.vue';
const props = defineProps<{
actionAnchorId?: string;
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantAvatar?: string;
copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader;
imageLoader?: ChatImageLoader;
items: ChatTimelineItem[];
regenerable?: (item: ChatTimelineMessageItem) => boolean;
regenerateDisabled?: boolean;
roundId: string;
variantLoading?: (item: ChatTimelineMessageItem) => boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
layoutChanged: [];
layoutToggle: [roundId: string];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
selectPreviousVariant: [item: ChatTimelineMessageItem];
}>();
const turnSucceeded = computed(() =>
props.items.some((item) => item.turnSucceeded === true),
);
const latestAssistantContentSource = computed(() =>
[...props.items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(item.parts.some((part) => part.type === 'text' && part.content) ||
Boolean(item.knowledgeItems?.length)),
),
);
const finalMessageSource = computed(() =>
turnSucceeded.value ? latestAssistantContentSource.value : undefined,
);
const finalMessage = computed<ChatTimelineMessageItem | undefined>(() => {
const item = finalMessageSource.value;
if (!item) {
return undefined;
}
return {
...item,
parts: item.parts.filter((part) => part.type === 'text'),
};
});
const processItems = computed<ChatTimelineItem[]>(() => {
const finalSource = finalMessageSource.value;
return props.items.flatMap((item) => {
if (item.type === 'artifact') {
return [];
}
if (
item.type === 'message' &&
item.role === 'assistant' &&
item.parts.length === 0 &&
!item.knowledgeItems?.length &&
!item.images?.length &&
!item.documents?.length
) {
return [];
}
if (item !== finalSource) {
return [item];
}
const thinkingParts = item.parts.filter((part) => part.type === 'thinking');
if (thinkingParts.length === 0) {
return [];
}
return [
{
...item,
id: `${item.id}-process`,
knowledgeItems: undefined,
parts: thinkingParts,
roundCompleted: false,
},
];
});
});
const artifactItems = computed(() =>
props.items.filter((item) => item.type === 'artifact'),
);
const turnStartedAt = computed(() => {
const starts = props.items
.map((item) => item.turnStartedAt)
.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value),
);
return starts.length > 0 ? Math.min(...starts) : undefined;
});
const turnFinishedAt = computed(() => {
const finishes = props.items
.map((item) => item.turnFinishedAt)
.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value),
);
return finishes.length > 0 ? Math.max(...finishes) : undefined;
});
const turnFinished = computed(() => turnFinishedAt.value !== undefined);
const turnActive = computed(() => !turnSucceeded.value && !turnFinished.value);
const hasPendingApproval = computed(() =>
props.items.some(
(item) =>
item.type === 'tool' &&
(item.status === 'approving' || item.status === 'pending_approval'),
),
);
const hasActiveProcessIndicator = computed(() =>
props.items.some((item) => {
if (item.type === 'tool') {
return ['approving', 'pending_approval', 'running'].includes(item.status);
}
if (item.type === 'status') {
return item.status === 'running';
}
return (
item.type === 'message' &&
item.parts.some(
(part) => part.type === 'thinking' && part.status === 'thinking',
)
);
}),
);
const continuingStatus = computed<ChatTimelineStatusItem | undefined>(() => {
if (!turnActive.value || hasActiveProcessIndicator.value) {
return undefined;
}
return {
id: `chat-turn-continuing-${props.roundId}`,
icon: 'none',
label: '正在继续处理',
presentation: 'inline',
roundId: props.roundId,
status: 'running',
statusKey: `chat-turn-continuing:${props.roundId}`,
turnStartedAt: turnStartedAt.value,
type: 'status',
};
});
const canCollapse = computed(
() =>
turnSucceeded.value &&
Boolean(finalMessage.value) &&
!hasPendingApproval.value,
);
const processId = computed(() => `chat-turn-process-${props.roundId}`);
const expanded = ref(!canCollapse.value);
watch(
canCollapse,
(value, previous) => {
if (value && !previous) {
if (previous === false) {
emit('layoutToggle', props.roundId);
}
expanded.value = false;
if (processItems.value.length === 0) {
void nextTick(() => emit('layoutChanged'));
}
} else if (!value) {
expanded.value = true;
}
},
{ immediate: true },
);
const observedStartedAt = Date.now();
const clockNow = ref(observedStartedAt);
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
function stopElapsedTimer() {
if (elapsedTimer === undefined) {
return;
}
clearInterval(elapsedTimer);
elapsedTimer = undefined;
}
function syncElapsedTimer() {
clockNow.value = Date.now();
stopElapsedTimer();
if (!turnActive.value) {
return;
}
elapsedTimer = setInterval(() => {
clockNow.value = Date.now();
}, 1000);
}
watch([turnStartedAt, turnActive], syncElapsedTimer, { immediate: true });
onBeforeUnmount(stopElapsedTimer);
const durationLabel = computed(() => {
const startedAt =
turnStartedAt.value ?? (turnActive.value ? observedStartedAt : undefined);
const finishedAt =
turnFinishedAt.value ?? (turnActive.value ? clockNow.value : undefined);
if (startedAt === undefined || finishedAt === undefined) {
return '';
}
const seconds = Math.max(1, Math.round((finishedAt - startedAt) / 1000));
if (seconds < 60) {
return `${seconds}`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return `${minutes}${remainingSeconds}`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours} 小时 ${remainingMinutes}${remainingSeconds}`;
});
const summaryLabel = computed(() => {
if (turnSucceeded.value) {
return durationLabel.value ? `已处理 ${durationLabel.value}` : '已处理';
}
if (turnFinished.value) {
return '处理未完成';
}
return `已处理 ${durationLabel.value || '1 秒'}`;
});
function isAssistantActionAnchor(item: ChatTimelineItem) {
return item.type === 'message' && item.id === props.actionAnchorId;
}
function canCopyMessage(item: ChatTimelineItem) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
function canRegenerateMessage(item: ChatTimelineItem) {
return item.type === 'message' && (props.regenerable?.(item) ?? false);
}
function isVariantLoading(item: ChatTimelineItem) {
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
}
function isLiveContentAnchor(item: ChatTimelineItem) {
return !turnSucceeded.value && item === latestAssistantContentSource.value;
}
function toggleProcess() {
if (!canCollapse.value) {
return;
}
emit('layoutToggle', props.roundId);
expanded.value = !expanded.value;
if (processItems.value.length === 0) {
void nextTick(() => emit('layoutChanged'));
}
}
function handleNestedLayoutToggle() {
emit('layoutToggle', props.roundId);
void nextTick(() => emit('layoutChanged'));
}
</script>
<template>
<section class="chat-timeline-turn" :data-round-id="roundId">
<header class="chat-timeline-turn__header">
<span class="chat-timeline-turn__avatar">
<ChatAssistantAvatar :src="assistantAvatar" />
</span>
<button
class="chat-timeline-turn__summary"
:class="{ 'is-toggleable': canCollapse }"
:disabled="!canCollapse"
type="button"
:aria-controls="canCollapse ? processId : undefined"
:aria-expanded="canCollapse ? expanded : undefined"
@click="toggleProcess"
>
<span>{{ summaryLabel }}</span>
<svg
v-if="canCollapse"
class="chat-timeline-turn__chevron"
:class="{ 'is-expanded': expanded }"
aria-hidden="true"
viewBox="0 0 16 16"
>
<path d="M5.5 3.5 10 8l-4.5 4.5" />
</svg>
</button>
</header>
<div class="chat-timeline-turn__content">
<Transition
name="chat-turn-process"
@after-enter="emit('layoutChanged')"
@after-leave="emit('layoutChanged')"
>
<div
v-if="expanded && (processItems.length > 0 || continuingStatus)"
:id="processId"
class="chat-timeline-turn__process"
>
<template v-for="item in processItems" :key="item.id">
<slot
v-if="item.type === 'custom'"
name="custom-item"
:item="item"
></slot>
<ChatTimelineItemView
v-else
:assistant-actions-visible="isAssistantActionAnchor(item)"
:artifact-loader="artifactLoader"
:data-chat-turn-live-anchor="
isLiveContentAnchor(item) ? '' : undefined
"
:item="item"
:document-loader="documentLoader"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"
:copyable="canCopyMessage(item)"
:regenerable="canRegenerateMessage(item)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleNestedLayoutToggle"
/>
</template>
<ChatTimelineItemView
v-if="continuingStatus"
:item="continuingStatus"
/>
</div>
</Transition>
<div v-if="finalMessage" data-chat-turn-final>
<ChatTimelineItemView
:assistant-actions-visible="isAssistantActionAnchor(finalMessage)"
:artifact-loader="artifactLoader"
:item="finalMessage"
:document-loader="documentLoader"
:image-loader="imageLoader"
:copy-action="copyAction"
:copyable="canCopyMessage(finalMessage)"
:regenerable="canRegenerateMessage(finalMessage)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(finalMessage)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
/>
</div>
<div
v-if="artifactItems.length > 0"
class="chat-timeline-turn__artifacts"
>
<ChatTimelineItemView
v-for="item in artifactItems"
:key="item.id"
:artifact-loader="artifactLoader"
:item="item"
/>
</div>
</div>
</section>
</template>
<style scoped>
.chat-timeline-turn {
--chat-turn-avatar-size: 28px;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
min-width: 0;
}
.chat-timeline-turn__header {
display: flex;
gap: var(--space-2);
align-items: center;
min-height: var(--chat-turn-avatar-size);
}
.chat-timeline-turn__avatar {
flex: 0 0 var(--chat-turn-avatar-size);
width: var(--chat-turn-avatar-size);
height: var(--chat-turn-avatar-size);
overflow: hidden;
border-radius: var(--radius-control);
}
.chat-timeline-turn__summary {
display: inline-flex;
gap: var(--space-1);
align-items: center;
min-width: 0;
padding: 0;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 20px;
color: var(--el-text-color-secondary);
appearance: none;
cursor: default;
background: transparent;
border: 0;
}
.chat-timeline-turn__summary.is-toggleable {
cursor: pointer;
}
.chat-timeline-turn__summary.is-toggleable:hover {
color: var(--el-text-color-primary);
}
.chat-timeline-turn__summary.is-toggleable:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 3px;
border-radius: var(--radius-control);
}
.chat-timeline-turn__summary:disabled {
opacity: 1;
}
.chat-timeline-turn__chevron {
width: 14px;
height: 14px;
fill: none;
stroke: currentcolor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.chat-timeline-turn__chevron.is-expanded {
transform: rotate(90deg);
}
.chat-timeline-turn__content {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
padding-inline-start: calc(var(--chat-turn-avatar-size) + var(--space-2));
}
.chat-timeline-turn__process {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.chat-timeline-turn__artifacts {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.chat-turn-process-enter-active,
.chat-turn-process-leave-active {
transition:
opacity var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-standard);
}
.chat-turn-process-enter-from,
.chat-turn-process-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@media (prefers-reduced-motion: reduce) {
.chat-timeline-turn__chevron,
.chat-turn-process-enter-active,
.chat-turn-process-leave-active {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,65 @@
import type { ChatTimelineArtifactItem } from '../types';
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import ChatArtifactAttachment from '../ChatArtifactAttachment.vue';
function artifact(
status: ChatTimelineArtifactItem['status'] = 'available',
): ChatTimelineArtifactItem {
return {
artifactId: '01JTESTARTIFACT',
downloadUrl:
status === 'available'
? '/api/v1/agent/artifacts/01JTESTARTIFACT/content'
: undefined,
fileName: '项目报告.pdf',
id: 'artifact:01JTESTARTIFACT',
mimeType: 'application/pdf',
sha256: 'a'.repeat(64),
size: 2048,
status,
type: 'artifact',
};
}
describe('chat Artifact attachment', () => {
it('支持鉴权下载、失败提示和原位重试', async () => {
const loader = vi
.fn()
.mockRejectedValueOnce(new Error('网络中断'))
.mockResolvedValueOnce(undefined);
const wrapper = mount(ChatArtifactAttachment, {
props: { artifactLoader: loader, item: artifact() },
});
await wrapper.get('button').trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('网络中断,点击重试');
expect(wrapper.get('button').attributes('aria-label')).toContain(
'重试下载',
);
await wrapper.get('button').trigger('click');
await flushPromises();
expect(loader).toHaveBeenCalledTimes(2);
expect(wrapper.text()).toContain('PDF · 2 KB');
});
it.each([
['expired', '已过期'],
['delete_failed', '删除失败'],
['unavailable', '不可用'],
] as const)('展示 %s 状态并禁用下载', (status, label) => {
const loader = vi.fn();
const wrapper = mount(ChatArtifactAttachment, {
props: { artifactLoader: loader, item: artifact(status) },
});
expect(wrapper.text()).toContain(label);
expect(wrapper.get('button').attributes('disabled')).toBeDefined();
expect(wrapper.html()).not.toMatch(/MinIO|bucket|objectKey|workspace/i);
});
});

View File

@@ -1,12 +1,14 @@
import type {ChatTimelineStatusItem} from '../types';
import type { ChatTimelineStatusItem } from '../types';
import {mount} from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import { SkillIcon } from '@easyflow/icons';
import { describe, expect, it } from 'vitest';
import ChatTimelineStatusRow from '../ChatTimelineStatusRow.vue';
describe('ChatTimelineStatusRow', () => {
describe('chatTimelineStatusRow', () => {
it('uses shimmer text while running and static text after done', async () => {
const item: ChatTimelineStatusItem = {
id: 'knowledge-retrieval',
@@ -20,7 +22,9 @@ describe('ChatTimelineStatusRow', () => {
});
expect(wrapper.text()).toContain('正在检索知识库');
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false);
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(
false,
);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
@@ -33,7 +37,9 @@ describe('ChatTimelineStatusRow', () => {
});
expect(wrapper.text()).toContain('已检索知识库');
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain(
'is-active',
);
});
it('renders memory compression status as a separator row', () => {
@@ -52,7 +58,9 @@ describe('ChatTimelineStatusRow', () => {
expect(wrapper.classes()).toContain('is-separator');
expect(wrapper.text()).toContain('正在整理上下文');
expect(wrapper.findAll('.chat-timeline-status-row__line')).toHaveLength(2);
expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe(true);
expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe(
true,
);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
});
@@ -71,7 +79,9 @@ describe('ChatTimelineStatusRow', () => {
expect(wrapper.find('.chat-event-label').exists()).toBe(true);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain(
'is-active',
);
});
it('can render a plain inline status without the context icon', () => {
@@ -88,7 +98,53 @@ describe('ChatTimelineStatusRow', () => {
});
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);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(
false,
);
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(
false,
);
});
it('uses the shared Skill icon, full accessible label and failure tone', () => {
const label = `调用 ${'超长技能名称'.repeat(20)} 失败`;
const item: ChatTimelineStatusItem = {
icon: 'skill',
id: 'skill-invocation:r1:101',
label,
status: 'error',
statusKey: 'skill-invocation:r1:101',
tone: 'danger',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.attributes('aria-label')).toBe(label);
expect(wrapper.attributes('title')).toBe(label);
expect(wrapper.classes()).toContain('is-error');
expect(wrapper.classes()).toContain('is-danger');
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain(
'is-active',
);
});
it('uses the knowledge status shimmer for a running Skill', () => {
const item: ChatTimelineStatusItem = {
icon: 'skill',
id: 'skill-invocation:r1:101',
label: '正在调用 合同审查助手',
status: 'running',
statusKey: 'skill-invocation:r1:101',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
expect(wrapper.findComponent(SkillIcon).exists()).toBe(true);
});
});

View File

@@ -0,0 +1,464 @@
import type { ChatTimelineItem } from '../types';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ChatTimeline from '../ChatTimeline.vue';
import ChatTimelineTurn from '../ChatTimelineTurn.vue';
function completedTurnItems(): ChatTimelineItem[] {
return [
{
id: 'reasoning-1',
parts: [
{
content: '先检索资料',
id: 'thinking-1',
status: 'end',
type: 'thinking',
},
],
role: 'assistant',
roundCompleted: true,
roundId: 'round-1',
status: 'done',
turnFinishedAt: 19_000,
turnStartedAt: 1000,
turnSucceeded: true,
type: 'message',
},
{
id: 'tool-1',
input: { query: 'AG-UI' },
mode: 'auto',
roundCompleted: true,
roundId: 'round-1',
status: 'success',
toolCallId: 'tool-1',
toolName: 'Context7 查询',
turnFinishedAt: 19_000,
turnStartedAt: 1000,
turnSucceeded: true,
type: 'tool',
},
{
id: 'assistant-final',
parts: [
{ content: 'AG-UI 是智能体交互协议。', id: 'text-1', type: 'text' },
],
role: 'assistant',
roundCompleted: true,
roundId: 'round-1',
status: 'done',
turnFinishedAt: 19_000,
turnStartedAt: 1000,
turnSucceeded: true,
type: 'message',
},
];
}
describe('chat timeline turn', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(19_000);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it('shows one running header for an empty RUN_STARTED placeholder', () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: [
{
id: 'turn-round-started',
parts: [],
role: 'assistant',
roundId: 'round-started',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
],
},
});
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1);
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 18 秒',
);
expect(
wrapper.findAll('.chat-timeline-item__assistant-avatar'),
).toHaveLength(0);
expect(wrapper.text()).toContain('正在继续处理');
});
it('keeps continuous feedback around a fast automatic tool call', async () => {
const assistant: ChatTimelineItem = {
id: 'assistant-fast-tool',
parts: [
{
content: '开始生成文件。',
id: 'text-fast-tool',
type: 'text',
},
],
role: 'assistant',
roundId: 'round-fast-tool',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
};
const runningTool: ChatTimelineItem = {
id: 'tool-fast-write',
mode: 'auto',
roundId: 'round-fast-tool',
status: 'running',
toolCallId: 'tool-fast-write',
toolName: 'write_text_file',
turnStartedAt: 1000,
type: 'tool',
};
const wrapper = mount(ChatTimelineTurn, {
props: {
items: [assistant],
roundId: 'round-fast-tool',
},
});
expect(wrapper.text()).toContain('正在继续处理');
await wrapper.setProps({ items: [assistant, runningTool] });
expect(wrapper.text()).toContain('调用中');
expect(wrapper.text()).not.toContain('正在继续处理');
await wrapper.setProps({
items: [assistant, { ...runningTool, status: 'success' }],
});
expect(wrapper.text()).toContain('已完成');
expect(wrapper.text()).toContain('正在继续处理');
});
it('updates the running duration every second and freezes it on success', async () => {
vi.setSystemTime(1000);
const runningItem: ChatTimelineItem = {
id: 'turn-round-live',
parts: [],
role: 'assistant',
roundId: 'round-live',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
};
const wrapper = mount(ChatTimelineTurn, {
props: {
items: [runningItem],
roundId: 'round-live',
},
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 秒',
);
expect(vi.getTimerCount()).toBe(1);
vi.advanceTimersByTime(64_000);
await nextTick();
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 分 4 秒',
);
await wrapper.setProps({
items: [
{
...runningItem,
roundCompleted: true,
status: 'done',
turnFinishedAt: 65_000,
turnSucceeded: true,
},
],
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 分 4 秒',
);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(10_000);
await nextTick();
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 分 4 秒',
);
});
it('switches the running timer to the incomplete terminal state', async () => {
vi.setSystemTime(5000);
const runningItem: ChatTimelineItem = {
id: 'turn-round-error',
parts: [],
role: 'assistant',
roundId: 'round-error',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
};
const wrapper = mount(ChatTimelineTurn, {
props: {
items: [runningItem],
roundId: 'round-error',
},
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 4 秒',
);
await wrapper.setProps({
items: [
{
...runningItem,
status: 'error',
turnFinishedAt: 5000,
turnSucceeded: false,
},
],
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'处理未完成',
);
expect(vi.getTimerCount()).toBe(0);
});
it('keeps partial assistant text in event order while the turn is active', () => {
const items: ChatTimelineItem[] = [
{
id: 'assistant-partial',
parts: [
{
content: '先检查依赖',
id: 'thinking-1',
status: 'end',
type: 'thinking',
},
{ content: '正文 A', id: 'text-1', type: 'text' },
],
role: 'assistant',
roundId: 'round-live-order',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
{
id: 'tool-completed',
mode: 'auto',
roundId: 'round-live-order',
status: 'success',
toolCallId: 'tool-completed',
toolName: '已执行工具',
turnStartedAt: 1000,
type: 'tool',
},
{
id: 'assistant-reasoning',
parts: [
{
content: '继续思考',
id: 'thinking-2',
status: 'thinking',
type: 'thinking',
},
],
role: 'assistant',
roundId: 'round-live-order',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
{
approval: {
approvalId: 'approval-1',
toolCallId: 'tool-pending',
toolName: '待审批工具',
},
id: 'tool-pending',
mode: 'approval',
roundId: 'round-live-order',
status: 'pending_approval',
toolCallId: 'tool-pending',
toolName: '待审批工具',
turnStartedAt: 1000,
type: 'tool',
},
];
const wrapper = mount(ChatTimelineTurn, {
props: { items, roundId: 'round-live-order' },
});
const rendered = wrapper.text();
expect(wrapper.find('[data-chat-turn-final]').exists()).toBe(false);
expect(rendered.indexOf('正文 A')).toBeLessThan(
rendered.indexOf('已执行工具'),
);
expect(rendered.indexOf('已执行工具')).toBeLessThan(
rendered.indexOf('继续思考'),
);
expect(rendered.indexOf('继续思考')).toBeLessThan(
rendered.indexOf('待审批工具'),
);
});
it('renders one avatar and collapses process after a successful turn', async () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: completedTurnItems(),
},
});
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1);
expect(
wrapper.findAll('.chat-timeline-item__assistant-avatar'),
).toHaveLength(0);
expect(wrapper.find('.chat-timeline-turn__summary').text()).toContain(
'已处理 18 秒',
);
expect(wrapper.text()).toContain('AG-UI 是智能体交互协议。');
expect(wrapper.text()).not.toContain('Context7 查询');
expect(wrapper.text()).not.toContain('先检索资料');
await wrapper.find('.chat-timeline-turn__summary').trigger('click');
expect(wrapper.text()).toContain('Context7 查询');
expect(wrapper.text()).toContain('已思考');
expect(wrapper.text()).toContain('AG-UI 是智能体交互协议。');
});
it('preserves the final-message scroll anchor while auto-collapsing', async () => {
const runningItems = completedTurnItems().map((item) => {
const {
roundCompleted: _roundCompleted,
turnFinishedAt: _turnFinishedAt,
turnSucceeded: _turnSucceeded,
...runningItem
} = item;
return runningItem;
});
const wrapper = mount(ChatTimeline, {
attachTo: document.body,
global: { stubs: { Transition: false } },
props: {
assistantAvatar: '/assistant.svg',
items: runningItems,
},
});
const container = wrapper.find('.chat-timeline').element as HTMLElement;
Object.defineProperties(container, {
clientHeight: { configurable: true, value: 500 },
scrollHeight: { configurable: true, value: 1200 },
scrollTop: { configurable: true, value: 400, writable: true },
});
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
bottom: 500,
top: 0,
} as DOMRect);
const header = wrapper.find('.chat-timeline-turn__header')
.element as HTMLElement;
vi.spyOn(header, 'getBoundingClientRect').mockReturnValue({
bottom: -72,
top: -100,
} as DOMRect);
const liveMessage = wrapper.find('[data-chat-turn-live-anchor]')
.element as HTMLElement;
vi.spyOn(liveMessage, 'getBoundingClientRect')
.mockReturnValueOnce({ bottom: 700, top: 600 } as DOMRect)
.mockReturnValue({ bottom: 400, top: 300 } as DOMRect);
await wrapper.find('.chat-timeline').trigger('scroll');
wrapper.findComponent(ChatTimelineTurn).vm.$emit('layoutToggle', 'round-1');
await wrapper.setProps({ items: completedTurnItems() });
await nextTick();
wrapper.findComponent(ChatTimelineTurn).vm.$emit('layoutChanged');
await nextTick();
expect(container.scrollTop).toBe(100);
wrapper.unmount();
});
it('keeps running and approval content expanded in one turn', () => {
const items: ChatTimelineItem[] = [
{
id: 'reasoning-1',
parts: [
{
content: '准备调用工具',
id: 'thinking-1',
status: 'thinking',
type: 'thinking',
},
],
role: 'assistant',
roundId: 'round-1',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
{
approval: {
approvalId: 'approval-1',
toolCallId: 'tool-1',
toolName: 'context7',
},
id: 'tool-1',
mode: 'approval',
roundId: 'round-1',
status: 'pending_approval',
toolCallId: 'tool-1',
toolName: 'Context7 查询',
turnStartedAt: 1000,
type: 'tool',
},
];
const wrapper = mount(ChatTimeline, {
props: { assistantAvatar: '/assistant.svg', items },
});
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1);
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 18 秒',
);
expect(wrapper.text()).toContain('准备调用工具');
expect(wrapper.text()).toContain('Context7 查询');
expect(
wrapper.find('.chat-timeline-turn__summary').attributes('disabled'),
).toBeDefined();
});
it('renders one avatar for each of multiple turns', () => {
const secondTurn = completedTurnItems().map((item) => ({
...item,
id: `${item.id}-2`,
roundId: 'round-2',
}));
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: [...completedTurnItems(), ...secondTurn],
},
});
expect(wrapper.findAll('.chat-timeline-turn')).toHaveLength(2);
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(2);
});
});

View File

@@ -1,8 +1,8 @@
import type {ChatTimelineItem} from '../types';
import type { ChatTimelineItem } from '../types';
import {describe, expect, it} from 'vitest';
import { describe, expect, it } from 'vitest';
import {ChatTimelineBuilder} from '../builder';
import { ChatTimelineBuilder } from '../builder';
describe('chat timeline builder', () => {
it('keeps streamed thinking, text, tool and following text in timeline order', () => {
@@ -156,6 +156,72 @@ describe('chat timeline builder', () => {
}
});
it('updates one Skill invocation row in place and preserves Skill order', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
displayName: '合同审查助手',
status: 'RUNNING',
statusKey: 'skill-invocation:r1:101',
});
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
displayName: '数据分析助手',
status: 'RUNNING',
statusKey: 'skill-invocation:r1:102',
});
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
displayName: '合同审查助手',
status: 'SUCCESS',
statusKey: 'skill-invocation:r1:101',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
icon: 'skill',
label: '已调用 合同审查助手',
status: 'done',
});
expect(items[1]).toMatchObject({
icon: 'skill',
label: '正在调用 数据分析助手',
status: 'running',
});
});
it('never turns an unterminated Skill invocation into fake success', () => {
const incompleteItems: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertSkillInvocationStatus(incompleteItems, {
displayName: '合同审查助手',
roundId: 'r1',
status: 'RUNNING',
statusKey: 'skill-invocation:r1:101',
});
ChatTimelineBuilder.finalize(incompleteItems, { roundId: 'r1' });
expect(incompleteItems[0]).toMatchObject({
label: '调用 合同审查助手 未完成',
status: 'incomplete',
});
const cancelledItems: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertSkillInvocationStatus(cancelledItems, {
displayName: '合同审查助手',
roundId: 'r2',
status: 'RUNNING',
statusKey: 'skill-invocation:r2:101',
});
ChatTimelineBuilder.finalize(
cancelledItems,
{ roundId: 'r2' },
{ runningSkillStatus: 'cancelled' },
);
expect(cancelledItems[0]).toMatchObject({
label: '已停止调用 合同审查助手',
status: 'cancelled',
});
});
it('removes memory compression status when compression produced no compressed event', () => {
const items: ChatTimelineItem[] = [];
@@ -352,15 +418,13 @@ describe('chat timeline builder', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
toolName: '审批工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.markToolApproving(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
});
ChatTimelineBuilder.upsertToolCall(items, {
@@ -379,7 +443,7 @@ describe('chat timeline builder', () => {
if (items[0]?.type === 'tool') {
expect(items[0].mode).toBe('approval');
expect(items[0].status).toBe('success');
expect(items[0].approval?.requestId).toBe('request-1');
expect(items[0].approval?.approvalId).toBe('approval-1');
expect(items[0].input).toEqual({ keyword: 'EasyFlow' });
expect(items[0].output).toEqual({ result: 'ok' });
}
@@ -389,8 +453,7 @@ describe('chat timeline builder', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'submit-call-1',
toolName: '文档生成',
input: { user_input: '写一篇小作文' },
@@ -452,14 +515,13 @@ describe('chat timeline builder', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
toolName: '审批工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.markToolRejected(items, {
requestId: 'request-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
reason: '用户拒绝执行',
});
@@ -482,8 +544,7 @@ describe('chat timeline builder', () => {
input: { keyword: 'before approval' },
});
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-2',
toolName: '查询工具',
input: { keyword: 'approval' },
@@ -498,7 +559,7 @@ describe('chat timeline builder', () => {
expect(items[1].toolCallId).toBe('call-2');
expect(items[1].mode).toBe('approval');
expect(items[1].status).toBe('pending_approval');
expect(items[1].approval?.requestId).toBe('request-1');
expect(items[1].approval?.approvalId).toBe('approval-1');
expect(items[1].input).toEqual({ keyword: 'approval' });
}
});

View File

@@ -1,8 +1,12 @@
import type {
ChatArtifactAttachment,
ChatTimelineArtifactItem,
ChatTimelineItem,
ChatTimelineItemBase,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineSkillInvocationStatus,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,
@@ -13,6 +17,17 @@ import type {
ChatTimelineToolStatus,
} from './types';
type ChatTimelineTurnMetadata = Partial<
Pick<
ChatTimelineItemBase,
| 'roundCompleted'
| 'roundId'
| 'turnFinishedAt'
| 'turnStartedAt'
| 'turnSucceeded'
>
>;
function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
@@ -73,6 +88,24 @@ function ensureMessageTail(
Object.assign(last, metadata);
return last;
}
const placeholder =
role === 'assistant' && metadata?.roundId
? [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
item.roundId === metadata.roundId &&
item.status !== 'done' &&
item.parts.length === 0,
)
: undefined;
if (placeholder) {
placeholder.status = status;
Object.assign(placeholder, metadata);
return placeholder;
}
const item: ChatTimelineMessageItem = {
id: createId(role),
role,
@@ -138,19 +171,18 @@ function updateThinkingStatus(
);
}
function finishLastAssistantMessage(items: ChatTimelineItem[]) {
finishAssistantMessage(items, true);
}
function finishAssistantMessage(
items: ChatTimelineItem[],
roundCompleted: boolean,
roundId?: string,
) {
const lastMessage = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
item.type === 'message' &&
item.role === 'assistant' &&
(!roundId || item.roundId === roundId),
);
if (!lastMessage) {
return;
@@ -167,6 +199,7 @@ function findToolItem(
toolCallId?: string,
taskId?: string,
sourceToolCallId?: string,
approvalId?: string,
) {
const identities = new Set(
[toolCallId, sourceToolCallId]
@@ -174,13 +207,16 @@ function findToolItem(
.filter(Boolean),
);
const normalizedTaskId = normalizeText(taskId).trim();
if (identities.size === 0 && !normalizedTaskId) {
const normalizedApprovalId = normalizeText(approvalId).trim();
if (identities.size === 0 && !normalizedTaskId && !normalizedApprovalId) {
return undefined;
}
return items.find(
(item): item is ChatTimelineToolItem =>
item.type === 'tool' &&
((Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
((Boolean(normalizedApprovalId) &&
item.approval?.approvalId === normalizedApprovalId) ||
(Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
(item.toolCallId ? identities.has(item.toolCallId) : false)),
);
}
@@ -211,9 +247,33 @@ function doneStatusLabel(item: ChatTimelineStatusItem) {
return item.label.replace(/^正在/, '已');
}
function finishRunningStatusItems(items: ChatTimelineItem[]) {
function skillTerminalLabel(
label: string,
status: Extract<ChatTimelineStatusStatus, 'cancelled' | 'incomplete'>,
) {
const name = label.replace(/^正在调用\s*/, '').trim() || '技能';
return status === 'cancelled' ? `已停止调用 ${name}` : `调用 ${name} 未完成`;
}
function finishRunningStatusItems(
items: ChatTimelineItem[],
roundId?: string,
skillTerminalStatus: Extract<
ChatTimelineStatusStatus,
'cancelled' | 'incomplete'
> = 'incomplete',
) {
items.forEach((item) => {
if (item.type !== 'status' || item.status !== 'running') {
if (
item.type !== 'status' ||
item.status !== 'running' ||
(roundId && item.roundId !== roundId)
) {
return;
}
if (item.icon === 'skill') {
item.status = skillTerminalStatus;
item.label = skillTerminalLabel(item.label, skillTerminalStatus);
return;
}
item.status = 'done';
@@ -221,9 +281,36 @@ function finishRunningStatusItems(items: ChatTimelineItem[]) {
});
}
function skillStatusPresentation(status: ChatTimelineSkillInvocationStatus) {
switch (status) {
case 'CANCELLED': {
return {
prefix: '已停止调用',
status: 'cancelled' as const,
};
}
case 'FAILED': {
return { prefix: '调用', status: 'error' as const, suffix: '失败' };
}
case 'INCOMPLETE': {
return {
prefix: '调用',
status: 'incomplete' as const,
suffix: '未完成',
};
}
case 'RUNNING': {
return { prefix: '正在调用', status: 'running' as const };
}
case 'SUCCESS': {
return { prefix: '已调用', status: 'done' as const };
}
}
}
function upsertStatus(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
label: string;
presentation?: ChatTimelineStatusItem['presentation'];
status: ChatTimelineStatusStatus;
@@ -237,6 +324,7 @@ function upsertStatus(
found.presentation = payload.presentation ?? found.presentation;
found.status = payload.status;
found.tone = payload.tone ?? found.tone;
applyTurnMetadata(found, payload);
return found;
}
const item: ChatTimelineStatusItem = {
@@ -249,20 +337,20 @@ function upsertStatus(
tone: payload.tone ?? 'muted',
type: 'status',
};
applyTurnMetadata(item, payload);
items.push(item);
return item;
}
function upsertTool(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
approval?: ChatTimelineToolApprovalPayload;
approvalId?: string;
input?: unknown;
mode?: ChatTimelineToolMode;
output?: unknown;
rejectReason?: string;
requestId?: string;
resumeToken?: string;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus;
taskId?: string;
@@ -279,6 +367,7 @@ function upsertTool(
toolCallId,
taskId,
payload.sourceToolCallId,
payload.approvalId ?? payload.approval?.approvalId,
);
const approval = payload.approval ?? found?.approval;
const mode =
@@ -310,6 +399,7 @@ function upsertTool(
found.taskId = taskId || found.taskId;
found.toolCallId = toolCallId || found.toolCallId;
found.toolName = toolName || found.toolName;
applyTurnMetadata(found, payload);
return found;
}
@@ -330,11 +420,49 @@ function upsertTool(
toolName: toolName || '工具调用',
type: 'tool',
};
applyTurnMetadata(toolItem, payload);
items.push(toolItem);
return toolItem;
}
function applyTurnMetadata(
item: ChatTimelineItemBase,
metadata?: ChatTimelineTurnMetadata,
) {
if (!metadata) {
return;
}
if (metadata.roundId) {
item.roundId = metadata.roundId;
}
if (metadata.roundCompleted !== undefined) {
item.roundCompleted = metadata.roundCompleted;
}
if (metadata.turnSucceeded !== undefined) {
item.turnSucceeded = metadata.turnSucceeded;
}
if (metadata.turnStartedAt !== undefined) {
item.turnStartedAt = Math.min(
item.turnStartedAt ?? metadata.turnStartedAt,
metadata.turnStartedAt,
);
}
if (metadata.turnFinishedAt !== undefined) {
item.turnFinishedAt = Math.max(
item.turnFinishedAt ?? metadata.turnFinishedAt,
metadata.turnFinishedAt,
);
}
}
export const ChatTimelineBuilder = {
ensureAssistantTurn(
items: ChatTimelineItem[],
metadata?: Partial<ChatTimelineMessageItem>,
) {
ensureMessageTail(items, 'assistant', 'streaming', metadata);
},
appendUserMessage(
items: ChatTimelineItem[],
content?: unknown,
@@ -410,12 +538,45 @@ export const ChatTimelineBuilder = {
appendTextPart(message, text);
},
replaceMessageContent(items: ChatTimelineItem[], content?: unknown) {
replaceMessageContent(
items: ChatTimelineItem[],
content?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(content);
if (!text) {
return;
const message =
(metadata?.id
? items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.id === metadata.id,
)
: undefined) ||
[...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
) ||
ensureMessageTail(items, 'assistant', 'done', metadata);
for (let index = items.length - 1; index >= 0; index--) {
const item = items[index];
if (
item === message ||
item?.type !== 'message' ||
item.role !== 'assistant' ||
(metadata?.roundId && item.roundId !== metadata.roundId)
) {
continue;
}
item.parts = item.parts.filter((part) => part.type !== 'text');
if (item.parts.length === 0) {
items.splice(index, 1);
}
}
const message = ensureMessageTail(items, 'assistant', 'done');
Object.assign(message, metadata);
message.status = 'done';
updateThinkingStatus(message, 'end');
replaceTextPart(message, text);
},
@@ -423,8 +584,10 @@ export const ChatTimelineBuilder = {
appendToolApproval(
items: ChatTimelineItem[],
payload: ChatTimelineToolApprovalPayload,
metadata?: ChatTimelineTurnMetadata,
) {
upsertTool(items, {
...metadata,
approval: payload,
input: payload.input,
mode: 'approval',
@@ -436,7 +599,8 @@ export const ChatTimelineBuilder = {
upsertToolCall(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
input?: unknown;
output?: unknown;
sourceToolCallId?: string;
@@ -452,6 +616,7 @@ export const ChatTimelineBuilder = {
items,
payload.status === 'success' ? 'done' : 'running',
payload.statusKey,
payload,
);
return;
}
@@ -466,9 +631,11 @@ export const ChatTimelineBuilder = {
items: ChatTimelineItem[],
status: ChatTimelineStatusStatus,
statusKey?: string,
metadata?: ChatTimelineTurnMetadata,
) {
finishAssistantMessage(items, false);
finishAssistantMessage(items, false, metadata?.roundId);
upsertStatus(items, {
...metadata,
label: status === 'running' ? '正在检索知识库' : '已检索知识库',
status,
statusKey: knowledgeRetrievalStatusKey(statusKey),
@@ -476,9 +643,32 @@ export const ChatTimelineBuilder = {
});
},
upsertSkillInvocationStatus(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
displayName?: string;
status: ChatTimelineSkillInvocationStatus;
statusKey: string;
},
) {
const displayName = normalizeText(payload.displayName).trim() || '技能';
const presentation = skillStatusPresentation(payload.status);
const label = [presentation.prefix, displayName, presentation.suffix]
.filter(Boolean)
.join(' ');
finishAssistantMessage(items, false, payload.roundId);
upsertStatus(items, {
...payload,
label,
status: presentation.status,
statusKey: payload.statusKey,
tone: payload.status === 'FAILED' ? 'danger' : 'muted',
}).icon = 'skill';
},
upsertMemoryCompressionStatus(
items: ChatTimelineItem[],
payload?: {
payload?: ChatTimelineTurnMetadata & {
compressed?: boolean;
label?: string;
phase?: string;
@@ -491,7 +681,7 @@ export const ChatTimelineBuilder = {
? 'done'
: 'running';
const statusKey = payload?.statusKey || 'memory-compression';
finishAssistantMessage(items, false);
finishAssistantMessage(items, false, payload?.roundId);
if (status === 'done' && payload?.compressed === false) {
removeStatusItem(items, statusKey);
return;
@@ -501,6 +691,7 @@ export const ChatTimelineBuilder = {
? payload?.label || '正在整理上下文'
: payload?.label || '已整理上下文';
upsertStatus(items, {
...payload,
label,
status,
statusKey,
@@ -511,9 +702,8 @@ export const ChatTimelineBuilder = {
markToolApproving(
items: ChatTimelineItem[],
payload: {
requestId?: string;
resumeToken?: string;
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
toolCallId?: string;
},
) {
@@ -526,10 +716,9 @@ export const ChatTimelineBuilder = {
markToolRejected(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
reason?: string;
requestId?: string;
resumeToken?: string;
toolCallId?: string;
},
) {
@@ -544,6 +733,7 @@ export const ChatTimelineBuilder = {
appendKnowledge(
items: ChatTimelineItem[],
knowledgeItems: ChatTimelineKnowledgeHit[],
metadata?: ChatTimelineTurnMetadata,
) {
if (knowledgeItems.length === 0) {
return;
@@ -552,9 +742,12 @@ export const ChatTimelineBuilder = {
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
);
if (lastAssistantMessage) {
applyTurnMetadata(lastAssistantMessage, metadata);
lastAssistantMessage.knowledgeItems = [
...(lastAssistantMessage.knowledgeItems || []),
...knowledgeItems,
@@ -562,36 +755,119 @@ export const ChatTimelineBuilder = {
return;
}
const last = items[items.length - 1];
if (last?.type === 'knowledge') {
if (
last?.type === 'knowledge' &&
(!metadata?.roundId || last.roundId === metadata.roundId)
) {
applyTurnMetadata(last, metadata);
last.items.push(...knowledgeItems);
return;
}
items.push({
const item = {
id: createId('knowledge'),
createdAt: Date.now(),
items: knowledgeItems,
type: 'knowledge',
});
type: 'knowledge' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
appendError(items: ChatTimelineItem[], message?: unknown) {
upsertArtifact(
items: ChatTimelineItem[],
artifact: ChatArtifactAttachment,
metadata?: ChatTimelineTurnMetadata,
) {
const artifactId = normalizeText(artifact.artifactId).trim();
const fileName = normalizeText(artifact.fileName).trim();
if (!artifactId || !fileName) {
return;
}
const existing = items.find(
(item): item is ChatTimelineArtifactItem =>
item.type === 'artifact' && item.artifactId === artifactId,
);
if (existing) {
existing.downloadUrl = artifact.downloadUrl;
existing.fileName = fileName;
existing.mimeType = artifact.mimeType;
existing.sha256 = artifact.sha256;
existing.size = artifact.size;
existing.status = artifact.status;
applyTurnMetadata(existing, metadata);
return;
}
const item = {
artifactId,
createdAt: Date.now(),
downloadUrl: artifact.downloadUrl,
fileName,
id: `artifact:${artifactId}`,
mimeType: artifact.mimeType,
sha256: artifact.sha256,
size: artifact.size,
status: artifact.status,
type: 'artifact' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
appendError(
items: ChatTimelineItem[],
message?: unknown,
metadata?: ChatTimelineTurnMetadata,
) {
const text = normalizeText(message) || '请求失败';
const last = items[items.length - 1];
if (last?.type === 'message' && last.role === 'assistant') {
const last = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
);
if (last) {
updateThinkingStatus(last, 'error');
last.status = 'error';
}
items.push({
const item = {
id: createId('error'),
createdAt: Date.now(),
message: text,
type: 'error',
});
type: 'error' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
finalize(items: ChatTimelineItem[]) {
finishRunningStatusItems(items);
finishLastAssistantMessage(items);
finalize(
items: ChatTimelineItem[],
metadata?: ChatTimelineTurnMetadata,
options?: {
runningSkillStatus?: Extract<
ChatTimelineStatusStatus,
'cancelled' | 'incomplete'
>;
},
) {
finishRunningStatusItems(
items,
metadata?.roundId,
options?.runningSkillStatus,
);
finishAssistantMessage(
items,
metadata?.turnSucceeded ?? true,
metadata?.roundId,
);
if (metadata?.roundId) {
for (const item of items) {
if (item.roundId === metadata.roundId) {
applyTurnMetadata(item, metadata);
}
}
}
},
replaceRoundAssistant(

View File

@@ -1,5 +1,6 @@
export { defaultAssistantAvatar } from './assistantAvatar';
export { ChatTimelineBuilder } from './builder';
export { default as ChatArtifactCard } from './ChatArtifactAttachment.vue';
export { default as ChatAssistantAvatar } from './ChatAssistantAvatar.vue';
export { default as ChatDocumentAttachments } from './ChatDocumentAttachments.vue';
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
@@ -14,10 +15,14 @@ export { default as ChatToolApprovalCard } from './ChatToolApprovalCard.vue';
export { default as ChatToolCard } from './ChatToolCard.vue';
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
export type {
ChatArtifactAttachment,
ChatArtifactLoader,
ChatArtifactStatus,
ChatDocumentAttachment,
ChatDocumentLoader,
ChatImageAttachment,
ChatImageLoader,
ChatTimelineArtifactItem,
ChatTimelineCustomItem,
ChatTimelineErrorItem,
ChatTimelineItem,
@@ -27,6 +32,7 @@ export type {
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineRole,
ChatTimelineSkillInvocationStatus,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,

View File

@@ -9,8 +9,38 @@ export type ChatTimelineToolStatus =
| 'rejected'
| 'running'
| 'success';
export type ChatTimelineStatusStatus = 'done' | 'running';
export type ChatTimelineStatusTone = 'muted';
export type ChatTimelineStatusStatus =
| 'cancelled'
| 'done'
| 'error'
| 'incomplete'
| 'running';
export type ChatTimelineStatusTone = 'danger' | 'muted';
export type ChatTimelineSkillInvocationStatus =
| 'CANCELLED'
| 'FAILED'
| 'INCOMPLETE'
| 'RUNNING'
| 'SUCCESS';
export type ChatArtifactStatus =
| 'available'
| 'delete_failed'
| 'expired'
| 'unavailable';
export interface ChatArtifactAttachment {
artifactId: string;
downloadUrl?: string;
fileName: string;
mimeType?: string;
sha256?: string;
size?: number;
status: ChatArtifactStatus;
}
export type ChatArtifactLoader = (
artifact: ChatArtifactAttachment,
) => Promise<void>;
export interface ChatImageAttachment {
error?: string;
@@ -47,8 +77,7 @@ export type ChatDocumentLoader = (
) => Promise<void>;
export interface ChatTimelineToolApprovalPayload {
requestId: string;
resumeToken: string;
approvalId: string;
toolName: string;
toolDisplayName?: string;
toolCallId?: string;
@@ -81,6 +110,11 @@ export interface ChatTimelineKnowledgeHit {
export interface ChatTimelineItemBase {
createdAt?: number;
id: string;
roundCompleted?: boolean;
roundId?: string;
turnFinishedAt?: number;
turnStartedAt?: number;
turnSucceeded?: boolean;
}
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
@@ -90,8 +124,6 @@ export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
parts: ChatTimelineMessagePart[];
regenerable?: boolean;
role: ChatTimelineRole;
roundId?: string;
roundCompleted?: boolean;
roundNo?: number;
status?: ChatTimelineItemStatus;
selectedVariantIndex?: number;
@@ -127,8 +159,14 @@ export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase {
type: 'knowledge';
}
export interface ChatTimelineArtifactItem
extends ChatArtifactAttachment,
ChatTimelineItemBase {
type: 'artifact';
}
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
icon?: 'book' | 'none';
icon?: 'book' | 'none' | 'skill';
label: string;
presentation?: 'inline' | 'separator';
status: ChatTimelineStatusStatus;
@@ -149,6 +187,7 @@ export interface ChatTimelineCustomItem extends ChatTimelineItemBase {
}
export type ChatTimelineItem =
| ChatTimelineArtifactItem
| ChatTimelineCustomItem
| ChatTimelineErrorItem
| ChatTimelineKnowledgeItem