perf: 优化 Agent 流式会话处理
- 合并流式通知与持久化写入,限制浏览器会话缓存 - 增量投影轮次事件并稳定关联异步工具任务
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
@@ -23,6 +23,10 @@ describe('agentChatRuntimeManager', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('replaces draft image URLs and isolates snapshots by account', async () => {
|
it('replaces draft image URLs and isolates snapshots by account', async () => {
|
||||||
let callbacks: any;
|
let callbacks: any;
|
||||||
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
||||||
@@ -95,4 +99,76 @@ describe('agentChatRuntimeManager', () => {
|
|||||||
userStore.setUserInfo(firstAccount);
|
userStore.setUserInfo(firstAccount);
|
||||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('coalesces streaming notifications and persists terminal state immediately', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let callbacks: any;
|
||||||
|
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
||||||
|
callbacks = options;
|
||||||
|
return Promise.resolve() as any;
|
||||||
|
});
|
||||||
|
const account = {
|
||||||
|
avatar: '',
|
||||||
|
id: 'stream-user',
|
||||||
|
loginName: 'stream-user',
|
||||||
|
nickname: '流式用户',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
};
|
||||||
|
useUserStore().setUserInfo(account);
|
||||||
|
|
||||||
|
await agentChatRuntimeManager.start({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
prompt: '流式测试',
|
||||||
|
sessionId: 'stream-session',
|
||||||
|
});
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = agentChatRuntimeManager.subscribe(listener);
|
||||||
|
const storageSpy = vi.spyOn(sessionStorage, 'setItem');
|
||||||
|
|
||||||
|
for (const delta of ['A', 'B', 'C']) {
|
||||||
|
callbacks.onMessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
domain: 'LLM',
|
||||||
|
payload: { delta },
|
||||||
|
type: 'MESSAGE',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(storageSpy).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
agentChatRuntimeManager.getSnapshot('stream-session')?.items,
|
||||||
|
),
|
||||||
|
).toContain('ABC');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storageSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(storageSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
callbacks.onMessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
domain: 'LLM',
|
||||||
|
payload: { delta: 'D' },
|
||||||
|
type: 'MESSAGE',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
listener.mockClear();
|
||||||
|
storageSpy.mockClear();
|
||||||
|
callbacks.onFinished();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storageSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(agentChatRuntimeManager.getSnapshot('stream-session')).toEqual(
|
||||||
|
expect.objectContaining({ completed: true, sending: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
storageSpy.mockRestore();
|
||||||
|
clearAgentChatBrowserCache(account);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,10 +64,15 @@ interface StartOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_VERSION = 2;
|
const STORAGE_VERSION = 2;
|
||||||
|
const STREAM_NOTIFY_INTERVAL_MS = 50;
|
||||||
|
const STREAM_PERSIST_INTERVAL_MS = 300;
|
||||||
|
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
|
||||||
|
|
||||||
const sessions = new Map<string, RuntimeSessionState>();
|
const sessions = new Map<string, RuntimeSessionState>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const latestSessionIds = new Map<string, string>();
|
const latestSessionIds = new Map<string, string>();
|
||||||
|
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
let notifyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
const serialized = JSON.stringify(value);
|
const serialized = JSON.stringify(value);
|
||||||
@@ -108,6 +113,24 @@ function notify() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyNow() {
|
||||||
|
if (notifyTimer) {
|
||||||
|
clearTimeout(notifyTimer);
|
||||||
|
notifyTimer = undefined;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNotify() {
|
||||||
|
if (notifyTimer || listeners.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyTimer = setTimeout(() => {
|
||||||
|
notifyTimer = undefined;
|
||||||
|
notify();
|
||||||
|
}, STREAM_NOTIFY_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function persistSession(state: RuntimeSessionState) {
|
function persistSession(state: RuntimeSessionState) {
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -118,7 +141,7 @@ function persistSession(state: RuntimeSessionState) {
|
|||||||
agentName: state.agentName,
|
agentName: state.agentName,
|
||||||
completed: state.completed,
|
completed: state.completed,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
items: clone(state.items),
|
items: state.items,
|
||||||
prompt: state.prompt,
|
prompt: state.prompt,
|
||||||
roundId: state.roundId,
|
roundId: state.roundId,
|
||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
@@ -136,6 +159,82 @@ function persistSession(state: RuntimeSessionState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelPersistTimer(scopedSessionKey: string) {
|
||||||
|
const timer = persistTimers.get(scopedSessionKey);
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
persistTimers.delete(scopedSessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSessionCache(
|
||||||
|
scopedSessionKey: string,
|
||||||
|
state: RuntimeSessionState,
|
||||||
|
) {
|
||||||
|
cancelPersistTimer(scopedSessionKey);
|
||||||
|
sessions.delete(scopedSessionKey);
|
||||||
|
try {
|
||||||
|
safeSessionStorage()?.removeItem(
|
||||||
|
storageKey(state.identity, state.sessionId),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 本地缓存清理失败不影响服务端会话。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneRuntimeSessions(identity: string, currentSessionId: string) {
|
||||||
|
const scopedSessions = [...sessions.entries()]
|
||||||
|
.filter(([, session]) => session.identity === identity)
|
||||||
|
.sort((first, second) => second[1].updatedAt - first[1].updatedAt);
|
||||||
|
if (scopedSessions.length <= MAX_RUNTIME_SESSIONS_PER_IDENTITY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const protectedKeys = new Set(
|
||||||
|
scopedSessions
|
||||||
|
.filter(
|
||||||
|
([, session]) =>
|
||||||
|
session.sending || session.sessionId === currentSessionId,
|
||||||
|
)
|
||||||
|
.map(([key]) => key),
|
||||||
|
);
|
||||||
|
let retainedCount = protectedKeys.size;
|
||||||
|
for (const [key, session] of scopedSessions) {
|
||||||
|
if (protectedKeys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (retainedCount < MAX_RUNTIME_SESSIONS_PER_IDENTITY) {
|
||||||
|
retainedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
removeSessionCache(key, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchState(state: RuntimeSessionState) {
|
||||||
|
state.updatedAt = Date.now();
|
||||||
|
latestSessionIds.set(state.identity, state.sessionId);
|
||||||
|
sessions.set(sessionKey(state.identity, state.sessionId), state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStateUpdate(state: RuntimeSessionState) {
|
||||||
|
touchState(state);
|
||||||
|
const scopedSessionKey = sessionKey(state.identity, state.sessionId);
|
||||||
|
if (!persistTimers.has(scopedSessionKey)) {
|
||||||
|
persistTimers.set(
|
||||||
|
scopedSessionKey,
|
||||||
|
setTimeout(() => {
|
||||||
|
persistTimers.delete(scopedSessionKey);
|
||||||
|
const current = sessions.get(scopedSessionKey);
|
||||||
|
if (current) {
|
||||||
|
persistSession(current);
|
||||||
|
}
|
||||||
|
}, STREAM_PERSIST_INTERVAL_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scheduleNotify();
|
||||||
|
}
|
||||||
|
|
||||||
function restoreSession(identity: string, sessionId: string) {
|
function restoreSession(identity: string, sessionId: string) {
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -179,11 +278,11 @@ function restoreSession(identity: string, sessionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function upsertState(state: RuntimeSessionState) {
|
function upsertState(state: RuntimeSessionState) {
|
||||||
state.updatedAt = Date.now();
|
touchState(state);
|
||||||
latestSessionIds.set(state.identity, state.sessionId);
|
cancelPersistTimer(sessionKey(state.identity, state.sessionId));
|
||||||
sessions.set(sessionKey(state.identity, state.sessionId), state);
|
|
||||||
persistSession(state);
|
persistSession(state);
|
||||||
notify();
|
pruneRuntimeSessions(state.identity, state.sessionId);
|
||||||
|
notifyNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function runningSession(identity = identityScope()) {
|
function runningSession(identity = identityScope()) {
|
||||||
@@ -272,11 +371,12 @@ function replaceAcceptedImages(
|
|||||||
onAgentChatCacheClear((identity) => {
|
onAgentChatCacheClear((identity) => {
|
||||||
for (const [key, session] of sessions) {
|
for (const [key, session] of sessions) {
|
||||||
if (session.identity === identity) {
|
if (session.identity === identity) {
|
||||||
|
cancelPersistTimer(key);
|
||||||
sessions.delete(key);
|
sessions.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
latestSessionIds.delete(identity);
|
latestSessionIds.delete(identity);
|
||||||
notify();
|
notifyNow();
|
||||||
});
|
});
|
||||||
|
|
||||||
export const agentChatRuntimeManager = {
|
export const agentChatRuntimeManager = {
|
||||||
@@ -383,7 +483,7 @@ export const agentChatRuntimeManager = {
|
|||||||
void options.onInputAccepted?.();
|
void options.onInputAccepted?.();
|
||||||
}
|
}
|
||||||
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
||||||
upsertState(current);
|
scheduleStateUpdate(current);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
AgentToolBinding,
|
AgentToolBinding,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { ChatTimeline } from '@easyflow/common-ui';
|
import { ChatTimeline } from '@easyflow/common-ui';
|
||||||
import { BrushCleaning } from '@easyflow/icons';
|
import { BrushCleaning } from '@easyflow/icons';
|
||||||
@@ -39,6 +39,7 @@ const emit = defineEmits<{ close: [] }>();
|
|||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
clearDraftSession,
|
clearDraftSession,
|
||||||
|
dispose,
|
||||||
markToolApproving,
|
markToolApproving,
|
||||||
markToolRejected,
|
markToolRejected,
|
||||||
copyMessageText,
|
copyMessageText,
|
||||||
@@ -80,6 +81,9 @@ async function activateComposer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => void activateComposer());
|
onMounted(() => void activateComposer());
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.agent.id, props.agent.localId],
|
() => [props.agent.id, props.agent.localId],
|
||||||
|
|||||||
@@ -241,4 +241,158 @@ describe('useAgentTryoutRawRounds', () => {
|
|||||||
toolCallId: 'call-approval',
|
toolCallId: 'call-approval',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('异步工作流轮询事件始终归并到首张审批卡', () => {
|
||||||
|
const store = useAgentTryoutRawRounds({
|
||||||
|
mode: 'draft',
|
||||||
|
sessionId: 'session-raw-async-tool',
|
||||||
|
});
|
||||||
|
const roundId = store.createRound('生成文档');
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'TOOL',
|
||||||
|
payload: {
|
||||||
|
input: { user_input: '写一篇小作文' },
|
||||||
|
requestId: 'req-async',
|
||||||
|
resumeToken: 'resume-async',
|
||||||
|
toolCallId: 'submit-call-1',
|
||||||
|
toolName: '文档生成',
|
||||||
|
},
|
||||||
|
type: 'FORM_REQUEST',
|
||||||
|
});
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'TOOL',
|
||||||
|
payload: {
|
||||||
|
asyncTool: true,
|
||||||
|
phase: 'submit',
|
||||||
|
sourceToolCallId: 'submit-call-1',
|
||||||
|
status: 'RUNNING',
|
||||||
|
taskId: 'task-1',
|
||||||
|
toolCallId: 'task-1',
|
||||||
|
toolName: '文档生成',
|
||||||
|
},
|
||||||
|
type: 'TOOL_RESULT',
|
||||||
|
});
|
||||||
|
for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) {
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'TOOL',
|
||||||
|
payload: {
|
||||||
|
asyncTool: true,
|
||||||
|
input: { taskId: 'task-1' },
|
||||||
|
phase: 'observe',
|
||||||
|
sourceToolCallId,
|
||||||
|
status: 'RUNNING',
|
||||||
|
taskId: 'task-1',
|
||||||
|
toolCallId: 'task-1',
|
||||||
|
toolName: '文档生成',
|
||||||
|
},
|
||||||
|
type: 'TOOL_CALL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'TOOL',
|
||||||
|
payload: {
|
||||||
|
asyncTool: true,
|
||||||
|
phase: 'result',
|
||||||
|
sourceToolCallId: 'result-call-1',
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
taskId: 'task-1',
|
||||||
|
toolCallId: 'task-1',
|
||||||
|
toolName: '文档生成',
|
||||||
|
},
|
||||||
|
type: 'TOOL_RESULT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tools = store
|
||||||
|
.buildTimelineItems()
|
||||||
|
.filter((item) => item.type === 'tool');
|
||||||
|
|
||||||
|
expect(tools).toHaveLength(1);
|
||||||
|
expect(tools[0]).toMatchObject({
|
||||||
|
mode: 'approval',
|
||||||
|
status: 'success',
|
||||||
|
taskId: 'task-1',
|
||||||
|
toolCallId: 'task-1',
|
||||||
|
toolName: '文档生成',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('连续文本增量压缩后刷新内容保持一致', () => {
|
||||||
|
const sessionId = 'stream-compaction';
|
||||||
|
const store = useAgentTryoutRawRounds({
|
||||||
|
mode: 'draft',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
const roundId = store.createRound('你好');
|
||||||
|
const liveItems = store.buildTimelineItems();
|
||||||
|
const assistantText = (
|
||||||
|
items: ReturnType<typeof store.buildTimelineItems>,
|
||||||
|
) =>
|
||||||
|
items
|
||||||
|
.flatMap((item) =>
|
||||||
|
item.type === 'message' && item.role === 'assistant'
|
||||||
|
? item.parts
|
||||||
|
.filter((part) => part.type === 'text')
|
||||||
|
.map((part) => part.content)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
for (const delta of ['你', '好', ',', '世界']) {
|
||||||
|
const event = store.recordEvent(roundId, {
|
||||||
|
domain: 'LLM',
|
||||||
|
payload: { delta },
|
||||||
|
type: 'MESSAGE',
|
||||||
|
});
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('流式事件记录失败');
|
||||||
|
}
|
||||||
|
store.projectEvent(liveItems, roundId, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(store.currentVariant(roundId)?.runtimeEvents).toHaveLength(1);
|
||||||
|
expect(store.currentVariant(roundId)?.runtimeEvents[0]?.payload.delta).toBe(
|
||||||
|
'你好,世界',
|
||||||
|
);
|
||||||
|
expect(assistantText(liveItems)).toBe('你好,世界');
|
||||||
|
|
||||||
|
store.flush();
|
||||||
|
const restored = useAgentTryoutRawRounds({
|
||||||
|
mode: 'draft',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
expect(assistantText(restored.buildTimelineItems())).toBe('你好,世界');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('存在待持久化增量时结束事件仍立即落盘', () => {
|
||||||
|
const sessionId = 'terminal-persist';
|
||||||
|
const store = useAgentTryoutRawRounds({
|
||||||
|
mode: 'draft',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
const roundId = store.createRound('结束测试');
|
||||||
|
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'LLM',
|
||||||
|
payload: { reasoning: '思考中' },
|
||||||
|
type: 'THINKING',
|
||||||
|
});
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'SYSTEM',
|
||||||
|
payload: {},
|
||||||
|
type: 'DONE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const restored = useAgentTryoutRawRounds({
|
||||||
|
mode: 'draft',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
expect(restored.currentVariant(roundId)?.status).toBe('completed');
|
||||||
|
expect(
|
||||||
|
restored
|
||||||
|
.currentVariant(roundId)
|
||||||
|
?.runtimeEvents.some(
|
||||||
|
(event) => event.domain === 'SYSTEM' && event.type === 'DONE',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const STORAGE_VERSION = 1;
|
|||||||
const MAX_ROUNDS = 50;
|
const MAX_ROUNDS = 50;
|
||||||
const MAX_VARIANTS = 10;
|
const MAX_VARIANTS = 10;
|
||||||
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
|
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
|
||||||
const PERSIST_DEBOUNCE_MS = 300;
|
const PERSIST_DEBOUNCE_MS = 500;
|
||||||
const memorySessions = new Map<string, AgentTryoutRawRound[]>();
|
const memorySessions = new Map<string, AgentTryoutRawRound[]>();
|
||||||
|
|
||||||
function createRoundId() {
|
function createRoundId() {
|
||||||
@@ -275,6 +275,45 @@ function selectedVariant(round: AgentTryoutRawRound) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function streamingPayloadKey(event: AgentTryoutRuntimeEvent) {
|
||||||
|
if (event.domain !== 'LLM') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let candidates: string[] = [];
|
||||||
|
if (event.type === 'MESSAGE') {
|
||||||
|
candidates = ['delta'];
|
||||||
|
} else if (event.type === 'THINKING') {
|
||||||
|
candidates = ['reasoning', 'delta', 'text'];
|
||||||
|
}
|
||||||
|
return candidates.find((key) => typeof event.payload[key] === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRuntimeEvent(
|
||||||
|
variant: AgentTryoutRawVariant,
|
||||||
|
event: AgentTryoutRuntimeEvent,
|
||||||
|
) {
|
||||||
|
const previous = variant.runtimeEvents[variant.runtimeEvents.length - 1];
|
||||||
|
const currentKey = streamingPayloadKey(event);
|
||||||
|
const previousKey = previous ? streamingPayloadKey(previous) : undefined;
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
previous.domain === event.domain &&
|
||||||
|
previous.type === event.type &&
|
||||||
|
currentKey &&
|
||||||
|
currentKey === previousKey
|
||||||
|
) {
|
||||||
|
previous.payload = {
|
||||||
|
...previous.payload,
|
||||||
|
...event.payload,
|
||||||
|
[currentKey]:
|
||||||
|
asText(previous.payload[currentKey]) +
|
||||||
|
asText(event.payload[currentKey]),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
variant.runtimeEvents.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
function visibleText(item: ChatTimelineMessageItem) {
|
function visibleText(item: ChatTimelineMessageItem) {
|
||||||
return item.parts
|
return item.parts
|
||||||
.filter((part) => part.type === 'text')
|
.filter((part) => part.type === 'text')
|
||||||
@@ -529,6 +568,7 @@ function projectEventToTimeline(
|
|||||||
payload.toolDisplayName ?? rawToolName ?? '工具',
|
payload.toolDisplayName ?? rawToolName ?? '工具',
|
||||||
);
|
);
|
||||||
const asyncTool = payload.asyncTool === true;
|
const asyncTool = payload.asyncTool === true;
|
||||||
|
const taskInput = asRecord(payload.input ?? payload.toolInput);
|
||||||
ChatTimelineBuilder.upsertToolCall(items, {
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
input: payload.input ?? payload.toolInput,
|
input: payload.input ?? payload.toolInput,
|
||||||
output: asyncTool
|
output: asyncTool
|
||||||
@@ -549,6 +589,12 @@ function projectEventToTimeline(
|
|||||||
variantIndex,
|
variantIndex,
|
||||||
'knowledge-retrieval',
|
'knowledge-retrieval',
|
||||||
),
|
),
|
||||||
|
sourceToolCallId: asyncTool
|
||||||
|
? asText(payload.sourceToolCallId ?? payload.source_tool_call_id)
|
||||||
|
: undefined,
|
||||||
|
taskId: asyncTool
|
||||||
|
? asText(payload.taskId ?? taskInput.taskId ?? taskInput.task_id)
|
||||||
|
: undefined,
|
||||||
toolCallId: asText(
|
toolCallId: asText(
|
||||||
payload.toolCallId ??
|
payload.toolCallId ??
|
||||||
payload.taskId ??
|
payload.taskId ??
|
||||||
@@ -650,11 +696,6 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function schedulePersist() {
|
function schedulePersist() {
|
||||||
const key = storageKey(options.mode, options.sessionId);
|
|
||||||
memorySessions.set(
|
|
||||||
key,
|
|
||||||
[...rounds.values()].map((item) => clone(item)),
|
|
||||||
);
|
|
||||||
if (persistTimer) {
|
if (persistTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -737,7 +778,7 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
payload: event.payload || {},
|
payload: event.payload || {},
|
||||||
type: event.type.toUpperCase(),
|
type: event.type.toUpperCase(),
|
||||||
};
|
};
|
||||||
variant.runtimeEvents.push(runtimeEvent);
|
appendRuntimeEvent(variant, runtimeEvent);
|
||||||
if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') {
|
if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') {
|
||||||
variant.status = 'completed';
|
variant.status = 'completed';
|
||||||
round.status = 'completed';
|
round.status = 'completed';
|
||||||
@@ -754,9 +795,23 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
runtimeEvent.domain === 'ERROR'
|
runtimeEvent.domain === 'ERROR'
|
||||||
) {
|
) {
|
||||||
persistNow();
|
persistNow();
|
||||||
return;
|
return runtimeEvent;
|
||||||
}
|
}
|
||||||
schedulePersist();
|
schedulePersist();
|
||||||
|
return runtimeEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectEvent(
|
||||||
|
items: ChatTimelineItem[],
|
||||||
|
roundId: string,
|
||||||
|
event: AgentTryoutRuntimeEvent,
|
||||||
|
) {
|
||||||
|
const round = rounds.get(roundId);
|
||||||
|
const variant = round && selectedVariant(round);
|
||||||
|
if (!round || !variant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectEventToTimeline(items, event, round.roundId, variant.variantIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeRound(roundId: string) {
|
function completeRound(roundId: string) {
|
||||||
@@ -849,6 +904,7 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
createRound,
|
createRound,
|
||||||
currentVariant,
|
currentVariant,
|
||||||
getPrompt,
|
getPrompt,
|
||||||
|
projectEvent,
|
||||||
recordEvent,
|
recordEvent,
|
||||||
regenerateRound,
|
regenerateRound,
|
||||||
selectVariant,
|
selectVariant,
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function useAgentTryoutStream() {
|
|||||||
const sessionChanged = activeSessionId !== sessionId;
|
const sessionChanged = activeSessionId !== sessionId;
|
||||||
activeSessionId = sessionId;
|
activeSessionId = sessionId;
|
||||||
if (!rawRounds || sessionChanged) {
|
if (!rawRounds || sessionChanged) {
|
||||||
|
rawRounds?.flush();
|
||||||
rawRounds = useAgentTryoutRawRounds({
|
rawRounds = useAgentTryoutRawRounds({
|
||||||
mode: 'draft',
|
mode: 'draft',
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -174,12 +175,18 @@ export function useAgentTryoutStream() {
|
|||||||
const payload = envelope.payload || {};
|
const payload = envelope.payload || {};
|
||||||
|
|
||||||
if (activeRoundId) {
|
if (activeRoundId) {
|
||||||
rawRounds?.recordEvent(activeRoundId, {
|
const runtimeEvent = rawRounds?.recordEvent(activeRoundId, {
|
||||||
domain,
|
domain,
|
||||||
payload,
|
payload,
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
rebuildTimeline();
|
if (runtimeEvent) {
|
||||||
|
rawRounds?.projectEvent(
|
||||||
|
timelineItems.value,
|
||||||
|
activeRoundId,
|
||||||
|
runtimeEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domain === 'LLM' && type === 'MESSAGE') {
|
if (domain === 'LLM' && type === 'MESSAGE') {
|
||||||
@@ -340,12 +347,23 @@ export function useAgentTryoutStream() {
|
|||||||
finishStoppedRun();
|
finishStoppedRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
if (loading.value) {
|
||||||
|
userStopped = true;
|
||||||
|
sseClient.abort();
|
||||||
|
finishStoppedRun();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rawRounds?.flush();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
clearDraftSession,
|
clearDraftSession,
|
||||||
markToolApproving,
|
markToolApproving,
|
||||||
markToolRejected,
|
markToolRejected,
|
||||||
copyMessageText,
|
copyMessageText,
|
||||||
|
dispose,
|
||||||
regenerateDraft,
|
regenerateDraft,
|
||||||
selectVariant,
|
selectVariant,
|
||||||
syncDraftContext,
|
syncDraftContext,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
ChatTimelineToolApprovalPayload,
|
ChatTimelineToolApprovalPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
import ChatTimelineItem from './ChatTimelineItem.vue';
|
import ChatTimelineItem from './ChatTimelineItem.vue';
|
||||||
|
|
||||||
@@ -38,6 +38,15 @@ let preservedScrollTop: number | undefined;
|
|||||||
|
|
||||||
const bottomThreshold = 24;
|
const bottomThreshold = 24;
|
||||||
let scrollFrame = 0;
|
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) {
|
function isNearBottom(container: HTMLElement) {
|
||||||
return (
|
return (
|
||||||
@@ -85,26 +94,13 @@ function canRegenerateMessage(item: ChatTimelineItemType) {
|
|||||||
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAssistantActionAnchor(
|
function isAssistantActionAnchor(item: ChatTimelineItemType) {
|
||||||
item: ChatTimelineItemType,
|
return (
|
||||||
index: number,
|
item.type === 'message' &&
|
||||||
items: ChatTimelineItemType[],
|
item.role === 'assistant' &&
|
||||||
) {
|
Boolean(item.roundId) &&
|
||||||
if (item.type !== 'message' || item.role !== 'assistant' || !item.roundId) {
|
assistantActionAnchorIds.value.has(item.id)
|
||||||
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 isVariantLoading(item: ChatTimelineItemType) {
|
function isVariantLoading(item: ChatTimelineItemType) {
|
||||||
@@ -152,9 +148,9 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ChatTimelineItem
|
<ChatTimelineItem
|
||||||
v-for="(item, index) in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
|
:assistant-actions-visible="isAssistantActionAnchor(item)"
|
||||||
:item="item"
|
:item="item"
|
||||||
:image-loader="imageLoader"
|
:image-loader="imageLoader"
|
||||||
:approval-loading="approvalLoading"
|
: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', () => {
|
it('marks approval tool rejected in the same card', () => {
|
||||||
const items: ChatTimelineItem[] = [];
|
const items: ChatTimelineItem[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -162,14 +162,26 @@ function finishAssistantMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findToolItem(items: ChatTimelineItem[], toolCallId?: string) {
|
function findToolItem(
|
||||||
const identity = normalizeText(toolCallId).trim();
|
items: ChatTimelineItem[],
|
||||||
if (!identity) {
|
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 undefined;
|
||||||
}
|
}
|
||||||
return items.find(
|
return items.find(
|
||||||
(item): item is ChatTimelineToolItem =>
|
(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;
|
rejectReason?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
resumeToken?: string;
|
resumeToken?: string;
|
||||||
|
sourceToolCallId?: string;
|
||||||
status?: ChatTimelineToolStatus;
|
status?: ChatTimelineToolStatus;
|
||||||
|
taskId?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
},
|
},
|
||||||
@@ -259,7 +273,13 @@ function upsertTool(
|
|||||||
const toolCallId = normalizeText(
|
const toolCallId = normalizeText(
|
||||||
payload.toolCallId ?? payload.approval?.toolCallId,
|
payload.toolCallId ?? payload.approval?.toolCallId,
|
||||||
).trim();
|
).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 approval = payload.approval ?? found?.approval;
|
||||||
const mode =
|
const mode =
|
||||||
payload.mode === 'approval'
|
payload.mode === 'approval'
|
||||||
@@ -287,6 +307,7 @@ function upsertTool(
|
|||||||
found.output = normalizePayloadValue(payload.output) ?? found.output;
|
found.output = normalizePayloadValue(payload.output) ?? found.output;
|
||||||
found.rejectReason = payload.rejectReason ?? found.rejectReason;
|
found.rejectReason = payload.rejectReason ?? found.rejectReason;
|
||||||
found.status = payload.status || found.status;
|
found.status = payload.status || found.status;
|
||||||
|
found.taskId = taskId || found.taskId;
|
||||||
found.toolCallId = toolCallId || found.toolCallId;
|
found.toolCallId = toolCallId || found.toolCallId;
|
||||||
found.toolName = toolName || found.toolName;
|
found.toolName = toolName || found.toolName;
|
||||||
return found;
|
return found;
|
||||||
@@ -304,6 +325,7 @@ function upsertTool(
|
|||||||
rejectReason: payload.rejectReason,
|
rejectReason: payload.rejectReason,
|
||||||
status:
|
status:
|
||||||
payload.status || (mode === 'approval' ? 'pending_approval' : 'running'),
|
payload.status || (mode === 'approval' ? 'pending_approval' : 'running'),
|
||||||
|
taskId: taskId || undefined,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: toolName || '工具调用',
|
toolName: toolName || '工具调用',
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
@@ -417,8 +439,10 @@ export const ChatTimelineBuilder = {
|
|||||||
payload: {
|
payload: {
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
output?: unknown;
|
output?: unknown;
|
||||||
|
sourceToolCallId?: string;
|
||||||
status?: ChatTimelineToolStatus;
|
status?: ChatTimelineToolStatus;
|
||||||
statusKey?: string;
|
statusKey?: string;
|
||||||
|
taskId?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export interface ChatTimelineToolItem extends ChatTimelineItemBase {
|
|||||||
output?: unknown;
|
output?: unknown;
|
||||||
rejectReason?: string;
|
rejectReason?: string;
|
||||||
status: ChatTimelineToolStatus;
|
status: ChatTimelineToolStatus;
|
||||||
|
taskId?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
type: 'tool';
|
type: 'tool';
|
||||||
|
|||||||
Reference in New Issue
Block a user