feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -50,6 +50,11 @@ export const LOCAL_ICON_DATA: Record<string, IconifyIcon> = {
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 7v14m4-9h2m-2-4h2M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4a4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3a3 3 0 0 0-3-3zm3-6h2M6 8h2"/>',
|
||||
},
|
||||
'lucide:notebook-tabs': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2 6h4m-4 4h4m-4 4h4m-4 4h4"/><rect width="16" height="20" x="4" y="2" rx="2"/><path d="M15 2v20m0-15h5m-5 5h5m-5 5h5"/></g>',
|
||||
},
|
||||
'lucide:copyright': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
|
||||
@@ -57,6 +57,7 @@ export {
|
||||
Minimize,
|
||||
Minimize2,
|
||||
MoonStar,
|
||||
NotebookTabs,
|
||||
Palette,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
@@ -78,3 +79,8 @@ export {
|
||||
UserRoundPen,
|
||||
X,
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
export {
|
||||
BookOpenText as KnowledgeIcon,
|
||||
NotebookTabs as SkillIcon,
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user