perf: 优化 Agent 流式会话处理

- 合并流式通知与持久化写入,限制浏览器会话缓存

- 增量投影轮次事件并稳定关联异步工具任务
This commit is contained in:
2026-07-27 19:41:11 +08:00
parent 5497931abd
commit b08eb009bb
10 changed files with 539 additions and 47 deletions

View File

@@ -6,7 +6,7 @@ import type {
ChatTimelineToolApprovalPayload,
} from './types';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
@@ -38,6 +38,15 @@ let preservedScrollTop: number | undefined;
const bottomThreshold = 24;
let scrollFrame = 0;
const assistantActionAnchorIds = 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());
});
function isNearBottom(container: HTMLElement) {
return (
@@ -85,26 +94,13 @@ function canRegenerateMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.regenerable?.(item) ?? false);
}
function isAssistantActionAnchor(
item: ChatTimelineItemType,
index: number,
items: ChatTimelineItemType[],
) {
if (item.type !== 'message' || item.role !== 'assistant' || !item.roundId) {
return false;
}
for (let cursor = items.length - 1; cursor >= 0; cursor -= 1) {
const current = items[cursor];
if (
current &&
current.type === 'message' &&
current.role === 'assistant' &&
current.roundId === item.roundId
) {
return cursor === index;
}
}
return false;
function isAssistantActionAnchor(item: ChatTimelineItemType) {
return (
item.type === 'message' &&
item.role === 'assistant' &&
Boolean(item.roundId) &&
assistantActionAnchorIds.value.has(item.id)
);
}
function isVariantLoading(item: ChatTimelineItemType) {
@@ -152,9 +148,9 @@ watch(
</div>
<template v-else>
<ChatTimelineItem
v-for="(item, index) in items"
v-for="item in items"
:key="item.id"
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
:assistant-actions-visible="isAssistantActionAnchor(item)"
:item="item"
:image-loader="imageLoader"
:approval-loading="approvalLoading"

View File

@@ -385,6 +385,69 @@ describe('chat timeline builder', () => {
}
});
it('keeps async submit and polling lifecycle in the approval card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
toolCallId: 'submit-call-1',
toolName: '文档生成',
input: { user_input: '写一篇小作文' },
});
ChatTimelineBuilder.upsertToolCall(items, {
sourceToolCallId: 'submit-call-1',
status: 'running',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
});
ChatTimelineBuilder.upsertToolCall(items, {
sourceToolCallId: 'observe-call-1',
status: 'running',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
});
ChatTimelineBuilder.upsertToolCall(items, {
output: '文档生成已完成',
sourceToolCallId: 'result-call-1',
status: 'success',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
mode: 'approval',
status: 'success',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
type: 'tool',
});
});
it('keeps different async tasks as separate tool cards', () => {
const items: ChatTimelineItem[] = [];
for (const taskId of ['task-1', 'task-2']) {
ChatTimelineBuilder.upsertToolCall(items, {
sourceToolCallId: `${taskId}-observe`,
status: 'running',
taskId,
toolCallId: taskId,
toolName: '文档生成',
});
}
expect(items).toHaveLength(2);
expect(
items.map((item) => (item.type === 'tool' ? item.taskId : undefined)),
).toEqual(['task-1', 'task-2']);
});
it('marks approval tool rejected in the same card', () => {
const items: ChatTimelineItem[] = [];

View File

@@ -162,14 +162,26 @@ function finishAssistantMessage(
}
}
function findToolItem(items: ChatTimelineItem[], toolCallId?: string) {
const identity = normalizeText(toolCallId).trim();
if (!identity) {
function findToolItem(
items: ChatTimelineItem[],
toolCallId?: string,
taskId?: string,
sourceToolCallId?: string,
) {
const identities = new Set(
[toolCallId, sourceToolCallId]
.map((value) => normalizeText(value).trim())
.filter(Boolean),
);
const normalizedTaskId = normalizeText(taskId).trim();
if (identities.size === 0 && !normalizedTaskId) {
return undefined;
}
return items.find(
(item): item is ChatTimelineToolItem =>
item.type === 'tool' && item.toolCallId === identity,
item.type === 'tool' &&
((Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
(item.toolCallId ? identities.has(item.toolCallId) : false)),
);
}
@@ -251,7 +263,9 @@ function upsertTool(
rejectReason?: string;
requestId?: string;
resumeToken?: string;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus;
taskId?: string;
toolCallId?: string;
toolName?: string;
},
@@ -259,7 +273,13 @@ function upsertTool(
const toolCallId = normalizeText(
payload.toolCallId ?? payload.approval?.toolCallId,
).trim();
const found = findToolItem(items, toolCallId);
const taskId = normalizeText(payload.taskId).trim();
const found = findToolItem(
items,
toolCallId,
taskId,
payload.sourceToolCallId,
);
const approval = payload.approval ?? found?.approval;
const mode =
payload.mode === 'approval'
@@ -287,6 +307,7 @@ function upsertTool(
found.output = normalizePayloadValue(payload.output) ?? found.output;
found.rejectReason = payload.rejectReason ?? found.rejectReason;
found.status = payload.status || found.status;
found.taskId = taskId || found.taskId;
found.toolCallId = toolCallId || found.toolCallId;
found.toolName = toolName || found.toolName;
return found;
@@ -304,6 +325,7 @@ function upsertTool(
rejectReason: payload.rejectReason,
status:
payload.status || (mode === 'approval' ? 'pending_approval' : 'running'),
taskId: taskId || undefined,
toolCallId,
toolName: toolName || '工具调用',
type: 'tool',
@@ -417,8 +439,10 @@ export const ChatTimelineBuilder = {
payload: {
input?: unknown;
output?: unknown;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus;
statusKey?: string;
taskId?: string;
toolCallId?: string;
toolName?: string;
},

View File

@@ -89,6 +89,7 @@ export interface ChatTimelineToolItem extends ChatTimelineItemBase {
output?: unknown;
rejectReason?: string;
status: ChatTimelineToolStatus;
taskId?: string;
toolCallId?: string;
toolName: string;
type: 'tool';