diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts index b93b758c..a5675520 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts @@ -24,6 +24,7 @@ function sse(events: unknown[]) { describe('easyFlowAguiClient', () => { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); it('trims outbound history, tools, context and state at the transport boundary', async () => { @@ -200,6 +201,81 @@ describe('easyFlowAguiClient', () => { ).rejects.toBeInstanceOf(EasyFlowAguiProjectionError); }); + it('yields a paint opportunity before consuming events after tool start', async () => { + vi.useFakeTimers(); + let paintCallback: FrameRequestCallback | undefined; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + paintCallback = callback; + return 1; + }), + ); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + sse([ + { runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }, + { + runId: 'run-1', + threadId: '101', + toolCallId: 'tool-1', + toolCallName: 'write_text_file', + type: EventType.TOOL_CALL_START, + }, + { + delta: '{}', + runId: 'run-1', + threadId: '101', + toolCallId: 'tool-1', + type: EventType.TOOL_CALL_ARGS, + }, + { + runId: 'run-1', + threadId: '101', + toolCallId: 'tool-1', + type: EventType.TOOL_CALL_END, + }, + { + content: 'done', + messageId: 'tool-result-1', + role: 'tool', + runId: 'run-1', + threadId: '101', + toolCallId: 'tool-1', + type: EventType.TOOL_CALL_RESULT, + }, + { + runId: 'run-1', + threadId: '101', + type: EventType.RUN_FINISHED, + }, + ]), + ), + ); + const received: string[] = []; + + const runPromise = new EasyFlowAguiClient().run({ + onEvent: (event) => { + received.push(event.type); + }, + threadId: '101', + url: '/api/v1/agent/1/agui/run', + userMessage: { content: '生成文件', id: 'user-1', role: 'user' }, + }); + await vi.advanceTimersByTimeAsync(0); + + expect(received).toContain(EventType.TOOL_CALL_START); + expect(received).not.toContain(EventType.TOOL_CALL_RESULT); + + paintCallback?.(0); + await vi.advanceTimersByTimeAsync(0); + await runPromise; + + expect(received).toContain(EventType.TOOL_CALL_RESULT); + expect(received.at(-1)).toBe(EventType.RUN_FINISHED); + }); + it('replays a completed run from the server journal after refresh', async () => { vi.stubGlobal( 'fetch', diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts index 900c0374..f77ed95e 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts @@ -29,6 +29,8 @@ interface ActiveAguiRun { abort: () => void; } +const TOOL_START_PAINT_FALLBACK_MS = 50; + /** 带 HTTP 状态码的 AG-UI 传输异常。 */ export class EasyFlowAguiHttpError extends Error { constructor( @@ -69,6 +71,36 @@ function toTransportJson(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +/** + * 在工具开始事件后让出一次页面绘制机会。 + * + *

AG-UI 可能在同一批响应数据中连续送达工具开始和结果事件。Vue 虽已收到开始态, + * 浏览器仍可能在首次绘制前就将其覆盖为完成态。这里使用一帧回调并设置有界兜底, + * 既确保前台页面能展示“调用中”,也避免后台标签页暂停动画帧时阻塞事件流。

+ * + * @returns 页面获得绘制机会后完成的 Promise + */ +function waitForToolStartPaint(): Promise { + return new Promise((resolve) => { + let settled = false; + const complete = () => { + if (settled) return; + settled = true; + clearTimeout(fallbackTimer); + resolve(); + }; + const fallbackTimer = setTimeout(complete, TOOL_START_PAINT_FALLBACK_MS); + if (typeof globalThis.requestAnimationFrame !== 'function') { + setTimeout(complete, 0); + return; + } + globalThis.requestAnimationFrame(() => { + // requestAnimationFrame 回调发生在绘制前,再让出一个任务周期后继续消费后续事件。 + setTimeout(complete, 0); + }); + }); +} + /** * EasyFlow 的无头 AG-UI 运行客户端。 * @@ -142,6 +174,9 @@ export class EasyFlowAguiClient { ) { terminalReceived = true; } + if (event.type === EventType.TOOL_CALL_START) { + await waitForToolStartPaint(); + } } if (!activeRun.aborted && !terminalReceived) { throw new Error('Agent 事件流缺少终态,请重试'); @@ -198,6 +233,9 @@ export class EasyFlowAguiClient { if (Number.isSafeInteger(cursor) && cursor > 0) { options.onCursor?.(cursor); } + if (event.type === EventType.TOOL_CALL_START) { + await waitForToolStartPaint(); + } } if (!terminalReceived) { if (activeRun.aborted) return;