feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志 - 支持正式聊天与草稿试运行刷新重连和权威终态恢复 - 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { EasyFlowAguiRunOptions } from '../shared/agent-agui/client';
|
||||
import type {
|
||||
EasyFlowAguiResumeOptions,
|
||||
EasyFlowAguiRunOptions,
|
||||
} from '../shared/agent-agui/client';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
@@ -15,12 +18,32 @@ import { agentChatRuntimeManager } from './agentChatRuntimeManager';
|
||||
|
||||
const aguiMocks = vi.hoisted(() => ({
|
||||
abort: vi.fn(),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
detach: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
run: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/agent-agui/client', () => ({
|
||||
createAguiRunId: () => 'run-test',
|
||||
EasyFlowAguiHttpError: class extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
EasyFlowAguiProjectionError: class extends Error {
|
||||
constructor(public readonly projectionCause: unknown) {
|
||||
super('Agent 事件投影失败');
|
||||
}
|
||||
},
|
||||
EasyFlowAguiClient: class {
|
||||
abort = aguiMocks.abort;
|
||||
cancel = aguiMocks.cancel;
|
||||
detach = aguiMocks.detach;
|
||||
resume = aguiMocks.resume;
|
||||
run = aguiMocks.run;
|
||||
},
|
||||
}));
|
||||
@@ -34,6 +57,9 @@ describe('agentChatRuntimeManager', () => {
|
||||
setActivePinia(createPinia());
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
aguiMocks.cancel.mockResolvedValue(undefined);
|
||||
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
|
||||
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -155,6 +181,163 @@ describe('agentChatRuntimeManager', () => {
|
||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('刷新恢复已确认输入时重新触发草稿清理回调', async () => {
|
||||
const account = {
|
||||
avatar: '',
|
||||
id: 'accepted-restore-user',
|
||||
loginName: 'accepted-restore-user',
|
||||
nickname: '输入恢复用户',
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
useUserStore().setUserInfo(account);
|
||||
const sessionId = 'accepted-restore-session';
|
||||
sessionStorage.setItem(
|
||||
`easyflow:agent-chat-runtime:tenant-1:accepted-restore-user:${sessionId}`,
|
||||
JSON.stringify({
|
||||
agentId: 'agent-1',
|
||||
completed: false,
|
||||
inputAccepted: true,
|
||||
items: [],
|
||||
prompt: '刷新前输入',
|
||||
runId: 'run-accepted-restore',
|
||||
roundId: 'round-accepted-restore',
|
||||
sending: true,
|
||||
sessionId,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
version: 4,
|
||||
}),
|
||||
);
|
||||
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
|
||||
const onInputAccepted = vi.fn();
|
||||
|
||||
agentChatRuntimeManager.resume(sessionId, onInputAccepted);
|
||||
|
||||
expect(onInputAccepted).toHaveBeenCalledTimes(1);
|
||||
expect(aguiMocks.resume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: 'run-accepted-restore' }),
|
||||
);
|
||||
clearAgentChatBrowserCache(account);
|
||||
});
|
||||
|
||||
it('硬刷新后从持久化游标续传且保留已投影正文', async () => {
|
||||
const account = {
|
||||
avatar: '',
|
||||
id: 'cursor-restore-user',
|
||||
loginName: 'cursor-restore-user',
|
||||
nickname: '游标恢复用户',
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
useUserStore().setUserInfo(account);
|
||||
const sessionId = 'cursor-restore-session';
|
||||
sessionStorage.setItem(
|
||||
`easyflow:agent-chat-runtime:tenant-1:cursor-restore-user:${sessionId}`,
|
||||
JSON.stringify({
|
||||
agentId: 'agent-1',
|
||||
completed: false,
|
||||
inputAccepted: false,
|
||||
items: [
|
||||
{
|
||||
id: 'assistant-cursor',
|
||||
parts: [
|
||||
{
|
||||
content: '刷新前正文',
|
||||
id: 'text-cursor',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
roundId: 'round-cursor',
|
||||
status: 'streaming',
|
||||
type: 'message',
|
||||
},
|
||||
],
|
||||
lastCursor: 9,
|
||||
prompt: '游标恢复',
|
||||
projectionToolArgs: {},
|
||||
projectionToolNames: {},
|
||||
runId: 'run-cursor-restore',
|
||||
roundId: 'round-cursor',
|
||||
sending: true,
|
||||
sessionId,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
version: 5,
|
||||
}),
|
||||
);
|
||||
aguiMocks.resume.mockImplementationOnce(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
expect(options.afterCursor).toBe(9);
|
||||
options.onEvent({
|
||||
delta: '续传正文',
|
||||
messageId: 'assistant-cursor',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
options.onCursor?.(10);
|
||||
options.onEvent({
|
||||
runId: 'run-cursor-restore',
|
||||
threadId: sessionId,
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
options.onCursor?.(11);
|
||||
},
|
||||
);
|
||||
|
||||
agentChatRuntimeManager.resume(sessionId);
|
||||
|
||||
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
|
||||
expect(aguiMocks.resume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ afterCursor: 9 }),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentChatRuntimeManager.getSnapshot(sessionId)).toEqual(
|
||||
expect.objectContaining({ completed: true, lastCursor: 11 }),
|
||||
);
|
||||
});
|
||||
const timeline = JSON.stringify(
|
||||
agentChatRuntimeManager.getSnapshot(sessionId)?.items,
|
||||
);
|
||||
expect(timeline).toContain('刷新前正文续传正文');
|
||||
clearAgentChatBrowserCache(account);
|
||||
});
|
||||
|
||||
it('短暂断网超过旧重试窗口后仍按游标恢复到服务端终态', async () => {
|
||||
vi.useFakeTimers();
|
||||
const account = {
|
||||
avatar: '',
|
||||
id: 'reconnect-user',
|
||||
loginName: 'reconnect-user',
|
||||
nickname: '重连用户',
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
useUserStore().setUserInfo(account);
|
||||
aguiMocks.run.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
aguiMocks.resume
|
||||
.mockRejectedValueOnce(new TypeError('Network unavailable'))
|
||||
.mockRejectedValueOnce(new TypeError('Network unavailable'))
|
||||
.mockImplementationOnce(async (options: EasyFlowAguiResumeOptions) => {
|
||||
await options.onEvent({
|
||||
runId: 'run-test',
|
||||
threadId: 'reconnect-session',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
});
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '断网恢复',
|
||||
sessionId: 'reconnect-session',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(aguiMocks.resume).toHaveBeenCalledTimes(3);
|
||||
expect(agentChatRuntimeManager.getSnapshot('reconnect-session')).toEqual(
|
||||
expect.objectContaining({ completed: true, sending: false }),
|
||||
);
|
||||
clearAgentChatBrowserCache(account);
|
||||
});
|
||||
|
||||
it('合并流式通知并在 AG-UI 终态立即持久化', async () => {
|
||||
vi.useFakeTimers();
|
||||
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||
@@ -280,13 +463,26 @@ describe('agentChatRuntimeManager', () => {
|
||||
nickname: '竞态用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
aguiMocks.resume.mockImplementationOnce(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
options.onEvent({
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户已停止生成',
|
||||
runId: 'run-test',
|
||||
threadId: 'race-session',
|
||||
type: EventType.RUN_ERROR,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '旧问题',
|
||||
sessionId: 'race-session',
|
||||
});
|
||||
agentChatRuntimeManager.stop('race-session');
|
||||
await agentChatRuntimeManager.stop('race-session');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '新问题',
|
||||
@@ -360,4 +556,238 @@ describe('agentChatRuntimeManager', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('取消请求失败且原流无终态时保留等待期间事件并立即恢复订阅', async () => {
|
||||
let rejectCancel: ((error: Error) => void) | undefined;
|
||||
let resolveRun: (() => void) | undefined;
|
||||
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||
runOptions = options;
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
});
|
||||
aguiMocks.detach.mockImplementationOnce(() => resolveRun?.());
|
||||
aguiMocks.cancel.mockImplementation(
|
||||
() =>
|
||||
new Promise<undefined>((_resolve, reject) => {
|
||||
rejectCancel = reject;
|
||||
}),
|
||||
);
|
||||
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'cancel-failed-user',
|
||||
loginName: 'cancel-failed-user',
|
||||
nickname: '取消失败用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '取消失败恢复',
|
||||
sessionId: 'cancel-failed-session',
|
||||
});
|
||||
|
||||
const stopPromise = agentChatRuntimeManager.stop('cancel-failed-session');
|
||||
runOptions?.onEvent({
|
||||
delta: '取消等待期间正文',
|
||||
messageId: 'assistant-cancel-failed',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
runOptions?.onCursor?.(1);
|
||||
rejectCancel?.(new Error('取消接口暂时不可用'));
|
||||
|
||||
await expect(stopPromise).rejects.toThrow('取消接口暂时不可用');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
JSON.stringify(
|
||||
agentChatRuntimeManager.getSnapshot('cancel-failed-session')?.items,
|
||||
),
|
||||
).toContain('取消等待期间正文');
|
||||
expect(
|
||||
agentChatRuntimeManager.getSnapshot('cancel-failed-session'),
|
||||
).toEqual(expect.objectContaining({ sending: true }));
|
||||
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('取消成功后重放服务端终态且不把尚未投影的自然完成改成取消', async () => {
|
||||
aguiMocks.resume.mockImplementationOnce(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
options.onEvent({
|
||||
runId: 'run-test',
|
||||
threadId: 'cancel-authoritative-session',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
options.onCursor?.(8);
|
||||
},
|
||||
);
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'cancel-authoritative-user',
|
||||
loginName: 'cancel-authoritative-user',
|
||||
nickname: '取消终态仲裁用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '取消时服务端已经自然完成',
|
||||
sessionId: 'cancel-authoritative-session',
|
||||
});
|
||||
|
||||
await agentChatRuntimeManager.stop('cancel-authoritative-session');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
agentChatRuntimeManager.getSnapshot('cancel-authoritative-session'),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
completed: true,
|
||||
lastCursor: 8,
|
||||
sending: false,
|
||||
terminalOutcome: 'success',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
JSON.stringify(
|
||||
agentChatRuntimeManager.getSnapshot('cancel-authoritative-session')
|
||||
?.items,
|
||||
),
|
||||
).not.toContain('"turnSucceeded":false');
|
||||
});
|
||||
|
||||
it('取消请求失败但原流已到终态时直接展示权威完成态', async () => {
|
||||
let rejectCancel: ((error: Error) => void) | undefined;
|
||||
let resolveRun: (() => void) | undefined;
|
||||
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||
runOptions = options;
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
});
|
||||
aguiMocks.cancel.mockImplementation(
|
||||
() =>
|
||||
new Promise<undefined>((_resolve, reject) => {
|
||||
rejectCancel = reject;
|
||||
}),
|
||||
);
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'cancel-terminal-user',
|
||||
loginName: 'cancel-terminal-user',
|
||||
nickname: '取消终态用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '取消与完成竞态',
|
||||
sessionId: 'cancel-terminal-session',
|
||||
});
|
||||
|
||||
const stopPromise = agentChatRuntimeManager.stop('cancel-terminal-session');
|
||||
runOptions?.onEvent({
|
||||
runId: 'run-test',
|
||||
threadId: 'cancel-terminal-session',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
runOptions?.onCursor?.(1);
|
||||
resolveRun?.();
|
||||
await Promise.resolve();
|
||||
rejectCancel?.(new Error('取消接口晚到失败'));
|
||||
|
||||
await expect(stopPromise).rejects.toThrow('取消接口晚到失败');
|
||||
expect(
|
||||
agentChatRuntimeManager.getSnapshot('cancel-terminal-session'),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
completed: true,
|
||||
sending: false,
|
||||
terminalOutcome: 'success',
|
||||
}),
|
||||
);
|
||||
expect(aguiMocks.resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('首条传输闪断后按最后游标恢复且不会重复投影', async () => {
|
||||
aguiMocks.run.mockImplementation(
|
||||
async (options: EasyFlowAguiRunOptions) => {
|
||||
options.onEvent({
|
||||
delta: '前半段',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
options.onCursor?.(7);
|
||||
throw new Error('network reset');
|
||||
},
|
||||
);
|
||||
aguiMocks.resume.mockImplementation(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
expect(options.afterCursor).toBe(7);
|
||||
options.onEvent({
|
||||
delta: '后半段',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
options.onEvent({
|
||||
runId: 'run-test',
|
||||
threadId: 'recover-session',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
},
|
||||
);
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'recover-user',
|
||||
loginName: 'recover-user',
|
||||
nickname: '恢复用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '恢复测试',
|
||||
sessionId: 'recover-session',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
|
||||
expect(agentChatRuntimeManager.getSnapshot('recover-session')).toEqual(
|
||||
expect.objectContaining({ completed: true, sending: false }),
|
||||
);
|
||||
});
|
||||
|
||||
const timeline = JSON.stringify(
|
||||
agentChatRuntimeManager.getSnapshot('recover-session')?.items,
|
||||
);
|
||||
expect(timeline).toContain('前半段后半段');
|
||||
expect(timeline.match(/前半段/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('pagehide 只断开页面传输并保留可恢复运行态', async () => {
|
||||
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
|
||||
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'reload-user',
|
||||
loginName: 'reload-user',
|
||||
nickname: '刷新用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '刷新测试',
|
||||
sessionId: 'reload-session',
|
||||
});
|
||||
|
||||
globalThis.dispatchEvent(new Event('pagehide'));
|
||||
|
||||
expect(aguiMocks.detach).toHaveBeenCalled();
|
||||
expect(agentChatRuntimeManager.getSnapshot('reload-session')).toEqual(
|
||||
expect.objectContaining({ completed: false, sending: true }),
|
||||
);
|
||||
|
||||
globalThis.dispatchEvent(new Event('pageshow'));
|
||||
expect(aguiMocks.resume).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,18 @@ import {
|
||||
RUNTIME_STORAGE_PREFIX,
|
||||
} from '#/utils/agent-chat-cache';
|
||||
|
||||
import { EasyFlowAguiClient } from '../shared/agent-agui/client';
|
||||
import {
|
||||
createAguiRunId,
|
||||
EasyFlowAguiClient,
|
||||
} from '../shared/agent-agui/client';
|
||||
import {
|
||||
applyAguiEventToTimeline,
|
||||
createAguiTimelineProjectionState,
|
||||
} from '../shared/agent-agui/projection';
|
||||
import {
|
||||
isRetryableAguiTransportError,
|
||||
resumeAguiRunUntilTerminal,
|
||||
} from '../shared/agent-agui/reconnect';
|
||||
import { generateAgentSessionId } from './api';
|
||||
|
||||
interface RuntimeSessionState {
|
||||
@@ -31,11 +38,19 @@ interface RuntimeSessionState {
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
identity: string;
|
||||
inputAccepted: boolean;
|
||||
inputAcceptedNotified: boolean;
|
||||
items: ChatTimelineItem[];
|
||||
lastCursor: number;
|
||||
prompt: string;
|
||||
projectionToolArgs: Record<string, string>;
|
||||
projectionToolNames: Record<string, string>;
|
||||
runId?: string;
|
||||
roundId: string;
|
||||
sending: boolean;
|
||||
sessionId: string;
|
||||
startedAt: number;
|
||||
terminalOutcome?: 'failed' | 'success';
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@@ -44,10 +59,18 @@ interface StoredRuntimeSession {
|
||||
agentName?: string;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
inputAccepted: boolean;
|
||||
items: ChatTimelineItem[];
|
||||
lastCursor?: number;
|
||||
prompt: string;
|
||||
projectionToolArgs?: Record<string, string>;
|
||||
projectionToolNames?: Record<string, string>;
|
||||
runId?: string;
|
||||
roundId: string;
|
||||
sending: boolean;
|
||||
sessionId: string;
|
||||
startedAt: number;
|
||||
terminalOutcome?: 'failed' | 'success';
|
||||
updatedAt: number;
|
||||
version: number;
|
||||
}
|
||||
@@ -66,7 +89,7 @@ interface StartOptions {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const STORAGE_VERSION = 2;
|
||||
const STORAGE_VERSION = 5;
|
||||
const STREAM_NOTIFY_INTERVAL_MS = 50;
|
||||
const STREAM_PERSIST_INTERVAL_MS = 300;
|
||||
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
|
||||
@@ -76,7 +99,10 @@ const listeners = new Set<() => void>();
|
||||
const latestSessionIds = new Map<string, string>();
|
||||
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const runClients = new Map<string, EasyFlowAguiClient>();
|
||||
const stoppingSessions = new Set<string>();
|
||||
const inputAcceptedCallbacks = new Map<string, () => Promise<void> | void>();
|
||||
let notifyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pageDetaching = false;
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
const serialized = JSON.stringify(value);
|
||||
@@ -145,10 +171,18 @@ function persistSession(state: RuntimeSessionState) {
|
||||
agentName: state.agentName,
|
||||
completed: state.completed,
|
||||
error: state.error,
|
||||
inputAccepted: state.inputAccepted,
|
||||
items: state.items,
|
||||
lastCursor: state.lastCursor,
|
||||
prompt: state.prompt,
|
||||
projectionToolArgs: state.projectionToolArgs,
|
||||
projectionToolNames: state.projectionToolNames,
|
||||
runId: state.runId,
|
||||
roundId: state.roundId,
|
||||
sending: state.sending,
|
||||
sessionId: state.sessionId,
|
||||
startedAt: state.startedAt,
|
||||
terminalOutcome: state.terminalOutcome,
|
||||
updatedAt: state.updatedAt,
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
@@ -177,6 +211,7 @@ function removeSessionCache(
|
||||
state: RuntimeSessionState,
|
||||
) {
|
||||
cancelPersistTimer(scopedSessionKey);
|
||||
inputAcceptedCallbacks.delete(scopedSessionKey);
|
||||
sessions.delete(scopedSessionKey);
|
||||
try {
|
||||
safeSessionStorage()?.removeItem(
|
||||
@@ -262,7 +297,10 @@ function restoreSession(identity: string, sessionId: string) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as StoredRuntimeSession;
|
||||
if (parsed.version !== STORAGE_VERSION || parsed.sessionId !== sessionId) {
|
||||
if (
|
||||
![3, 4, STORAGE_VERSION].includes(parsed.version) ||
|
||||
parsed.sessionId !== sessionId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const restored: RuntimeSessionState = {
|
||||
@@ -271,11 +309,31 @@ function restoreSession(identity: string, sessionId: string) {
|
||||
completed: parsed.completed,
|
||||
error: parsed.error,
|
||||
identity,
|
||||
inputAccepted: Boolean(parsed.inputAccepted),
|
||||
inputAcceptedNotified: false,
|
||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||
lastCursor: Math.max(0, Number(parsed.lastCursor || 0)),
|
||||
prompt: parsed.prompt,
|
||||
projectionToolArgs:
|
||||
parsed.projectionToolArgs &&
|
||||
typeof parsed.projectionToolArgs === 'object'
|
||||
? parsed.projectionToolArgs
|
||||
: {},
|
||||
projectionToolNames:
|
||||
parsed.projectionToolNames &&
|
||||
typeof parsed.projectionToolNames === 'object'
|
||||
? parsed.projectionToolNames
|
||||
: {},
|
||||
runId: parsed.runId,
|
||||
roundId: parsed.roundId,
|
||||
sending: false,
|
||||
sending: Boolean(parsed.sending && parsed.runId),
|
||||
sessionId,
|
||||
startedAt: Number(parsed.startedAt || parsed.updatedAt || Date.now()),
|
||||
terminalOutcome:
|
||||
parsed.terminalOutcome === 'failed' ||
|
||||
parsed.terminalOutcome === 'success'
|
||||
? parsed.terminalOutcome
|
||||
: undefined,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
sessions.set(scopedSessionKey, restored);
|
||||
@@ -293,6 +351,45 @@ function upsertState(state: RuntimeSessionState) {
|
||||
notifyNow();
|
||||
}
|
||||
|
||||
function notifyInputAccepted(state: RuntimeSessionState) {
|
||||
if (state.inputAcceptedNotified) {
|
||||
return;
|
||||
}
|
||||
const callback = inputAcceptedCallbacks.get(
|
||||
sessionKey(state.identity, state.sessionId),
|
||||
);
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
state.inputAcceptedNotified = true;
|
||||
void callback();
|
||||
}
|
||||
|
||||
function registerInputAcceptedCallback(
|
||||
state: RuntimeSessionState,
|
||||
callback?: () => Promise<void> | void,
|
||||
) {
|
||||
if (callback) {
|
||||
inputAcceptedCallbacks.set(
|
||||
sessionKey(state.identity, state.sessionId),
|
||||
callback,
|
||||
);
|
||||
}
|
||||
if (state.inputAccepted) {
|
||||
notifyInputAccepted(state);
|
||||
}
|
||||
}
|
||||
|
||||
function acceptInput(
|
||||
state: RuntimeSessionState,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
replaceAcceptedAttachments(state.items, state.roundId, payload);
|
||||
state.inputAccepted = true;
|
||||
persistSession(state);
|
||||
notifyInputAccepted(state);
|
||||
}
|
||||
|
||||
function runningSession(identity = identityScope()) {
|
||||
return [...sessions.values()].find(
|
||||
(session) => session.identity === identity && session.sending,
|
||||
@@ -409,10 +506,93 @@ function replaceAcceptedAttachments(
|
||||
}
|
||||
}
|
||||
|
||||
function resetRoundForReplay(state: RuntimeSessionState) {
|
||||
state.items = state.items.filter(
|
||||
(item) =>
|
||||
item.roundId !== state.roundId ||
|
||||
(item.type === 'message' && item.role === 'user'),
|
||||
);
|
||||
ChatTimelineBuilder.ensureAssistantTurn(state.items, {
|
||||
id: `turn-${state.roundId}`,
|
||||
roundId: state.roundId,
|
||||
turnStartedAt: state.startedAt,
|
||||
});
|
||||
state.completed = false;
|
||||
state.error = undefined;
|
||||
state.sending = true;
|
||||
state.terminalOutcome = undefined;
|
||||
}
|
||||
|
||||
function restoreProjectionState(state: RuntimeSessionState) {
|
||||
const projectionState = createAguiTimelineProjectionState(state.startedAt);
|
||||
projectionState.toolArgs = new Map(Object.entries(state.projectionToolArgs));
|
||||
projectionState.toolNames = new Map(
|
||||
Object.entries(state.projectionToolNames),
|
||||
);
|
||||
return projectionState;
|
||||
}
|
||||
|
||||
function captureProjectionCursor(
|
||||
state: RuntimeSessionState,
|
||||
projectionState: ReturnType<typeof createAguiTimelineProjectionState>,
|
||||
cursor: number,
|
||||
) {
|
||||
state.lastCursor = Math.max(state.lastCursor, cursor);
|
||||
state.projectionToolArgs = Object.fromEntries(projectionState.toolArgs);
|
||||
state.projectionToolNames = Object.fromEntries(projectionState.toolNames);
|
||||
}
|
||||
|
||||
function finishRuntimeSuccess(state: RuntimeSessionState) {
|
||||
state.sending = false;
|
||||
state.completed = true;
|
||||
const cancelled = state.items.some(
|
||||
(item) =>
|
||||
item.roundId === state.roundId &&
|
||||
item.turnFinishedAt !== undefined &&
|
||||
item.turnSucceeded === false,
|
||||
);
|
||||
if (!cancelled) {
|
||||
ChatTimelineBuilder.finalize(state.items, {
|
||||
roundCompleted: true,
|
||||
roundId: state.roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: true,
|
||||
});
|
||||
}
|
||||
upsertState(state);
|
||||
}
|
||||
|
||||
function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) {
|
||||
state.error = errorMessage(error);
|
||||
state.sending = false;
|
||||
state.completed = true;
|
||||
const last = state.items[state.items.length - 1];
|
||||
if (last?.type !== 'error') {
|
||||
ChatTimelineBuilder.appendError(state.items, state.error, {
|
||||
roundId: state.roundId,
|
||||
});
|
||||
}
|
||||
ChatTimelineBuilder.finalize(state.items, {
|
||||
roundId: state.roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: false,
|
||||
});
|
||||
upsertState(state);
|
||||
}
|
||||
|
||||
function observeTerminalEvent(state: RuntimeSessionState, eventType: string) {
|
||||
if (eventType === EventType.RUN_FINISHED) {
|
||||
state.terminalOutcome = 'success';
|
||||
} else if (eventType === EventType.RUN_ERROR) {
|
||||
state.terminalOutcome = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
onAgentChatCacheClear((identity) => {
|
||||
for (const [key, session] of sessions) {
|
||||
if (session.identity === identity) {
|
||||
cancelPersistTimer(key);
|
||||
inputAcceptedCallbacks.delete(key);
|
||||
sessions.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -447,6 +627,111 @@ export const agentChatRuntimeManager = {
|
||||
upsertState(state);
|
||||
},
|
||||
|
||||
resume(sessionId?: string, onInputAccepted?: () => Promise<void> | void) {
|
||||
const identity = identityScope();
|
||||
const state = sessionId
|
||||
? restoreSession(identity, sessionId)
|
||||
: runningSession(identity);
|
||||
if (state) {
|
||||
registerInputAcceptedCallback(state, onInputAccepted);
|
||||
}
|
||||
if (!state?.sending || !state.runId) {
|
||||
return;
|
||||
}
|
||||
if (state.terminalOutcome) {
|
||||
finishRuntimeSuccess(state);
|
||||
return;
|
||||
}
|
||||
const scopedSessionKey = sessionKey(identity, state.sessionId);
|
||||
if (runClients.has(scopedSessionKey)) {
|
||||
return;
|
||||
}
|
||||
if (state.lastCursor === 0) {
|
||||
resetRoundForReplay(state);
|
||||
}
|
||||
upsertState(state);
|
||||
const projectionState = restoreProjectionState(state);
|
||||
const runClient = new EasyFlowAguiClient();
|
||||
runClients.set(scopedSessionKey, runClient);
|
||||
void resumeAguiRunUntilTerminal({
|
||||
client: runClient,
|
||||
onEvent(event) {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (!current || current !== state || !current.sending) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code !== 'RUN_CANCELLED'
|
||||
) {
|
||||
current.error = event.message || '发送失败,请稍后再试';
|
||||
}
|
||||
observeTerminalEvent(current, event.type);
|
||||
applyAguiEventToTimeline(
|
||||
current.items,
|
||||
event,
|
||||
{
|
||||
onInputAccepted(payload) {
|
||||
acceptInput(current, payload);
|
||||
},
|
||||
roundId: current.roundId,
|
||||
startedAt: current.startedAt,
|
||||
},
|
||||
projectionState,
|
||||
);
|
||||
scheduleStateUpdate(current, event.type === EventType.TOOL_CALL_START);
|
||||
},
|
||||
initialCursor: state.lastCursor,
|
||||
onCursor(cursor) {
|
||||
captureProjectionCursor(state, projectionState, cursor);
|
||||
},
|
||||
runId: state.runId,
|
||||
shouldContinue: () => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
return Boolean(
|
||||
runClients.get(scopedSessionKey) === runClient &&
|
||||
!pageDetaching &&
|
||||
current &&
|
||||
current === state &&
|
||||
current.sending,
|
||||
);
|
||||
},
|
||||
startedAt: state.startedAt,
|
||||
})
|
||||
.then((terminalReceived) => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (
|
||||
terminalReceived &&
|
||||
runClients.get(scopedSessionKey) === runClient &&
|
||||
!pageDetaching &&
|
||||
!stoppingSessions.has(scopedSessionKey) &&
|
||||
current &&
|
||||
current === state &&
|
||||
current.sending
|
||||
) {
|
||||
finishRuntimeSuccess(current);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (
|
||||
runClients.get(scopedSessionKey) === runClient &&
|
||||
!pageDetaching &&
|
||||
!stoppingSessions.has(scopedSessionKey) &&
|
||||
current &&
|
||||
current === state &&
|
||||
current.sending
|
||||
) {
|
||||
finishRuntimeFailure(current, error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (runClients.get(scopedSessionKey) === runClient) {
|
||||
runClients.delete(scopedSessionKey);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async start(options: StartOptions) {
|
||||
const identity = identityScope();
|
||||
if (!identity) {
|
||||
@@ -458,19 +743,29 @@ export const agentChatRuntimeManager = {
|
||||
}
|
||||
const sessionId = await resolveSessionId(options.sessionId);
|
||||
const roundId = createRoundId();
|
||||
const runId = createAguiRunId();
|
||||
const startedAt = Date.now();
|
||||
const state: RuntimeSessionState = {
|
||||
agentId: options.agentId,
|
||||
agentName: options.agentName,
|
||||
completed: false,
|
||||
identity,
|
||||
inputAccepted: false,
|
||||
inputAcceptedNotified: false,
|
||||
items: clone(options.baseItems || []),
|
||||
lastCursor: 0,
|
||||
prompt: options.prompt,
|
||||
projectionToolArgs: {},
|
||||
projectionToolNames: {},
|
||||
runId,
|
||||
roundId,
|
||||
sending: true,
|
||||
sessionId,
|
||||
startedAt,
|
||||
terminalOutcome: undefined,
|
||||
updatedAt: startedAt,
|
||||
};
|
||||
registerInputAcceptedCallback(state, options.onInputAccepted);
|
||||
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
|
||||
documents: options.documents,
|
||||
images: options.images,
|
||||
@@ -487,6 +782,35 @@ export const agentChatRuntimeManager = {
|
||||
const projectionState = createAguiTimelineProjectionState(startedAt);
|
||||
const runClient = new EasyFlowAguiClient();
|
||||
runClients.set(scopedSessionKey, runClient);
|
||||
let lastCursor = 0;
|
||||
const projectEvent: Parameters<EasyFlowAguiClient['run']>[0]['onEvent'] = (
|
||||
event,
|
||||
) => {
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || current !== state || !current.sending) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code !== 'RUN_CANCELLED'
|
||||
) {
|
||||
current.error = event.message || '发送失败,请稍后再试';
|
||||
}
|
||||
observeTerminalEvent(current, event.type);
|
||||
applyAguiEventToTimeline(
|
||||
current.items,
|
||||
event,
|
||||
{
|
||||
onInputAccepted(payload) {
|
||||
acceptInput(current, payload);
|
||||
},
|
||||
roundId,
|
||||
startedAt,
|
||||
},
|
||||
projectionState,
|
||||
);
|
||||
scheduleStateUpdate(current, event.type === EventType.TOOL_CALL_START);
|
||||
};
|
||||
void runClient
|
||||
.run({
|
||||
forwardedProps: {
|
||||
@@ -498,35 +822,12 @@ export const agentChatRuntimeManager = {
|
||||
},
|
||||
},
|
||||
},
|
||||
onEvent(event) {
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || current !== state || !current.sending) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code !== 'RUN_CANCELLED'
|
||||
) {
|
||||
current.error = event.message || '发送失败,请稍后再试';
|
||||
}
|
||||
applyAguiEventToTimeline(
|
||||
current.items,
|
||||
event,
|
||||
{
|
||||
onInputAccepted(payload) {
|
||||
replaceAcceptedAttachments(current.items, roundId, payload);
|
||||
void options.onInputAccepted?.();
|
||||
},
|
||||
roundId,
|
||||
startedAt,
|
||||
},
|
||||
projectionState,
|
||||
);
|
||||
scheduleStateUpdate(
|
||||
current,
|
||||
event.type === EventType.TOOL_CALL_START,
|
||||
);
|
||||
onCursor(cursor) {
|
||||
lastCursor = Math.max(lastCursor, cursor);
|
||||
captureProjectionCursor(state, projectionState, cursor);
|
||||
},
|
||||
onEvent: projectEvent,
|
||||
runId,
|
||||
threadId: sessionId,
|
||||
url: `/api/v1/agent/${encodeURIComponent(options.agentId)}/agui/run`,
|
||||
userMessage: {
|
||||
@@ -535,49 +836,62 @@ export const agentChatRuntimeManager = {
|
||||
role: 'user',
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (!current || current !== state || !current.sending) {
|
||||
.catch(async (error) => {
|
||||
if (pageDetaching || !state.sending) {
|
||||
return;
|
||||
}
|
||||
current.sending = false;
|
||||
current.completed = true;
|
||||
const cancelled = current.items.some(
|
||||
(item) =>
|
||||
item.roundId === roundId &&
|
||||
item.turnFinishedAt !== undefined &&
|
||||
item.turnSucceeded === false,
|
||||
);
|
||||
if (!cancelled) {
|
||||
ChatTimelineBuilder.finalize(current.items, {
|
||||
roundCompleted: true,
|
||||
roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: true,
|
||||
});
|
||||
if (!isRetryableAguiTransportError(error, state.startedAt)) {
|
||||
throw error;
|
||||
}
|
||||
upsertState(current);
|
||||
return resumeAguiRunUntilTerminal({
|
||||
client: runClient,
|
||||
initialCursor: lastCursor,
|
||||
onCursor(cursor) {
|
||||
captureProjectionCursor(state, projectionState, cursor);
|
||||
},
|
||||
onEvent: projectEvent,
|
||||
runId,
|
||||
shouldContinue: () => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
return Boolean(
|
||||
runClients.get(scopedSessionKey) === runClient &&
|
||||
!pageDetaching &&
|
||||
current &&
|
||||
current === state &&
|
||||
current.sending,
|
||||
);
|
||||
},
|
||||
startedAt,
|
||||
});
|
||||
})
|
||||
.then((terminalReceived = true) => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (
|
||||
!terminalReceived ||
|
||||
runClients.get(scopedSessionKey) !== runClient ||
|
||||
pageDetaching ||
|
||||
stoppingSessions.has(scopedSessionKey) ||
|
||||
!current ||
|
||||
current !== state ||
|
||||
!current.sending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
finishRuntimeSuccess(current);
|
||||
})
|
||||
.catch((error) => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (!current || current !== state || !current.sending) {
|
||||
if (
|
||||
runClients.get(scopedSessionKey) !== runClient ||
|
||||
pageDetaching ||
|
||||
stoppingSessions.has(scopedSessionKey) ||
|
||||
!current ||
|
||||
current !== state ||
|
||||
!current.sending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
current.error = errorMessage(error);
|
||||
current.sending = false;
|
||||
current.completed = true;
|
||||
const last = current.items[current.items.length - 1];
|
||||
if (last?.type !== 'error') {
|
||||
ChatTimelineBuilder.appendError(current.items, current.error, {
|
||||
roundId,
|
||||
});
|
||||
}
|
||||
ChatTimelineBuilder.finalize(current.items, {
|
||||
roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: false,
|
||||
});
|
||||
upsertState(current);
|
||||
finishRuntimeFailure(current, error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (runClients.get(scopedSessionKey) === runClient) {
|
||||
@@ -588,7 +902,7 @@ export const agentChatRuntimeManager = {
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
stop(sessionId?: string) {
|
||||
async stop(sessionId?: string) {
|
||||
const identity = identityScope();
|
||||
const state = sessionId
|
||||
? restoreSession(identity, sessionId)
|
||||
@@ -596,16 +910,44 @@ export const agentChatRuntimeManager = {
|
||||
if (!state || !state.sending) {
|
||||
return;
|
||||
}
|
||||
runClients.get(sessionKey(identity, state.sessionId))?.abort();
|
||||
runClients.delete(sessionKey(identity, state.sessionId));
|
||||
state.sending = false;
|
||||
state.completed = true;
|
||||
ChatTimelineBuilder.finalize(state.items, {
|
||||
roundId: state.roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: false,
|
||||
});
|
||||
const scopedSessionKey = sessionKey(identity, state.sessionId);
|
||||
if (stoppingSessions.has(scopedSessionKey)) {
|
||||
return;
|
||||
}
|
||||
stoppingSessions.add(scopedSessionKey);
|
||||
const runClient = runClients.get(scopedSessionKey);
|
||||
if (state.runId) {
|
||||
try {
|
||||
await (runClient || new EasyFlowAguiClient()).cancel(state.runId);
|
||||
} catch (error) {
|
||||
stoppingSessions.delete(scopedSessionKey);
|
||||
runClient?.detach();
|
||||
if (runClients.get(scopedSessionKey) === runClient) {
|
||||
runClients.delete(scopedSessionKey);
|
||||
}
|
||||
if (state.terminalOutcome) {
|
||||
finishRuntimeSuccess(state);
|
||||
} else {
|
||||
state.error = errorMessage(error);
|
||||
upsertState(state);
|
||||
this.resume(state.sessionId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
runClient?.detach();
|
||||
}
|
||||
stoppingSessions.delete(scopedSessionKey);
|
||||
if (runClients.get(scopedSessionKey) === runClient) {
|
||||
runClients.delete(scopedSessionKey);
|
||||
}
|
||||
if (state.terminalOutcome) {
|
||||
finishRuntimeSuccess(state);
|
||||
return;
|
||||
}
|
||||
// 取消接口成功只说明服务端已接受命令,最终结果仍以可重放事件流中的权威终态为准。
|
||||
upsertState(state);
|
||||
this.resume(state.sessionId);
|
||||
},
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
@@ -615,3 +957,28 @@ export const agentChatRuntimeManager = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof globalThis.addEventListener === 'function') {
|
||||
globalThis.addEventListener('pagehide', () => {
|
||||
pageDetaching = true;
|
||||
for (const client of runClients.values()) {
|
||||
client.detach();
|
||||
}
|
||||
runClients.clear();
|
||||
stoppingSessions.clear();
|
||||
for (const state of sessions.values()) {
|
||||
if (state.sending) {
|
||||
persistSession(state);
|
||||
}
|
||||
}
|
||||
});
|
||||
globalThis.addEventListener('pageshow', () => {
|
||||
if (!pageDetaching) return;
|
||||
pageDetaching = false;
|
||||
for (const state of sessions.values()) {
|
||||
if (state.sending) {
|
||||
agentChatRuntimeManager.resume(state.sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -711,10 +711,7 @@ async function sendContent(rawContent: string) {
|
||||
})),
|
||||
imageUploadIds: composer.images.uploadIds.value,
|
||||
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||
onInputAccepted: () =>
|
||||
composer.markAccepted().catch(() => {
|
||||
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||
}),
|
||||
onInputAccepted: markComposerInputAccepted,
|
||||
prompt: content,
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
@@ -752,6 +749,12 @@ async function activateComposer(agentId: string, sessionId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function markComposerInputAccepted() {
|
||||
return composer.markAccepted().catch(() => {
|
||||
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||
});
|
||||
}
|
||||
|
||||
function chooseAttachmentFiles() {
|
||||
attachmentFileInputRef.value?.click();
|
||||
}
|
||||
@@ -961,7 +964,7 @@ function handlePromptKeydown(event: Event | KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
async function handleStop() {
|
||||
if (!canStopRuntime.value) {
|
||||
return;
|
||||
}
|
||||
@@ -970,7 +973,12 @@ function handleStop() {
|
||||
currentSessionId.value
|
||||
? currentSessionId.value
|
||||
: agentChatRuntimeManager.getLatestSnapshot()?.sessionId;
|
||||
agentChatRuntimeManager.stop(runningSessionId);
|
||||
try {
|
||||
await agentChatRuntimeManager.stop(runningSessionId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '停止失败');
|
||||
return;
|
||||
}
|
||||
if (runningSessionId === currentSessionId.value) {
|
||||
syncRuntimeSnapshot(currentSessionId.value);
|
||||
} else {
|
||||
@@ -1094,6 +1102,13 @@ async function bootstrap() {
|
||||
await Promise.all([loadAgents(), loadSessions(), loadKnowledges()]);
|
||||
const routeSessionId = String(route.query.sessionId || '');
|
||||
if (routeSessionId) {
|
||||
const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(routeSessionId);
|
||||
if (runtimeSnapshot?.sending) {
|
||||
syncRuntimeSnapshot(routeSessionId);
|
||||
await activateComposer(selectedAgentId.value, routeSessionId);
|
||||
agentChatRuntimeManager.resume(routeSessionId, markComposerInputAccepted);
|
||||
return;
|
||||
}
|
||||
await loadConversation(routeSessionId);
|
||||
return;
|
||||
}
|
||||
@@ -1101,6 +1116,10 @@ async function bootstrap() {
|
||||
if (latestSnapshot?.items.length) {
|
||||
syncRuntimeSnapshot(latestSnapshot.sessionId);
|
||||
await activateComposer(selectedAgentId.value, latestSnapshot.sessionId);
|
||||
agentChatRuntimeManager.resume(
|
||||
latestSnapshot.sessionId,
|
||||
markComposerInputAccepted,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await activateComposer(selectedAgentId.value);
|
||||
|
||||
@@ -82,8 +82,19 @@ function getDraftContext() {
|
||||
};
|
||||
}
|
||||
|
||||
function markComposerInputAccepted() {
|
||||
return composer.markAccepted().catch(() => {
|
||||
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||
});
|
||||
}
|
||||
|
||||
function syncCurrentDraftContext(restore = false) {
|
||||
syncDraftContext(getDraftContext(), restore, composer.sessionId.value);
|
||||
syncDraftContext(
|
||||
getDraftContext(),
|
||||
restore,
|
||||
composer.sessionId.value,
|
||||
markComposerInputAccepted,
|
||||
);
|
||||
}
|
||||
|
||||
async function activateComposer() {
|
||||
@@ -157,10 +168,7 @@ async function handleSend(prompt: string) {
|
||||
imageUploadIds: composer.images.uploadIds.value,
|
||||
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||
sessionId: composer.sessionId.value,
|
||||
onAccepted: () =>
|
||||
composer.markAccepted().catch(() => {
|
||||
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||
}),
|
||||
onAccepted: markComposerInputAccepted,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -315,11 +323,15 @@ async function handleRemoveDocument(item: ChatDocumentAttachment) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
async function handleStop() {
|
||||
if (!loading.value) {
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
try {
|
||||
await stop();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '停止失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(payload: ChatTimelineToolApprovalPayload) {
|
||||
|
||||
@@ -263,4 +263,112 @@ describe('useAgentTryoutRawRounds', () => {
|
||||
expect(restored.currentVariant(roundId)?.status).toBe('error');
|
||||
expect(JSON.stringify(restored.buildTimelineItems())).toContain('调用失败');
|
||||
});
|
||||
|
||||
it('刷新后保留运行标识并可重置当前变体进行权威重放', () => {
|
||||
const sessionId = 'resume-running';
|
||||
const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
const roundId = store.createRound('长回答');
|
||||
store.bindRun(roundId, 'run-resume');
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
delta: '刷新前片段',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
}),
|
||||
);
|
||||
store.flush();
|
||||
|
||||
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
expect(restored.recoverableRun()).toEqual(
|
||||
expect.objectContaining({
|
||||
roundId,
|
||||
runId: 'run-resume',
|
||||
}),
|
||||
);
|
||||
|
||||
restored.resetRuntimeEvents(roundId);
|
||||
expect(restored.currentVariant(roundId)?.runtimeEvents).toEqual([]);
|
||||
expect(restored.currentVariant(roundId)?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('不恢复终态后超过二十四小时的草稿影子记录', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2 * 24 * 60 * 60 * 1000);
|
||||
const sessionId = 'expired-draft';
|
||||
sessionStorage.setItem(
|
||||
`easyflow:agent-tryout-raw-rounds:draft:${sessionId}`,
|
||||
JSON.stringify({
|
||||
rounds: [
|
||||
{
|
||||
createdAt: 1,
|
||||
prompt: '过期问题',
|
||||
roundId: 'expired-round',
|
||||
selectedVariantIndex: 1,
|
||||
status: 'completed',
|
||||
updatedAt: 1,
|
||||
variants: [
|
||||
{
|
||||
createdAt: 1,
|
||||
runId: 'run-expired',
|
||||
runtimeEvents: [],
|
||||
status: 'completed',
|
||||
updatedAt: 1,
|
||||
variantIndex: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sessionId,
|
||||
version: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
|
||||
expect(restored.recoverableRun()).toBeUndefined();
|
||||
expect(restored.buildTimelineItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it('运行中影子超过二十四小时仍保留到服务端确认终态', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2 * 24 * 60 * 60 * 1000);
|
||||
const sessionId = 'old-running-draft';
|
||||
sessionStorage.setItem(
|
||||
`easyflow:agent-tryout-raw-rounds:draft:${sessionId}`,
|
||||
JSON.stringify({
|
||||
rounds: [
|
||||
{
|
||||
createdAt: 1,
|
||||
prompt: '仍在运行的问题',
|
||||
roundId: 'old-running-round',
|
||||
selectedVariantIndex: 1,
|
||||
status: 'running',
|
||||
updatedAt: 1,
|
||||
variants: [
|
||||
{
|
||||
createdAt: 1,
|
||||
runId: 'run-old-running',
|
||||
runtimeEvents: [],
|
||||
status: 'running',
|
||||
updatedAt: 1,
|
||||
variantIndex: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sessionId,
|
||||
version: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
|
||||
expect(restored.recoverableRun()).toEqual(
|
||||
expect.objectContaining({
|
||||
roundId: 'old-running-round',
|
||||
runId: 'run-old-running',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
import { EventSchemas, EventType } from '@ag-ui/client';
|
||||
|
||||
import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events';
|
||||
import {
|
||||
applyAguiEventToTimeline,
|
||||
createAguiTimelineProjectionState,
|
||||
@@ -21,6 +22,9 @@ type AgentTryoutRoundStatus = 'completed' | 'error' | 'running';
|
||||
|
||||
interface AgentTryoutRawVariant {
|
||||
createdAt: number;
|
||||
inputAccepted: boolean;
|
||||
lastCursor: number;
|
||||
runId?: string;
|
||||
runtimeEvents: AgentTryoutRuntimeEvent[];
|
||||
status: AgentTryoutRoundStatus;
|
||||
updatedAt: number;
|
||||
@@ -45,13 +49,21 @@ interface AgentTryoutRawSessionRecord {
|
||||
version: number;
|
||||
}
|
||||
|
||||
const STORAGE_VERSION = 3;
|
||||
const STORAGE_VERSION = 4;
|
||||
const MAX_ROUNDS = 50;
|
||||
const MAX_VARIANTS = 10;
|
||||
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
|
||||
const PERSIST_DEBOUNCE_MS = 500;
|
||||
const DRAFT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
const memorySessions = new Map<string, AgentTryoutRawRound[]>();
|
||||
|
||||
function shouldRetainRound(round: AgentTryoutRawRound) {
|
||||
return (
|
||||
round.status === 'running' ||
|
||||
Date.now() - round.updatedAt < DRAFT_RETENTION_MS
|
||||
);
|
||||
}
|
||||
|
||||
function createRoundId() {
|
||||
return `round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
@@ -79,6 +91,8 @@ function createVariant(variantIndex: number): AgentTryoutRawVariant {
|
||||
const now = Date.now();
|
||||
return {
|
||||
createdAt: now,
|
||||
inputAccepted: false,
|
||||
lastCursor: 0,
|
||||
runtimeEvents: [],
|
||||
status: 'running',
|
||||
updatedAt: now,
|
||||
@@ -106,6 +120,16 @@ function normalizeVariant(value: any, index: number): AgentTryoutRawVariant {
|
||||
: [];
|
||||
return {
|
||||
createdAt: Number(value.createdAt || Date.now()),
|
||||
inputAccepted: Boolean(
|
||||
value.inputAccepted ||
|
||||
runtimeEvents.some(
|
||||
(event: AgentTryoutRuntimeEvent) =>
|
||||
event.type === EventType.CUSTOM &&
|
||||
event.name === easyFlowAguiCustomEvent.inputAccepted,
|
||||
),
|
||||
),
|
||||
lastCursor: Math.max(0, Number(value.lastCursor || 0)),
|
||||
runId: typeof value.runId === 'string' ? value.runId : undefined,
|
||||
runtimeEvents,
|
||||
status:
|
||||
value.status === 'completed' || value.status === 'error'
|
||||
@@ -163,7 +187,9 @@ function restoreSession(mode: string, sessionId: string) {
|
||||
const key = storageKey(mode, sessionId);
|
||||
const memoryRecords = memorySessions.get(key);
|
||||
if (memoryRecords) {
|
||||
return memoryRecords.map((item) => clone(item));
|
||||
const retained = memoryRecords.filter((round) => shouldRetainRound(round));
|
||||
memorySessions.set(key, retained);
|
||||
return retained.map((item) => clone(item));
|
||||
}
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) return [];
|
||||
@@ -178,6 +204,7 @@ function restoreSession(mode: string, sessionId: string) {
|
||||
? parsed.rounds
|
||||
.map((item) => normalizeRound(item))
|
||||
.filter((item): item is AgentTryoutRawRound => item !== undefined)
|
||||
.filter((round) => shouldRetainRound(round))
|
||||
: [];
|
||||
memorySessions.set(key, clone(rounds));
|
||||
return rounds;
|
||||
@@ -196,7 +223,9 @@ function persistSession(
|
||||
// 同一份 JSON 同时用于解除 Proxy、内存快照和 sessionStorage,
|
||||
// 避免对完整草稿会话重复做全量序列化。
|
||||
const serialized = JSON.stringify({
|
||||
rounds: rounds.slice(-MAX_ROUNDS),
|
||||
rounds: rounds
|
||||
.filter((round) => shouldRetainRound(round))
|
||||
.slice(-MAX_ROUNDS),
|
||||
sessionId,
|
||||
version: STORAGE_VERSION,
|
||||
} satisfies AgentTryoutRawSessionRecord);
|
||||
@@ -401,11 +430,76 @@ export function useAgentTryoutRawRounds(options: {
|
||||
return round ? selectedVariant(round) : undefined;
|
||||
}
|
||||
|
||||
function bindRun(roundId: string, runId: string) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant) return;
|
||||
variant.runId = runId;
|
||||
variant.lastCursor = 0;
|
||||
variant.status = 'running';
|
||||
round.status = 'running';
|
||||
variant.updatedAt = Date.now();
|
||||
round.updatedAt = variant.updatedAt;
|
||||
persistNow();
|
||||
}
|
||||
|
||||
function recoverableRun() {
|
||||
const round = [...sortedRounds(rounds)].reverse().find((candidate) => {
|
||||
const variant = selectedVariant(candidate);
|
||||
return candidate.status === 'running' && Boolean(variant?.runId);
|
||||
});
|
||||
const variant = round && selectedVariant(round);
|
||||
return round && variant?.runId
|
||||
? {
|
||||
inputAccepted: variant.inputAccepted,
|
||||
lastCursor: variant.lastCursor,
|
||||
roundId: round.roundId,
|
||||
runId: variant.runId,
|
||||
startedAt: variant.createdAt,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isInputAccepted(roundId: string) {
|
||||
const round = rounds.get(roundId);
|
||||
return Boolean(round && selectedVariant(round)?.inputAccepted);
|
||||
}
|
||||
|
||||
function updateCursor(roundId: string, cursor: number) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant || !Number.isSafeInteger(cursor) || cursor <= 0) {
|
||||
return;
|
||||
}
|
||||
variant.lastCursor = Math.max(variant.lastCursor, cursor);
|
||||
variant.updatedAt = Date.now();
|
||||
round.updatedAt = variant.updatedAt;
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
function resetRuntimeEvents(roundId: string) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant) return;
|
||||
variant.runtimeEvents = [];
|
||||
variant.lastCursor = 0;
|
||||
variant.status = 'running';
|
||||
round.status = 'running';
|
||||
liveProjectionStates.delete(`${roundId}:${variant.variantIndex}`);
|
||||
persistNow();
|
||||
}
|
||||
|
||||
function recordEvent(roundId: string, event: AgentTryoutRuntimeEvent) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant) return undefined;
|
||||
appendRuntimeEvent(variant, event);
|
||||
const inputAcceptedEvent =
|
||||
event.type === EventType.CUSTOM &&
|
||||
event.name === easyFlowAguiCustomEvent.inputAccepted;
|
||||
if (inputAcceptedEvent) {
|
||||
variant.inputAccepted = true;
|
||||
}
|
||||
if (event.type === EventType.RUN_FINISHED) {
|
||||
variant.status = 'completed';
|
||||
round.status = 'completed';
|
||||
@@ -417,7 +511,8 @@ export function useAgentTryoutRawRounds(options: {
|
||||
round.updatedAt = variant.updatedAt;
|
||||
if (
|
||||
event.type === EventType.RUN_FINISHED ||
|
||||
event.type === EventType.RUN_ERROR
|
||||
event.type === EventType.RUN_ERROR ||
|
||||
inputAcceptedEvent
|
||||
) {
|
||||
persistNow();
|
||||
} else {
|
||||
@@ -545,6 +640,7 @@ export function useAgentTryoutRawRounds(options: {
|
||||
}
|
||||
|
||||
return {
|
||||
bindRun,
|
||||
buildTimelineItems,
|
||||
canSwitch,
|
||||
clear,
|
||||
@@ -554,10 +650,14 @@ export function useAgentTryoutRawRounds(options: {
|
||||
currentVariant,
|
||||
failRound,
|
||||
getPrompt,
|
||||
isInputAccepted,
|
||||
projectEvent,
|
||||
recoverableRun,
|
||||
recordEvent,
|
||||
resetRuntimeEvents,
|
||||
regenerateRound,
|
||||
selectVariant,
|
||||
updateCursor,
|
||||
flush: persistNow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { EasyFlowAguiRunOptions } from '../../shared/agent-agui/client';
|
||||
import type {
|
||||
EasyFlowAguiResumeOptions,
|
||||
EasyFlowAguiRunOptions,
|
||||
} from '../../shared/agent-agui/client';
|
||||
import type { AgentInfo } from '../types';
|
||||
|
||||
import { EventType } from '@ag-ui/client';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events';
|
||||
import { useAgentTryoutStream } from './useAgentTryoutStream';
|
||||
|
||||
const aguiMocks = vi.hoisted(() => ({
|
||||
abort: vi.fn(),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
detach: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
run: vi.fn(),
|
||||
runs: [] as EasyFlowAguiRunOptions[],
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/agent-agui/client', () => ({
|
||||
createAguiRunId: () => 'run-test',
|
||||
EasyFlowAguiHttpError: class extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
EasyFlowAguiProjectionError: class extends Error {
|
||||
constructor(public readonly projectionCause: unknown) {
|
||||
super('Agent 事件投影失败');
|
||||
}
|
||||
},
|
||||
EasyFlowAguiClient: class {
|
||||
abort = aguiMocks.abort;
|
||||
cancel = aguiMocks.cancel;
|
||||
detach = aguiMocks.detach;
|
||||
resume = aguiMocks.resume;
|
||||
|
||||
run(options: EasyFlowAguiRunOptions) {
|
||||
aguiMocks.runs.push(options);
|
||||
return new Promise(() => {});
|
||||
return aguiMocks.run(options);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -32,10 +57,24 @@ describe('useAgentTryoutStream', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
aguiMocks.runs.length = 0;
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
aguiMocks.cancel.mockResolvedValue(undefined);
|
||||
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
|
||||
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
|
||||
});
|
||||
|
||||
it('ignores late events from a stopped draft run after resending', async () => {
|
||||
aguiMocks.resume.mockImplementationOnce(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
options.onEvent({
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户已停止生成',
|
||||
runId: 'run-test',
|
||||
threadId: 'draft-race',
|
||||
type: EventType.RUN_ERROR,
|
||||
});
|
||||
},
|
||||
);
|
||||
const stream = useAgentTryoutStream();
|
||||
const payload = {
|
||||
agent: { id: 'agent-1', name: 'Agent' } as AgentInfo,
|
||||
@@ -48,7 +87,7 @@ describe('useAgentTryoutStream', () => {
|
||||
|
||||
void stream.sendDraft(payload);
|
||||
await Promise.resolve();
|
||||
stream.stop();
|
||||
await stream.stop();
|
||||
void stream.sendDraft({ ...payload, prompt: '新问题' });
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -177,6 +216,227 @@ describe('useAgentTryoutStream', () => {
|
||||
sortNo: 3,
|
||||
},
|
||||
]);
|
||||
stream.stop();
|
||||
aguiMocks.resume.mockImplementationOnce(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
options.onEvent({
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户已停止生成',
|
||||
runId: 'run-test',
|
||||
threadId: 'draft-snapshot',
|
||||
type: EventType.RUN_ERROR,
|
||||
});
|
||||
},
|
||||
);
|
||||
await stream.stop();
|
||||
});
|
||||
|
||||
it('刷新跨过输入确认事件后会幂等清理恢复的草稿', async () => {
|
||||
const payload = {
|
||||
agent: { id: 'agent-accepted', name: 'Agent' } as AgentInfo,
|
||||
knowledgeBindings: [],
|
||||
prompt: '带附件的问题',
|
||||
sessionId: 'draft-accepted-restore',
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
};
|
||||
const firstStream = useAgentTryoutStream();
|
||||
void firstStream.sendDraft(payload);
|
||||
await Promise.resolve();
|
||||
aguiMocks.runs[0]?.onEvent({
|
||||
name: easyFlowAguiCustomEvent.inputAccepted,
|
||||
type: EventType.CUSTOM,
|
||||
value: {},
|
||||
});
|
||||
aguiMocks.runs[0]?.onCursor?.(4);
|
||||
firstStream.dispose();
|
||||
|
||||
const restoredAccepted = vi.fn();
|
||||
const restoredStream = useAgentTryoutStream();
|
||||
restoredStream.syncDraftContext(
|
||||
payload,
|
||||
true,
|
||||
payload.sessionId,
|
||||
restoredAccepted,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(restoredAccepted).toHaveBeenCalledTimes(1);
|
||||
expect(aguiMocks.resume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ afterCursor: 4, runId: 'run-test' }),
|
||||
);
|
||||
aguiMocks.resume.mockImplementationOnce(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
options.onEvent({
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户已停止生成',
|
||||
runId: 'run-test',
|
||||
threadId: 'draft-accepted-restore',
|
||||
type: EventType.RUN_ERROR,
|
||||
});
|
||||
},
|
||||
);
|
||||
await restoredStream.stop();
|
||||
});
|
||||
|
||||
it('草稿流短暂断网后从最后游标恢复且不伪造本地失败', async () => {
|
||||
vi.useFakeTimers();
|
||||
aguiMocks.run.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
aguiMocks.resume
|
||||
.mockRejectedValueOnce(new TypeError('Network unavailable'))
|
||||
.mockRejectedValueOnce(new TypeError('Network unavailable'))
|
||||
.mockImplementationOnce(async (options: EasyFlowAguiResumeOptions) => {
|
||||
await options.onEvent({
|
||||
runId: 'run-test',
|
||||
threadId: 'draft-network-recovery',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
});
|
||||
const stream = useAgentTryoutStream();
|
||||
const runPromise = stream.sendDraft({
|
||||
agent: { id: 'agent-network', name: 'Agent' } as AgentInfo,
|
||||
knowledgeBindings: [],
|
||||
prompt: '网络恢复',
|
||||
sessionId: 'draft-network-recovery',
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await runPromise;
|
||||
|
||||
expect(aguiMocks.resume).toHaveBeenCalledTimes(3);
|
||||
expect(stream.loading.value).toBe(false);
|
||||
expect(JSON.stringify(stream.timelineItems.value)).not.toContain(
|
||||
'试运行失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('停止请求失败时不会解引用已清空运行并会重连服务端运行', async () => {
|
||||
aguiMocks.cancel.mockRejectedValueOnce(new Error('停止接口不可用'));
|
||||
aguiMocks.resume.mockImplementation(() => new Promise(() => {}));
|
||||
const stream = useAgentTryoutStream();
|
||||
void stream.sendDraft({
|
||||
agent: { id: 'agent-stop-failed', name: 'Agent' } as AgentInfo,
|
||||
knowledgeBindings: [],
|
||||
prompt: '停止失败恢复',
|
||||
sessionId: 'draft-stop-failed',
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
await expect(stream.stop()).rejects.toThrow('停止接口不可用');
|
||||
|
||||
expect(aguiMocks.detach).toHaveBeenCalled();
|
||||
expect(aguiMocks.resume).toHaveBeenCalledTimes(1);
|
||||
expect(stream.loading.value).toBe(true);
|
||||
expect(JSON.stringify(stream.timelineItems.value)).toContain(
|
||||
'停止失败恢复',
|
||||
);
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
it('运行中拒绝清理会话且不取消服务端运行', async () => {
|
||||
const stream = useAgentTryoutStream();
|
||||
void stream.sendDraft({
|
||||
agent: { id: 'agent-clear-failed', name: 'Agent' } as AgentInfo,
|
||||
knowledgeBindings: [],
|
||||
prompt: '应保留的草稿',
|
||||
sessionId: 'draft-clear-failed',
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
await expect(stream.clearDraftSession()).rejects.toThrow(
|
||||
'试运行进行中,暂时无法清理会话',
|
||||
);
|
||||
|
||||
expect(aguiMocks.cancel).not.toHaveBeenCalled();
|
||||
expect(aguiMocks.resume).not.toHaveBeenCalled();
|
||||
expect(stream.loading.value).toBe(true);
|
||||
expect(JSON.stringify(stream.timelineItems.value)).toContain(
|
||||
'应保留的草稿',
|
||||
);
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
it('取消响应晚于自然完成时保留权威完成态', async () => {
|
||||
let resolveCancel: ((value: undefined) => void) | undefined;
|
||||
let resolveRun: (() => void) | undefined;
|
||||
aguiMocks.cancel.mockImplementation(
|
||||
() =>
|
||||
new Promise<undefined>((resolve) => {
|
||||
resolveCancel = resolve;
|
||||
}),
|
||||
);
|
||||
aguiMocks.run.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
}),
|
||||
);
|
||||
const stream = useAgentTryoutStream();
|
||||
void stream.sendDraft({
|
||||
agent: { id: 'agent-stop-race', name: 'Agent' } as AgentInfo,
|
||||
knowledgeBindings: [],
|
||||
prompt: '自然完成优先',
|
||||
sessionId: 'draft-stop-terminal-race',
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
const stopPromise = stream.stop();
|
||||
aguiMocks.runs[0]?.onEvent({
|
||||
runId: 'run-test',
|
||||
threadId: 'draft-stop-terminal-race',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
aguiMocks.runs[0]?.onCursor?.(1);
|
||||
resolveRun?.();
|
||||
resolveCancel?.(undefined);
|
||||
await stopPromise;
|
||||
|
||||
expect(stream.loading.value).toBe(false);
|
||||
expect(JSON.stringify(stream.timelineItems.value)).toContain(
|
||||
'"turnSucceeded":true',
|
||||
);
|
||||
expect(JSON.stringify(stream.timelineItems.value)).not.toContain(
|
||||
'"turnSucceeded":false',
|
||||
);
|
||||
});
|
||||
|
||||
it('取消成功后重放权威终态且保留尚未投影的自然完成', async () => {
|
||||
aguiMocks.resume.mockImplementationOnce(
|
||||
async (options: EasyFlowAguiResumeOptions) => {
|
||||
options.onEvent({
|
||||
runId: 'run-test',
|
||||
threadId: 'draft-stop-authoritative',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
options.onCursor?.(9);
|
||||
},
|
||||
);
|
||||
const stream = useAgentTryoutStream();
|
||||
void stream.sendDraft({
|
||||
agent: { id: 'agent-stop-authoritative', name: 'Agent' } as AgentInfo,
|
||||
knowledgeBindings: [],
|
||||
prompt: '取消时服务端已经自然完成',
|
||||
sessionId: 'draft-stop-authoritative',
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
await stream.stop();
|
||||
|
||||
expect(stream.loading.value).toBe(false);
|
||||
expect(JSON.stringify(stream.timelineItems.value)).toContain(
|
||||
'"turnSucceeded":true',
|
||||
);
|
||||
expect(JSON.stringify(stream.timelineItems.value)).not.toContain(
|
||||
'"turnSucceeded":false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,14 @@ import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
import { EventType } from '@ag-ui/client';
|
||||
|
||||
import { EasyFlowAguiClient } from '../../shared/agent-agui/client';
|
||||
import {
|
||||
createAguiRunId,
|
||||
EasyFlowAguiClient,
|
||||
} from '../../shared/agent-agui/client';
|
||||
import {
|
||||
isRetryableAguiTransportError,
|
||||
resumeAguiRunUntilTerminal,
|
||||
} from '../../shared/agent-agui/reconnect';
|
||||
import { clearAgentDraftSession } from '../api';
|
||||
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
|
||||
|
||||
@@ -96,8 +103,13 @@ interface DraftRuntimeContext {
|
||||
}
|
||||
|
||||
interface ActiveDraftRun {
|
||||
cancelling: boolean;
|
||||
inputAcceptedNotified: boolean;
|
||||
lastCursor: number;
|
||||
roundId: string;
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
startedAt: number;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
@@ -108,6 +120,7 @@ export function useAgentTryoutStream() {
|
||||
let activeRoundId = '';
|
||||
let activeSessionId = '';
|
||||
let activeRun: ActiveDraftRun | undefined;
|
||||
let resumeOnAccepted: (() => Promise<void> | void) | undefined;
|
||||
const aguiClient = new EasyFlowAguiClient();
|
||||
|
||||
function errorMessageOf(error: unknown) {
|
||||
@@ -141,7 +154,9 @@ export function useAgentTryoutStream() {
|
||||
payload: DraftRuntimeContext,
|
||||
restore = false,
|
||||
requestedSessionId?: string,
|
||||
onAccepted?: () => Promise<void> | void,
|
||||
) {
|
||||
resumeOnAccepted = onAccepted;
|
||||
const sessionId =
|
||||
requestedSessionId || resolveDraftSessionId(payload.agent);
|
||||
const sessionChanged = activeSessionId !== sessionId;
|
||||
@@ -156,6 +171,99 @@ export function useAgentTryoutStream() {
|
||||
}
|
||||
if (restore && sessionChanged && !loading.value) {
|
||||
rebuildTimeline();
|
||||
void resumeDraftRun();
|
||||
}
|
||||
}
|
||||
|
||||
function notifyInputAccepted(run: ActiveDraftRun) {
|
||||
if (run.inputAcceptedNotified || !resumeOnAccepted) {
|
||||
return;
|
||||
}
|
||||
run.inputAcceptedNotified = true;
|
||||
void resumeOnAccepted();
|
||||
}
|
||||
|
||||
function projectRunEvent(
|
||||
run: ActiveDraftRun,
|
||||
rounds: NonNullable<typeof rawRounds>,
|
||||
event: AguiEvent,
|
||||
) {
|
||||
if (activeRun !== run) return;
|
||||
const runtimeEvent = rounds.recordEvent(run.roundId, event);
|
||||
if (!runtimeEvent) return;
|
||||
rounds.projectEvent(timelineItems.value, run.roundId, runtimeEvent, () =>
|
||||
notifyInputAccepted(run),
|
||||
);
|
||||
}
|
||||
|
||||
function resumeActiveRun(
|
||||
run: ActiveDraftRun,
|
||||
rounds: NonNullable<typeof rawRounds>,
|
||||
) {
|
||||
return resumeAguiRunUntilTerminal({
|
||||
client: aguiClient,
|
||||
initialCursor: run.lastCursor,
|
||||
onCursor: (cursor) => {
|
||||
run.lastCursor = Math.max(run.lastCursor, cursor);
|
||||
rounds.updateCursor(run.roundId, run.lastCursor);
|
||||
},
|
||||
onEvent: (event) => projectRunEvent(run, rounds, event),
|
||||
runId: run.runId,
|
||||
shouldContinue: () => activeRun === run && !run.stopped,
|
||||
startedAt: run.startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
async function resumeDraftRun() {
|
||||
const rounds = rawRounds;
|
||||
const recovery = rounds?.recoverableRun();
|
||||
if (!rounds || !recovery || activeRun) {
|
||||
return;
|
||||
}
|
||||
const run: ActiveDraftRun = {
|
||||
cancelling: false,
|
||||
inputAcceptedNotified: false,
|
||||
lastCursor: recovery.lastCursor,
|
||||
roundId: recovery.roundId,
|
||||
runId: recovery.runId,
|
||||
sessionId: activeSessionId,
|
||||
startedAt: recovery.startedAt,
|
||||
stopped: false,
|
||||
};
|
||||
activeRun = run;
|
||||
activeRoundId = run.roundId;
|
||||
if (run.lastCursor === 0) {
|
||||
rounds.resetRuntimeEvents(run.roundId);
|
||||
}
|
||||
rebuildTimeline();
|
||||
loading.value = true;
|
||||
try {
|
||||
if (recovery.inputAccepted) {
|
||||
notifyInputAccepted(run);
|
||||
}
|
||||
const terminalReceived = await resumeActiveRun(run, rounds);
|
||||
if (terminalReceived && activeRun === run && !run.stopped) {
|
||||
finishAssistant();
|
||||
markRoundCompleted(run.roundId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (activeRun === run && !run.stopped) {
|
||||
const runError = {
|
||||
message: errorMessageOf(error) || '试运行恢复失败,请稍后再试',
|
||||
runId: run.runId,
|
||||
threadId: run.sessionId,
|
||||
type: EventType.RUN_ERROR,
|
||||
} as AguiEvent;
|
||||
rounds.recordEvent(run.roundId, runError);
|
||||
rounds.projectEvent(timelineItems.value, run.roundId, runError);
|
||||
finishAssistant();
|
||||
rounds.flush();
|
||||
}
|
||||
} finally {
|
||||
if (activeRun === run && !run.cancelling) {
|
||||
activeRun = undefined;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,66 +311,74 @@ export function useAgentTryoutStream() {
|
||||
if (!rawRounds) {
|
||||
return;
|
||||
}
|
||||
activeRoundId = rawRounds.createRound(
|
||||
const rounds = rawRounds;
|
||||
activeRoundId = rounds.createRound(
|
||||
payload.prompt,
|
||||
payload.images,
|
||||
payload.documents,
|
||||
);
|
||||
const runId = createAguiRunId();
|
||||
rounds.bindRun(activeRoundId, runId);
|
||||
const startedAt = Date.now();
|
||||
const run: ActiveDraftRun = {
|
||||
cancelling: false,
|
||||
inputAcceptedNotified: false,
|
||||
lastCursor: 0,
|
||||
roundId: activeRoundId,
|
||||
runId,
|
||||
sessionId: activeSessionId,
|
||||
startedAt,
|
||||
stopped: false,
|
||||
};
|
||||
resumeOnAccepted = payload.onAccepted;
|
||||
activeRun = run;
|
||||
rebuildTimeline();
|
||||
loading.value = true;
|
||||
let accepted = false;
|
||||
try {
|
||||
await aguiClient.run({
|
||||
forwardedProps: {
|
||||
easyflow: {
|
||||
draft: {
|
||||
agent: draftAgentTransport(payload.agent),
|
||||
knowledgeBindings: payload.knowledgeBindings.map((binding) =>
|
||||
draftKnowledgeBindingTransport(binding),
|
||||
),
|
||||
skillBindings: payload.skillBindings.map((binding) =>
|
||||
draftSkillBindingTransport(binding),
|
||||
),
|
||||
toolBindings: payload.toolBindings.map((binding) =>
|
||||
draftToolBindingTransport(binding),
|
||||
),
|
||||
},
|
||||
input: {
|
||||
documentUploadIds: payload.documentUploadIds,
|
||||
imageUploadIds: payload.imageUploadIds,
|
||||
let terminalReceived = true;
|
||||
try {
|
||||
await aguiClient.run({
|
||||
forwardedProps: {
|
||||
easyflow: {
|
||||
draft: {
|
||||
agent: draftAgentTransport(payload.agent),
|
||||
knowledgeBindings: payload.knowledgeBindings.map((binding) =>
|
||||
draftKnowledgeBindingTransport(binding),
|
||||
),
|
||||
skillBindings: payload.skillBindings.map((binding) =>
|
||||
draftSkillBindingTransport(binding),
|
||||
),
|
||||
toolBindings: payload.toolBindings.map((binding) =>
|
||||
draftToolBindingTransport(binding),
|
||||
),
|
||||
},
|
||||
input: {
|
||||
documentUploadIds: payload.documentUploadIds,
|
||||
imageUploadIds: payload.imageUploadIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onEvent(event) {
|
||||
if (activeRun !== run) return;
|
||||
const runtimeEvent = rawRounds?.recordEvent(run.roundId, event);
|
||||
if (!runtimeEvent) return;
|
||||
rawRounds?.projectEvent(
|
||||
timelineItems.value,
|
||||
run.roundId,
|
||||
runtimeEvent,
|
||||
() => {
|
||||
if (accepted) return;
|
||||
accepted = true;
|
||||
void payload.onAccepted?.();
|
||||
},
|
||||
);
|
||||
},
|
||||
threadId: run.sessionId,
|
||||
url: '/api/v1/agent/agui/run/draft',
|
||||
userMessage: {
|
||||
content: payload.prompt,
|
||||
id: `user-${run.roundId}`,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
if (activeRun === run && !run.stopped) {
|
||||
onCursor(cursor) {
|
||||
run.lastCursor = Math.max(run.lastCursor, cursor);
|
||||
rounds.updateCursor(run.roundId, run.lastCursor);
|
||||
},
|
||||
onEvent: (event) => projectRunEvent(run, rounds, event),
|
||||
runId,
|
||||
threadId: run.sessionId,
|
||||
url: '/api/v1/agent/agui/run/draft',
|
||||
userMessage: {
|
||||
content: payload.prompt,
|
||||
id: `user-${run.roundId}`,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRetryableAguiTransportError(error, run.startedAt)) {
|
||||
throw error;
|
||||
}
|
||||
terminalReceived = await resumeActiveRun(run, rounds);
|
||||
}
|
||||
if (terminalReceived && activeRun === run && !run.stopped) {
|
||||
finishAssistant();
|
||||
markRoundCompleted(run.roundId);
|
||||
}
|
||||
@@ -270,17 +386,17 @@ export function useAgentTryoutStream() {
|
||||
if (activeRun === run && !run.stopped) {
|
||||
const runError = {
|
||||
message: errorMessageOf(error) || '试运行失败,请稍后再试',
|
||||
runId: run.roundId,
|
||||
runId: run.runId,
|
||||
threadId: run.sessionId,
|
||||
type: EventType.RUN_ERROR,
|
||||
} as AguiEvent;
|
||||
rawRounds?.recordEvent(run.roundId, runError);
|
||||
rawRounds?.projectEvent(timelineItems.value, run.roundId, runError);
|
||||
rounds.recordEvent(run.roundId, runError);
|
||||
rounds.projectEvent(timelineItems.value, run.roundId, runError);
|
||||
finishAssistant();
|
||||
rawRounds?.flush();
|
||||
rounds.flush();
|
||||
}
|
||||
} finally {
|
||||
if (activeRun === run) {
|
||||
if (activeRun === run && !run.cancelling) {
|
||||
activeRun = undefined;
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -325,10 +441,7 @@ export function useAgentTryoutStream() {
|
||||
|
||||
async function clearDraftSession() {
|
||||
if (loading.value) {
|
||||
if (activeRun) activeRun.stopped = true;
|
||||
activeRun = undefined;
|
||||
aguiClient.abort();
|
||||
loading.value = false;
|
||||
throw new Error('试运行进行中,暂时无法清理会话');
|
||||
}
|
||||
const sessionId = activeSessionId;
|
||||
rawRounds?.clear();
|
||||
@@ -339,24 +452,59 @@ export function useAgentTryoutStream() {
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
function recoverAfterCancelFailure(run: ActiveDraftRun) {
|
||||
run.cancelling = false;
|
||||
run.stopped = false;
|
||||
aguiClient.detach();
|
||||
if (activeRun === run) {
|
||||
activeRun = undefined;
|
||||
}
|
||||
loading.value = false;
|
||||
rebuildTimeline();
|
||||
void resumeDraftRun();
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!loading.value) {
|
||||
return;
|
||||
}
|
||||
const stoppedRoundId = activeRun?.roundId || activeRoundId;
|
||||
if (activeRun) activeRun.stopped = true;
|
||||
const run = activeRun;
|
||||
if (run) {
|
||||
if (run.cancelling) {
|
||||
return;
|
||||
}
|
||||
run.cancelling = true;
|
||||
run.stopped = true;
|
||||
try {
|
||||
await aguiClient.cancel(run.runId);
|
||||
} catch (error) {
|
||||
recoverAfterCancelFailure(run);
|
||||
throw error;
|
||||
}
|
||||
run.cancelling = false;
|
||||
}
|
||||
activeRun = undefined;
|
||||
aguiClient.abort();
|
||||
if (run && rawRounds?.currentVariant(run.roundId)?.status !== 'running') {
|
||||
loading.value = false;
|
||||
rebuildTimeline();
|
||||
return;
|
||||
}
|
||||
if (run) {
|
||||
// 取消接口成功后重新订阅服务端日志,以 RUN_FINISHED/RUN_ERROR 决定最终展示状态。
|
||||
loading.value = false;
|
||||
rebuildTimeline();
|
||||
await resumeDraftRun();
|
||||
return;
|
||||
}
|
||||
finishStoppedRun(stoppedRoundId);
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (loading.value) {
|
||||
const stoppedRoundId = activeRun?.roundId || activeRoundId;
|
||||
if (activeRun) activeRun.stopped = true;
|
||||
activeRun = undefined;
|
||||
aguiClient.abort();
|
||||
finishStoppedRun(stoppedRoundId);
|
||||
aguiClient.detach();
|
||||
rawRounds?.flush();
|
||||
return;
|
||||
}
|
||||
rawRounds?.flush();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { EventType } from '@ag-ui/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EasyFlowAguiClient } from './client';
|
||||
import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client';
|
||||
import { isRetryableAguiTransportError } from './reconnect';
|
||||
|
||||
vi.mock('#/api/request', () => ({
|
||||
createEventStreamHeaders: () => ({ 'easyflow-token': 'test-token' }),
|
||||
@@ -10,7 +11,12 @@ vi.mock('#/api/request', () => ({
|
||||
|
||||
function sse(events: unknown[]) {
|
||||
return new Response(
|
||||
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''),
|
||||
events
|
||||
.map(
|
||||
(event, index) =>
|
||||
`id: ${index + 1}\ndata: ${JSON.stringify(event)}\n\n`,
|
||||
)
|
||||
.join(''),
|
||||
{ headers: { 'Content-Type': 'text/event-stream' }, status: 200 },
|
||||
);
|
||||
}
|
||||
@@ -36,15 +42,22 @@ describe('easyFlowAguiClient', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const received: string[] = [];
|
||||
const cursors: number[] = [];
|
||||
await new EasyFlowAguiClient().run({
|
||||
forwardedProps: { easyflow: { input: { imageUploadIds: ['image-1'] } } },
|
||||
onEvent: (event) => received.push(event.type),
|
||||
onEvent: (event) => {
|
||||
received.push(event.type);
|
||||
},
|
||||
onCursor: (cursor) => {
|
||||
cursors.push(cursor);
|
||||
},
|
||||
threadId: '101',
|
||||
url: '/api/v1/agent/1/agui/run',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
});
|
||||
|
||||
expect(received).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]);
|
||||
expect(cursors).toEqual([1, 2]);
|
||||
expect(requestBody).toEqual(
|
||||
expect.objectContaining({
|
||||
context: [],
|
||||
@@ -106,6 +119,32 @@ describe('easyFlowAguiClient', () => {
|
||||
).rejects.toThrow('缺少终态');
|
||||
});
|
||||
|
||||
it('marks deterministic event projection failures as non-retryable', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
sse([{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }]),
|
||||
),
|
||||
);
|
||||
|
||||
const run = new EasyFlowAguiClient().run({
|
||||
onEvent: () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
threadId: '101',
|
||||
url: '/api/v1/agent/1/agui/run',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
});
|
||||
|
||||
await expect(run).rejects.toBeInstanceOf(EasyFlowAguiProjectionError);
|
||||
expect(
|
||||
isRetryableAguiTransportError(
|
||||
new EasyFlowAguiProjectionError(new Error('projection failed')),
|
||||
Date.now(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a standard cancelled terminal as an accepted stop', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
@@ -132,4 +171,132 @@ describe('easyFlowAguiClient', () => {
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not hide projection errors raised by a cancelled terminal', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
sse([
|
||||
{
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户停止',
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.RUN_ERROR,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new EasyFlowAguiClient().run({
|
||||
onEvent: () => {
|
||||
throw new Error('cancel projection failed');
|
||||
},
|
||||
threadId: '101',
|
||||
url: '/api/v1/agent/1/agui/run',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(EasyFlowAguiProjectionError);
|
||||
});
|
||||
|
||||
it('replays a completed run from the server journal after refresh', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
[
|
||||
'id: 1\ndata: {"type":"RUN_STARTED","threadId":"101","runId":"run-resume"}\n\n',
|
||||
'id: 2\ndata: {"type":"TEXT_MESSAGE_START","messageId":"assistant-1","role":"assistant"}\n\n',
|
||||
'id: 3\ndata: {"type":"TEXT_MESSAGE_CONTENT","messageId":"assistant-1","delta":"刷新后恢复"}\n\n',
|
||||
'id: 4\ndata: {"type":"TEXT_MESSAGE_END","messageId":"assistant-1"}\n\n',
|
||||
'id: 5\ndata: {"type":"RUN_FINISHED","threadId":"101","runId":"run-resume"}\n\n',
|
||||
].join(''),
|
||||
{ headers: { 'Content-Type': 'text/event-stream' }, status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
const received: string[] = [];
|
||||
const cursors: number[] = [];
|
||||
|
||||
await new EasyFlowAguiClient().resume({
|
||||
onCursor: (cursor) => {
|
||||
cursors.push(cursor);
|
||||
},
|
||||
onEvent: (event) => {
|
||||
received.push(event.type);
|
||||
},
|
||||
runId: 'run-resume',
|
||||
});
|
||||
|
||||
expect(received).toEqual([
|
||||
EventType.RUN_STARTED,
|
||||
EventType.TEXT_MESSAGE_START,
|
||||
EventType.TEXT_MESSAGE_CONTENT,
|
||||
EventType.TEXT_MESSAGE_END,
|
||||
EventType.RUN_FINISHED,
|
||||
]);
|
||||
expect(cursors).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/run-resume/events?after=0'),
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the explicit cancel endpoint instead of treating detach as cancel', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ errorCode: 0 }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await new EasyFlowAguiClient().cancel('run-cancel');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/run-cancel/cancel'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the active stream attached when the cancel request fails', async () => {
|
||||
let runSignal: AbortSignal | undefined;
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (url.includes('/cancel')) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: '取消服务暂不可用' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 503,
|
||||
}),
|
||||
);
|
||||
}
|
||||
runSignal = init?.signal as AbortSignal;
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
runSignal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const client = new EasyFlowAguiClient();
|
||||
const runPromise = client.run({
|
||||
onEvent: () => undefined,
|
||||
runId: 'run-cancel-failure',
|
||||
threadId: '101',
|
||||
url: '/api/v1/agent/1/agui/run',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
});
|
||||
|
||||
await expect(client.cancel('run-cancel-failure')).rejects.toThrow(
|
||||
'取消服务暂不可用',
|
||||
);
|
||||
expect(runSignal?.aborted).toBe(false);
|
||||
|
||||
client.detach();
|
||||
expect(runSignal?.aborted).toBe(true);
|
||||
await runPromise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AguiEvent } from './client';
|
||||
|
||||
import {
|
||||
EasyFlowAguiClient,
|
||||
EasyFlowAguiHttpError,
|
||||
EasyFlowAguiProjectionError,
|
||||
} from './client';
|
||||
|
||||
const INITIAL_RECONNECT_DELAY_MS = 300;
|
||||
const MAX_RECONNECT_DELAY_MS = 5000;
|
||||
const RUN_CREATION_GRACE_MS = 30_000;
|
||||
|
||||
interface ResumeAguiRunOptions {
|
||||
client: EasyFlowAguiClient;
|
||||
initialCursor?: number;
|
||||
onCursor?: (cursor: number) => void;
|
||||
onEvent: (event: AguiEvent) => Promise<void> | void;
|
||||
runId: string;
|
||||
shouldContinue: () => boolean;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
function wait(delay: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断传输异常是否允许通过运行日志继续恢复。
|
||||
*
|
||||
* 404 仅在 POST 尚未建立运行记录的短窗口内重试;鉴权、参数和协议解析错误立即交给页面处理。
|
||||
*/
|
||||
export function isRetryableAguiTransportError(
|
||||
error: unknown,
|
||||
startedAt: number,
|
||||
) {
|
||||
if (error instanceof EasyFlowAguiProjectionError) {
|
||||
return false;
|
||||
}
|
||||
if (error instanceof EasyFlowAguiHttpError) {
|
||||
if (error.status === 404) {
|
||||
return Date.now() - startedAt < RUN_CREATION_GRACE_MS;
|
||||
}
|
||||
return (
|
||||
error.status === 408 ||
|
||||
error.status === 425 ||
|
||||
error.status === 429 ||
|
||||
error.status >= 500
|
||||
);
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
return false;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'ZodError') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 以服务端游标持续恢复 AG-UI 运行,直至收到权威终态或页面主动分离。
|
||||
*
|
||||
* 退避间隔有上限,网络故障期间保留可恢复态,不会用本地错误覆盖仍在执行的服务端运行。
|
||||
*/
|
||||
export async function resumeAguiRunUntilTerminal(
|
||||
options: ResumeAguiRunOptions,
|
||||
) {
|
||||
let cursor = Math.max(0, options.initialCursor || 0);
|
||||
let delay = INITIAL_RECONNECT_DELAY_MS;
|
||||
while (options.shouldContinue()) {
|
||||
try {
|
||||
await options.client.resume({
|
||||
afterCursor: cursor,
|
||||
onCursor(nextCursor) {
|
||||
cursor = Math.max(cursor, nextCursor);
|
||||
options.onCursor?.(cursor);
|
||||
},
|
||||
onEvent: options.onEvent,
|
||||
runId: options.runId,
|
||||
});
|
||||
return options.shouldContinue();
|
||||
} catch (error) {
|
||||
if (
|
||||
!options.shouldContinue() ||
|
||||
!isRetryableAguiTransportError(error, options.startedAt)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await wait(delay);
|
||||
delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user