feat: 支持 Agent 运行刷新恢复

- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

- 支持正式聊天与草稿试运行刷新重连和权威终态恢复

- 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
2026-08-20 11:18:10 +08:00
parent 310fc1fb58
commit fa07134cf8
36 changed files with 4876 additions and 322 deletions

View File

@@ -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();