Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts
陈子默 fa07134cf8 feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

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

- 完善显式取消、owner 丢失、容量限制与故障测试
2026-08-20 11:18:10 +08:00

528 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
} from '@easyflow/common-ui';
import type { AguiEvent } from '../../shared/agent-agui/client';
import type {
AgentInfo,
AgentKnowledgeBinding,
AgentSkillBinding,
AgentToolBinding,
} from '../types';
import { ref } from 'vue';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import { EventType } from '@ag-ui/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';
function resolveDraftSessionId(agent: AgentInfo) {
return `agent-draft-${agent.id || agent.localId || 'unsaved'}`;
}
function asText(value: unknown) {
return value === null || value === undefined ? '' : String(value);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function draftAgentTransport(agent: AgentInfo) {
return {
avatar: agent.avatar,
categoryId: agent.categoryId,
description: agent.description,
executionConfigJson: agent.executionConfigJson,
generationConfigJson: agent.generationConfigJson,
id: agent.id,
memoryConfigJson: agent.memoryConfigJson,
modelConfigJson: agent.modelConfigJson,
modelId: agent.modelId,
name: agent.name,
promptConfigJson: agent.promptConfigJson,
publishStatus: agent.publishStatus,
status: agent.status,
visibilityScope: agent.visibilityScope,
};
}
function draftToolBindingTransport(binding: AgentToolBinding) {
return {
enabled: binding.enabled,
hitlConfigJson: binding.hitlConfigJson,
hitlEnabled: binding.hitlEnabled,
id: binding.id,
optionsJson: binding.optionsJson,
sortNo: binding.sortNo,
targetId: binding.targetId,
toolName: binding.toolName,
toolType: binding.toolType,
};
}
function draftKnowledgeBindingTransport(binding: AgentKnowledgeBinding) {
return {
enabled: binding.enabled,
id: binding.id,
knowledgeId: binding.knowledgeId,
optionsJson: binding.optionsJson,
retrievalMode: binding.retrievalMode,
sortNo: binding.sortNo,
};
}
function draftSkillBindingTransport(binding: AgentSkillBinding) {
return {
skillId: binding.skillId,
sortNo: binding.sortNo,
};
}
interface DraftRuntimeContext {
agent: AgentInfo;
knowledgeBindings: AgentKnowledgeBinding[];
skillBindings: AgentSkillBinding[];
toolBindings: AgentToolBinding[];
}
interface ActiveDraftRun {
cancelling: boolean;
inputAcceptedNotified: boolean;
lastCursor: number;
roundId: string;
runId: string;
sessionId: string;
startedAt: number;
stopped: boolean;
}
export function useAgentTryoutStream() {
const timelineItems = ref<ChatTimelineItemType[]>([]);
const loading = ref(false);
let rawRounds: ReturnType<typeof useAgentTryoutRawRounds> | undefined;
let activeRoundId = '';
let activeSessionId = '';
let activeRun: ActiveDraftRun | undefined;
let resumeOnAccepted: (() => Promise<void> | void) | undefined;
const aguiClient = new EasyFlowAguiClient();
function errorMessageOf(error: unknown) {
if (error instanceof Error) {
return `${error.name} ${error.message}`.trim();
}
if (typeof error === 'string') {
return error;
}
const value = asRecord(error);
const nested = asRecord(value.cause);
return [value.name, value.message, value.error, nested.name, nested.message]
.map((item) => asText(item).trim())
.filter(Boolean)
.join(' ');
}
function finishStoppedRun(roundId: string) {
if (roundId) {
rawRounds?.failRound(roundId);
rebuildTimeline();
}
loading.value = false;
}
function rebuildTimeline() {
timelineItems.value = rawRounds?.buildTimelineItems() || [];
}
function syncDraftContext(
payload: DraftRuntimeContext,
restore = false,
requestedSessionId?: string,
onAccepted?: () => Promise<void> | void,
) {
resumeOnAccepted = onAccepted;
const sessionId =
requestedSessionId || resolveDraftSessionId(payload.agent);
const sessionChanged = activeSessionId !== sessionId;
activeSessionId = sessionId;
if (!rawRounds || sessionChanged) {
rawRounds?.flush();
rawRounds = useAgentTryoutRawRounds({
mode: 'draft',
sessionId,
});
activeRoundId = '';
}
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;
}
}
}
function markRoundCompleted(roundId: string) {
if (!roundId) {
return;
}
rawRounds?.completeRound(roundId);
rebuildTimeline();
}
function finishAssistant() {
ChatTimelineBuilder.finalize(timelineItems.value);
}
function markToolApproving(payload: {
approvalId?: string;
toolCallId?: string;
}) {
ChatTimelineBuilder.markToolApproving(timelineItems.value, payload);
}
function markToolRejected(payload: {
approvalId?: string;
reason?: string;
toolCallId?: string;
}) {
ChatTimelineBuilder.markToolRejected(timelineItems.value, payload);
}
async function runDraft(payload: {
agent: AgentInfo;
documents?: ChatDocumentAttachment[];
documentUploadIds?: string[];
images?: ChatImageAttachment[];
imageUploadIds?: string[];
knowledgeBindings: AgentKnowledgeBinding[];
onAccepted?: () => Promise<void> | void;
prompt: string;
sessionId?: string;
skillBindings: AgentSkillBinding[];
toolBindings: AgentToolBinding[];
}) {
syncDraftContext(payload, false, payload.sessionId);
if (!rawRounds) {
return;
}
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;
try {
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,
},
},
},
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);
}
} 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;
}
}
}
async function sendDraft(payload: {
agent: AgentInfo;
documents?: ChatDocumentAttachment[];
documentUploadIds?: string[];
images?: ChatImageAttachment[];
imageUploadIds?: string[];
knowledgeBindings: AgentKnowledgeBinding[];
onAccepted?: () => Promise<void> | void;
prompt: string;
sessionId?: string;
skillBindings: AgentSkillBinding[];
toolBindings: AgentToolBinding[];
}) {
await runDraft(payload);
}
async function regenerateDraft(item: ChatTimelineMessageItem) {
// 有状态 runtime 的历史重生成需要后端 session fork/rollback第一版先禁用入口。
void item;
}
function selectVariant(
item: ChatTimelineMessageItem,
direction: 'next' | 'previous',
) {
if (!item.roundId || !rawRounds?.canSwitch(item, direction)) {
return;
}
rawRounds.selectVariant(item.roundId, direction);
rebuildTimeline();
}
function copyMessageText(item: ChatTimelineMessageItem) {
return rawRounds?.copyText(item) || '';
}
async function clearDraftSession() {
if (loading.value) {
throw new Error('试运行进行中,暂时无法清理会话');
}
const sessionId = activeSessionId;
rawRounds?.clear();
timelineItems.value = [];
activeRoundId = '';
if (sessionId) {
await clearAgentDraftSession(sessionId);
}
}
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;
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;
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) {
activeRun = undefined;
aguiClient.detach();
rawRounds?.flush();
return;
}
rawRounds?.flush();
}
return {
loading,
clearDraftSession,
markToolApproving,
markToolRejected,
copyMessageText,
dispose,
regenerateDraft,
selectVariant,
syncDraftContext,
timelineItems,
sendDraft,
stop,
};
}