feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志 - 支持正式聊天与草稿试运行刷新重连和权威终态恢复 - 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user