feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志 - 支持正式聊天与草稿试运行刷新重连和权威终态恢复 - 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
@@ -1,29 +1,66 @@
|
||||
import type {
|
||||
AgentSubscriber,
|
||||
Message,
|
||||
RunAgentInput,
|
||||
State,
|
||||
} from '@ag-ui/client';
|
||||
import type { Message } from '@ag-ui/client';
|
||||
|
||||
import { EventSchemas, EventType, HttpAgent, randomUUID } from '@ag-ui/client';
|
||||
import { EventSchemas, EventType, randomUUID } from '@ag-ui/client';
|
||||
import { events } from 'fetch-event-stream';
|
||||
|
||||
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
|
||||
|
||||
export interface EasyFlowAguiRunOptions {
|
||||
forwardedProps?: Record<string, unknown>;
|
||||
onEvent: (event: AguiEvent) => void;
|
||||
onMessagesChanged?: (messages: ReadonlyArray<Readonly<Message>>) => void;
|
||||
onStateChanged?: (state: Readonly<State>) => void;
|
||||
onCursor?: (cursor: number) => void;
|
||||
onEvent: (event: AguiEvent) => Promise<void> | void;
|
||||
runId?: string;
|
||||
threadId: string;
|
||||
url: string;
|
||||
userMessage: Message;
|
||||
}
|
||||
|
||||
export interface EasyFlowAguiResumeOptions {
|
||||
afterCursor?: number;
|
||||
onCursor?: (cursor: number) => void;
|
||||
onEvent: (event: AguiEvent) => Promise<void> | void;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
export type AguiEvent = ReturnType<(typeof EventSchemas)['parse']>;
|
||||
|
||||
interface ActiveAguiRun {
|
||||
aborted: boolean;
|
||||
agent: HttpAgent;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
/** 带 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> | void,
|
||||
event: AguiEvent,
|
||||
) {
|
||||
try {
|
||||
await onEvent(event);
|
||||
} catch (error) {
|
||||
throw new EasyFlowAguiProjectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function toTransportJson<T>(value: T): T {
|
||||
@@ -35,73 +72,142 @@ function toTransportJson<T>(value: T): T {
|
||||
/**
|
||||
* EasyFlow 的无头 AG-UI 运行客户端。
|
||||
*
|
||||
* <p>SDK 持有标准 messages/state;页面只消费投影回调。每次运行都会在出站边界再次裁剪
|
||||
* <p>客户端以最小标准 AG-UI 请求体启动运行,页面只消费投影回调。出站边界固定裁剪
|
||||
* tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。</p>
|
||||
*/
|
||||
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.agent.abortRun();
|
||||
this.activeRun.abort();
|
||||
this.activeRun = undefined;
|
||||
}
|
||||
|
||||
async run(options: EasyFlowAguiRunOptions) {
|
||||
this.abort();
|
||||
const requestUrl = options.url;
|
||||
const agent = new EasyFlowHttpAgent({
|
||||
headers: createEventStreamHeaders(requestUrl),
|
||||
initialMessages: [options.userMessage],
|
||||
threadId: options.threadId,
|
||||
url: resolveApiUrl(requestUrl),
|
||||
});
|
||||
const activeRun: ActiveAguiRun = { aborted: false, agent };
|
||||
async resume(options: EasyFlowAguiResumeOptions) {
|
||||
this.detach();
|
||||
const controller = new AbortController();
|
||||
const activeRun: ActiveAguiRun = {
|
||||
abort: () => controller.abort(),
|
||||
aborted: false,
|
||||
};
|
||||
this.activeRun = activeRun;
|
||||
let terminalReceived = false;
|
||||
let cancelledReceived = false;
|
||||
const subscriber: AgentSubscriber = {
|
||||
onEvent: ({ event }) => {
|
||||
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 (
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code === 'RUN_CANCELLED'
|
||||
) {
|
||||
cancelledReceived = true;
|
||||
}
|
||||
options.onEvent(event as AguiEvent);
|
||||
},
|
||||
onMessagesChanged: ({ messages }) => {
|
||||
options.onMessagesChanged?.(messages);
|
||||
},
|
||||
onStateChanged: ({ state }) => {
|
||||
options.onStateChanged?.(state);
|
||||
},
|
||||
}
|
||||
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 {
|
||||
await agent.runAgent(
|
||||
{
|
||||
const response = await fetch(resolveApiUrl(requestUrl), {
|
||||
body: JSON.stringify({
|
||||
context: [],
|
||||
forwardedProps: options.forwardedProps
|
||||
? toTransportJson(options.forwardedProps)
|
||||
: undefined,
|
||||
runId: `run_${randomUUID()}`,
|
||||
messages: [toTransportJson(options.userMessage)],
|
||||
runId: options.runId || createAguiRunId(),
|
||||
state: {},
|
||||
threadId: options.threadId,
|
||||
tools: [],
|
||||
},
|
||||
subscriber,
|
||||
);
|
||||
}),
|
||||
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 (!terminalReceived) {
|
||||
if (activeRun.aborted) return;
|
||||
throw new Error('Agent 事件流缺少终态,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
if (activeRun.aborted || cancelledReceived) return;
|
||||
if (activeRun.aborted) return;
|
||||
if (terminalReceived && !(error instanceof EasyFlowAguiProjectionError)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.activeRun === activeRun) {
|
||||
@@ -111,17 +217,26 @@ export class EasyFlowAguiClient {
|
||||
}
|
||||
}
|
||||
|
||||
class EasyFlowHttpAgent extends HttpAgent {
|
||||
protected override requestInit(input: RunAgentInput): RequestInit {
|
||||
const latestUserMessage = [...input.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user');
|
||||
return super.requestInit({
|
||||
...input,
|
||||
context: [],
|
||||
messages: latestUserMessage ? [latestUserMessage] : [],
|
||||
state: {},
|
||||
tools: [],
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user