perf: 优化 Agent 流式会话处理
- 合并流式通知与持久化写入,限制浏览器会话缓存 - 增量投影轮次事件并稳定关联异步工具任务
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -89,6 +89,7 @@ export interface ChatTimelineToolItem extends ChatTimelineItemBase {
|
||||
output?: unknown;
|
||||
rejectReason?: string;
|
||||
status: ChatTimelineToolStatus;
|
||||
taskId?: string;
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
type: 'tool';
|
||||
|
||||
Reference in New Issue
Block a user