import type { Message } from '@ag-ui/client'; import { EventSchemas, EventType, randomUUID } from '@ag-ui/client'; import { events } from 'fetch-event-stream'; import { createEventStreamHeaders, resolveApiUrl } from '#/api/request'; import { easyFlowAguiCustomEvent } from './custom-events'; export interface EasyFlowAguiRunOptions { forwardedProps?: Record; onCursor?: (cursor: number) => void; onEvent: (event: AguiEvent) => Promise | void; runId?: string; threadId: string; url: string; userMessage: Message; } export interface EasyFlowAguiResumeOptions { afterCursor?: number; onCursor?: (cursor: number) => void; onEvent: (event: AguiEvent) => Promise | void; runId: string; } export type AguiEvent = ReturnType<(typeof EventSchemas)['parse']>; interface ActiveAguiRun { aborted: boolean; abort: () => void; } const TOOL_START_PAINT_FALLBACK_MS = 50; /** 带 HTTP 状态码的 AG-UI 传输异常。 */ export class EasyFlowAguiHttpError extends Error { constructor( message: string, public readonly status: number, ) { super(message); this.name = 'EasyFlowAguiHttpError'; } } /** 页面事件投影失败,重放同一游标无法自行恢复。 */ export class EasyFlowAguiProjectionError extends Error { constructor(public readonly projectionCause: unknown) { super( projectionCause instanceof Error ? projectionCause.message : 'Agent 事件投影失败', ); this.name = 'EasyFlowAguiProjectionError'; } } async function projectEvent( onEvent: (event: AguiEvent) => Promise | void, event: AguiEvent, ) { try { await onEvent(event); } catch (error) { throw new EasyFlowAguiProjectionError(error); } } function toTransportJson(value: T): T { // AG-UI SDK 会在请求前 structuredClone;先以真实传输格式解除 Vue Proxy,避免草稿对象克隆失败。 // eslint-disable-next-line unicorn/prefer-structured-clone -- structuredClone 无法复制 Proxy 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); }); }); } /** * 判断事件是否开启了需要即时呈现的工具执行状态。 * * @param event AG-UI 事件 * @returns 标准工具开始或知识库检索开始时为 true */ function startsVisibleToolExecution(event: AguiEvent) { if (event.type === EventType.TOOL_CALL_START) { return true; } if ( event.type !== EventType.CUSTOM || event.name !== easyFlowAguiCustomEvent.knowledgeRetrievalStatus ) { return false; } const value = event.value && typeof event.value === 'object' && !Array.isArray(event.value) ? (event.value as Record) : {}; return String(value.status || '').toLowerCase() === 'running'; } /** * EasyFlow 的无头 AG-UI 运行客户端。 * *

客户端以最小标准 AG-UI 请求体启动运行,页面只消费投影回调。出站边界固定裁剪 * tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。

*/ export class EasyFlowAguiClient { private activeRun?: ActiveAguiRun; abort() { this.detach(); } async cancel(runId: string) { const requestUrl = `/api/v1/agent/agui/run/${encodeURIComponent(runId)}/cancel`; const response = await fetch(resolveApiUrl(requestUrl), { headers: createEventStreamHeaders(requestUrl), method: 'POST', }); if (!response.ok) { throw new EasyFlowAguiHttpError( await responseError(response), response.status, ); } const body = (await response.json()) as { errorCode?: number; message?: string; }; if (body.errorCode !== undefined && body.errorCode !== 0) { throw new Error(body.message || '停止 Agent 运行失败'); } this.detach(); } detach() { if (!this.activeRun) return; this.activeRun.aborted = true; this.activeRun.abort(); this.activeRun = undefined; } async resume(options: EasyFlowAguiResumeOptions) { this.detach(); const controller = new AbortController(); const activeRun: ActiveAguiRun = { abort: () => controller.abort(), aborted: false, }; this.activeRun = activeRun; let terminalReceived = false; try { const requestUrl = `/api/v1/agent/agui/run/${encodeURIComponent(options.runId)}/events?after=${Math.max(0, options.afterCursor || 0)}`; const response = await fetch(resolveApiUrl(requestUrl), { headers: createEventStreamHeaders(requestUrl), method: 'GET', signal: controller.signal, }); await assertSseResponse(response); for await (const message of events(response, controller.signal)) { if (activeRun.aborted || !message.data) continue; const event = EventSchemas.parse(JSON.parse(message.data)); await projectEvent(options.onEvent, event as AguiEvent); const cursor = Number(message.id || 0); if (Number.isSafeInteger(cursor) && cursor > 0) { options.onCursor?.(cursor); } if ( event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_ERROR ) { terminalReceived = true; } if (startsVisibleToolExecution(event as AguiEvent)) { await waitForToolStartPaint(); } } if (!activeRun.aborted && !terminalReceived) { throw new Error('Agent 事件流缺少终态,请重试'); } } catch (error) { if (activeRun.aborted) return; throw error; } finally { if (this.activeRun === activeRun) { this.activeRun = undefined; } } } async run(options: EasyFlowAguiRunOptions) { this.detach(); const requestUrl = options.url; const controller = new AbortController(); const activeRun: ActiveAguiRun = { abort: () => controller.abort(), aborted: false, }; this.activeRun = activeRun; let terminalReceived = false; try { const response = await fetch(resolveApiUrl(requestUrl), { body: JSON.stringify({ context: [], forwardedProps: options.forwardedProps ? toTransportJson(options.forwardedProps) : undefined, messages: [toTransportJson(options.userMessage)], runId: options.runId || createAguiRunId(), state: {}, threadId: options.threadId, tools: [], }), headers: createEventStreamHeaders(requestUrl), method: 'POST', signal: controller.signal, }); await assertSseResponse(response); for await (const message of events(response, controller.signal)) { if (activeRun.aborted || !message.data) continue; const event = EventSchemas.parse(JSON.parse(message.data)); if ( event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_ERROR ) { terminalReceived = true; } await projectEvent(options.onEvent, event as AguiEvent); const cursor = Number(message.id || 0); if (Number.isSafeInteger(cursor) && cursor > 0) { options.onCursor?.(cursor); } if (startsVisibleToolExecution(event as AguiEvent)) { await waitForToolStartPaint(); } } if (!terminalReceived) { if (activeRun.aborted) return; throw new Error('Agent 事件流缺少终态,请重试'); } } catch (error) { if (activeRun.aborted) return; if (terminalReceived && !(error instanceof EasyFlowAguiProjectionError)) { return; } throw error; } finally { if (this.activeRun === activeRun) { this.activeRun = undefined; } } } } export function createAguiRunId() { return `run_${randomUUID()}`; } async function assertSseResponse(response: Response) { const contentType = response.headers.get('content-type') || ''; if (!response.ok || !contentType.includes('text/event-stream')) { throw new EasyFlowAguiHttpError( await responseError(response), response.status, ); } } async function responseError(response: Response) { try { const body = await response.clone().json(); return body?.message || body?.error || `HTTP ${response.status}`; } catch { const text = await response.text(); return text.trim() || `HTTP ${response.status}`; } }