fix(canvas): Agent 会话 - 刷新后恢复后台任务结果

This commit is contained in:
Hao OUYANG
2026-07-22 22:32:16 +08:00
parent df391fb809
commit 94e68489b0
3 changed files with 265 additions and 42 deletions

View File

@@ -8,17 +8,17 @@ import { modelOptionName, normalizeModelOptionValue, resolveModelChannel, resolv
import { canvasThemes } from "@/lib/canvas-theme";
import { nanoid } from "nanoid";
import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
import { agentSessionFailureMessage, createAgentSession, queryAgentSession } from "@/services/api/task-center";
import { imageToDataUrl } from "@/services/image-storage";
import { useAssetStore } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { AgentChatComposer, AgentChatMessage, AgentModeSwitch, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendingBackendSession, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
@@ -168,6 +168,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const chatListRef = useRef<HTMLDivElement>(null);
const snapshotRef = useRef(snapshot);
const pendingToolContextRef = useRef(new Map<string, PendingOnlineToolContext>());
const cinematicSessionControllersRef = useRef(new Map<string, AbortController>());
useEffect(() => {
if (!sessions.length) return;
@@ -181,6 +182,12 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
snapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => () => {
// 收起面板或刷新页面时只停止前端查询,后台任务由下次挂载根据持久化 ID 继续接管。
cinematicSessionControllersRef.current.forEach((controller) => controller.abort());
cinematicSessionControllersRef.current.clear();
}, []);
useEffect(() => {
if (applyingExternalSessionsRef.current) {
applyingExternalSessionsRef.current = false;
@@ -195,6 +202,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const historySessions = safeSessions.filter((session) => session.messages.length > 0);
const messages = activeSession?.messages || [];
const hasMessages = messages.length > 0;
const agentBusy = isRunning || safeSessions.some((session) => session.pendingBackendSession?.status === "pending");
const activeModel = effectiveConfig.textModel || effectiveConfig.model;
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
@@ -205,7 +213,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
if (agentMode !== "online" || view !== "chat") return;
const frame = requestAnimationFrame(() => chatListRef.current?.scrollTo({ top: chatListRef.current.scrollHeight }));
return () => cancelAnimationFrame(frame);
}, [agentMode, isRunning, localActiveSessionId, messages, view]);
}, [agentBusy, agentMode, localActiveSessionId, messages, view]);
useEffect(() => {
setRemovedReferenceIds(new Set());
@@ -237,6 +245,107 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
});
};
const setPendingCinematicSession = (sessionId: string, backendSessionId: string) => {
const startedAt = new Date().toISOString();
const pending: CanvasAssistantPendingBackendSession = {
id: backendSessionId,
kind: "cinematic",
messageId: cinematicSessionMessageId(backendSessionId),
status: "pending",
startedAt,
};
updateSession(sessionId, (session) => ({
...session,
pendingBackendSession: pending,
messages: upsertAssistantMessage(session.messages, {
id: pending.messageId,
role: "assistant",
title: "影视项目生成中",
text: "后端影视 Agent 正在处理。即使页面刷新,也会在重新进入画布后继续等待结果。",
detail: { kind: "cinematic", backendSessionId, status: "pending", startedAt },
}),
updatedAt: startedAt,
}));
};
const completeCinematicSession = (sessionId: string, backendSessionId: string, ops: CanvasAgentOp[], recovered = false) => {
updateSession(sessionId, (session) => {
const pending = session.pendingBackendSession;
if (pending?.id !== backendSessionId) return session;
const completedAt = new Date().toISOString();
const summary = summarizeCanvasAgentOps(ops) || "影视项目已写回当前画布。";
return {
...session,
pendingBackendSession: undefined,
messages: upsertAssistantMessage(session.messages, {
id: pending.messageId,
role: "assistant",
title: recovered ? "影视项目已恢复并写回" : "影视项目已写回",
text: recovered ? `页面重新连接后已恢复后台结果:${summary}` : summary,
detail: { kind: "cinematic", backendSessionId, status: "completed", recovered, completedAt },
}),
updatedAt: completedAt,
};
});
};
const failCinematicSession = (sessionId: string, backendSessionId: string, error: unknown) => {
updateSession(sessionId, (session) => {
const pending = session.pendingBackendSession;
if (pending?.id !== backendSessionId) return session;
const failedAt = new Date().toISOString();
const text = error instanceof Error ? error.message : "影视项目生成失败";
return {
...session,
pendingBackendSession: undefined,
messages: upsertAssistantMessage(session.messages, {
id: pending.messageId,
role: "error",
title: "影视项目生成失败",
text,
detail: { kind: "cinematic", backendSessionId, status: "failed", failedAt },
}),
updatedAt: failedAt,
};
});
};
const runCinematicSession = async (sessionId: string, text: string, current: CanvasAgentSnapshot, config: AiConfig, onCreated?: (backendSessionId: string) => void) => {
const requestConfig = resolveModelRequestConfig(config, config.textModel || config.model);
const controller = new AbortController();
const requestKey = `creating:${nanoid()}`;
let backendSessionId = "";
cinematicSessionControllersRef.current.set(requestKey, controller);
try {
const detail = await createCinematicAgentSession(
{
projectId,
prompt: text,
canvasSnapshot: compactSnapshot(current) as unknown as Record<string, unknown>,
config: backendAgentProviderConfig(requestConfig),
},
{
signal: controller.signal,
onCreated: (created) => {
backendSessionId = created.session.id;
cinematicSessionControllersRef.current.delete(requestKey);
cinematicSessionControllersRef.current.set(backendSessionId, controller);
setPendingCinematicSession(sessionId, backendSessionId);
addOnlineLog("后端影视 Agent 会话已创建", { backendSessionId });
onCreated?.(backendSessionId);
},
},
);
return { backendSessionId: detail.session.id, ops: requireOps(JSON.parse(cinematicAgentSessionOpsJson(detail))) };
} catch (error) {
if (backendSessionId && !isAgentSessionPollingAbort(error)) failCinematicSession(sessionId, backendSessionId, error);
throw error;
} finally {
cinematicSessionControllersRef.current.delete(requestKey);
if (backendSessionId) cinematicSessionControllersRef.current.delete(backendSessionId);
}
};
const startChatSession = () => {
if (activeSession && activeSession.messages.length === 0) {
setLocalActiveSessionId(activeSession.id);
@@ -328,7 +437,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
};
const continueOnlineToolLoop = async (sessionId: string, assistantId: string, messages: ResponseInputMessage[], result: { content: string; toolCalls: ResponseToolCall[] }, step: number) => {
const toolResults = await executeOnlineToolCalls(result.toolCalls);
const toolResults = await executeOnlineToolCalls(sessionId, result.toolCalls);
addOnlineLog("工具执行结果", toolResults);
appendMessage(sessionId, {
id: nanoid(),
@@ -385,7 +494,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
return { changed, ops, ranGeneration, noopReason, before: JSON.parse(before), after: JSON.parse(snapshotSignature(next)) };
};
const executeOnlineTool = async (name: string, args: Record<string, unknown>): Promise<OnlineToolResult> => {
const executeOnlineTool = async (sessionId: string, name: string, args: Record<string, unknown>): Promise<OnlineToolResult> => {
const current = snapshotRef.current;
try {
if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
@@ -395,28 +504,36 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
return { ok: true, message: `当前选中 ${ids.size} 个节点。`, data: { nodes: compactSnapshot({ ...current, nodes: current.nodes.filter((node) => ids.has(node.id)) }).nodes } };
}
if (name === "canvas_create_cinematic_session") {
const ops = await createCinematicSessionOps(projectId, requireString(args.prompt, "prompt"), current, effectiveConfig);
const result = executeOps(ops);
return { ok: result.changed, message: result.changed ? summarizeCanvasAgentOps(ops) || "后端影视 Agent 已写回画布。" : result.noopReason, data: result };
const cinematic = await runCinematicSession(sessionId, requireString(args.prompt, "prompt"), current, effectiveConfig);
try {
const result = executeOps(cinematic.ops);
completeCinematicSession(sessionId, cinematic.backendSessionId, cinematic.ops);
return { ok: result.changed, message: result.changed ? summarizeCanvasAgentOps(cinematic.ops) || "后端影视 Agent 已写回画布。" : result.noopReason, data: result };
} catch (error) {
failCinematicSession(sessionId, cinematic.backendSessionId, error);
throw error;
}
}
const ops = onlineToolToOps(name, args, current, effectiveConfig);
const result = executeOps(ops);
return { ok: result.changed, message: result.changed ? summarizeCanvasAgentOps(ops) || "画布操作已执行。" : result.noopReason, data: result };
} catch (error) {
if (isAgentSessionPollingAbort(error)) throw error;
return { ok: false, message: error instanceof Error ? error.message : "工具执行失败" };
}
};
const executeOnlineToolCall = async (toolCall: ResponseToolCall): Promise<OnlineExecutedToolCall> => {
const executeOnlineToolCall = async (sessionId: string, toolCall: ResponseToolCall): Promise<OnlineExecutedToolCall> => {
try {
const result = await executeOnlineTool(toolCall.function.name, parseToolArguments(toolCall.function.arguments));
const result = await executeOnlineTool(sessionId, toolCall.function.name, parseToolArguments(toolCall.function.arguments));
return { toolCallId: toolCall.id, name: toolCall.function.name, result };
} catch (error) {
if (isAgentSessionPollingAbort(error)) throw error;
return { toolCallId: toolCall.id, name: toolCall.function.name, result: { ok: false, message: error instanceof Error ? error.message : "工具参数错误" } };
}
};
const executeOnlineToolCalls = async (toolCalls: ResponseToolCall[]) => {
const executeOnlineToolCalls = async (sessionId: string, toolCalls: ResponseToolCall[]) => {
const results: OnlineExecutedToolCall[] = [];
let stopped = false;
for (const toolCall of toolCalls) {
@@ -424,7 +541,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
results.push({ toolCallId: toolCall.id, name: toolCall.function.name, result: { ok: false, message: "前一个工具调用失败,未继续执行。" } });
continue;
}
const result = await executeOnlineToolCall(toolCall);
const result = await executeOnlineToolCall(sessionId, toolCall);
results.push(result);
if (!result.result.ok) stopped = true;
}
@@ -447,7 +564,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
}
try {
setIsRunning(true);
const results = await executeOnlineToolCalls(toolCalls);
const results = await executeOnlineToolCalls(session.id, toolCalls);
addOnlineLog("工具执行结果", results);
upsertMessage(session.id, { id: messageId, role: "tool", title: "工具执行完成", text: results.map((item) => toolResultText(item.result)).join("\n"), detail: { ...detail, results, status: "completed" } });
pendingToolContextRef.current.delete(messageId);
@@ -469,7 +586,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const submit = async () => {
const text = prompt.trim();
if (!text || isRunning) return;
if (!text || agentBusy) return;
await sendMessage(text, messages);
};
@@ -483,7 +600,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const submitCinematicProject = async (text: string) => {
const value = text.trim();
if (!value) return;
if (!value || agentBusy) return;
const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model };
if (!isAiConfigReady(requestConfig, requestConfig.model)) {
openConfigDialog(true);
@@ -497,19 +614,54 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
appendMessage(session.id, { id: nanoid(), role: "user", text: value });
setPrompt("");
setIsRunning(true);
let backendSessionId = "";
try {
const ops = await createCinematicSessionOps(projectId, value, snapshotRef.current, effectiveConfig);
const next = onApplyOps(ops);
const cinematic = await runCinematicSession(session.id, value, snapshotRef.current, effectiveConfig, (createdId) => {
backendSessionId = createdId;
});
const next = onApplyOps(cinematic.ops);
snapshotRef.current = next;
appendMessage(session.id, { id: nanoid(), role: "assistant", title: "影视项目已写回", text: summarizeCanvasAgentOps(ops) || "影视项目已写回当前画布。" });
completeCinematicSession(session.id, cinematic.backendSessionId, cinematic.ops);
setCinematicEntryActive(false);
} catch (error) {
appendMessage(session.id, { id: nanoid(), role: "error", title: "影视项目生成失败", text: error instanceof Error ? error.message : "影视项目生成失败" });
if (isAgentSessionPollingAbort(error)) return;
if (backendSessionId) failCinematicSession(session.id, backendSessionId, error);
else appendMessage(session.id, { id: nanoid(), role: "error", title: "影视项目生成失败", text: error instanceof Error ? error.message : "影视项目生成失败" });
} finally {
setIsRunning(false);
}
};
const resumePendingCinematicSession = async (sessionId: string, pending: CanvasAssistantPendingBackendSession) => {
if (cinematicSessionControllersRef.current.has(pending.id)) return;
const controller = new AbortController();
cinematicSessionControllersRef.current.set(pending.id, controller);
setIsRunning(true);
addOnlineLog("恢复后端影视 Agent 会话", { backendSessionId: pending.id });
try {
const detail = await resumeCinematicAgentSession(pending.id, { signal: controller.signal });
const ops = requireOps(JSON.parse(cinematicAgentSessionOpsJson(detail)));
executeOps(ops);
completeCinematicSession(sessionId, pending.id, ops, true);
addOnlineLog("后端影视 Agent 会话恢复完成", { backendSessionId: pending.id });
} catch (error) {
if (!isAgentSessionPollingAbort(error)) {
failCinematicSession(sessionId, pending.id, error);
addOnlineLog("后端影视 Agent 会话恢复失败", error instanceof Error ? error.message : error);
}
} finally {
if (cinematicSessionControllersRef.current.get(pending.id) === controller) cinematicSessionControllersRef.current.delete(pending.id);
if (cinematicSessionControllersRef.current.size === 0) setIsRunning(false);
}
};
useEffect(() => {
localSessions.forEach((session) => {
const pending = session.pendingBackendSession;
if (pending?.kind === "cinematic" && pending.status === "pending") void resumePendingCinematicSession(session.id, pending);
});
}, [localSessions]);
const addImagesToCanvas = (files: FileList | File[] | null) => {
const file = Array.from(files || []).find((item) => item.type.startsWith("image/"));
if (file) onPasteImage(file);
@@ -590,7 +742,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
onDelete={(id) => setDeleteChatIds([id])}
/>
) : view === "log" ? (
<OnlineAgentLogView logs={onlineLogs} theme={theme} context={{ model: activeModel, running: isRunning, confirmTools, messages: messages.length, nodes: snapshot.nodes.length, connections: snapshot.connections.length }} onClear={() => setOnlineLogs([])} />
<OnlineAgentLogView logs={onlineLogs} theme={theme} context={{ model: activeModel, running: agentBusy, confirmTools, messages: messages.length, nodes: snapshot.nodes.length, connections: snapshot.connections.length }} onClear={() => setOnlineLogs([])} />
) : messages.length ? (
<>
{messages.map((message) => (
@@ -599,7 +751,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
{message.references?.length ? <MessageReferences message={message} /> : null}
</div>
))}
{isRunning ? <AgentWorkingMessage theme={theme} /> : null}
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<div className="flex h-full flex-col items-center justify-center px-3 text-center">
@@ -634,7 +786,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
) : null}
<AgentChatComposer
prompt={prompt}
sending={isRunning}
sending={agentBusy}
placeholder={cinematicEntryActive ? "一句话描述题材、角色和核心冲突" : "描述你想让 Agent 如何操作画布"}
theme={theme}
onPromptChange={setPrompt}
@@ -998,26 +1150,6 @@ function parseToolArguments(value: string) {
}
}
async function createCinematicSessionOps(projectId: string, prompt: string, snapshot: CanvasAgentSnapshot, config: AiConfig): Promise<CanvasAgentOp[]> {
const requestConfig = resolveModelRequestConfig(config, config.textModel || config.model);
const created = await createAgentSession({ projectId, prompt, canvasSnapshot: compactSnapshot(snapshot) as unknown as Record<string, unknown>, config: backendAgentProviderConfig(requestConfig) });
let detail = created;
for (let attempt = 0; attempt < 120; attempt += 1) {
if (detail.session.status === "completed") break;
if (detail.session.status === "failed") throw new Error(agentSessionFailureMessage(detail));
await delay(2000);
detail = await queryAgentSession(created.session.id);
}
if (detail.session.status !== "completed") throw new Error("后端影视 Agent 会话超时");
const opsJson = detail.session.canvasOpsJson;
if (!opsJson) throw new Error("后端影视 Agent 没有返回画布操作");
return requireOps(JSON.parse(opsJson));
}
function delay(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms));
}
function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
if (name === "canvas_apply_ops") return requireOps(input.ops);
if (name === "canvas_create_node") {
@@ -1419,6 +1551,15 @@ function backendAgentProviderConfig(config: ReturnType<typeof resolveModelReques
};
}
function cinematicSessionMessageId(backendSessionId: string) {
return `cinematic-session:${backendSessionId}`;
}
function upsertAssistantMessage(messages: CanvasAssistantMessage[], message: CanvasAssistantMessage) {
const exists = messages.some((item) => item.id === message.id);
return exists ? messages.map((item) => (item.id === message.id ? { ...item, ...message } : item)) : [...messages, message];
}
function createSession(): CanvasAssistantSession {
const now = new Date().toISOString();
return { id: nanoid(), title: "新对话", messages: [], createdAt: now, updatedAt: now };

View File

@@ -0,0 +1,73 @@
import {
agentSessionFailureMessage,
createAgentSession,
queryAgentSession,
type AgentSessionDetail,
type CreateSessionInput,
} from "@/services/api/task-center";
const CINEMATIC_SESSION_POLL_INTERVAL_MS = 2000;
const CINEMATIC_SESSION_MAX_POLLS = 120;
type CinematicSessionWaitOptions = {
signal?: AbortSignal;
};
type CreateCinematicSessionOptions = CinematicSessionWaitOptions & {
onCreated?: (detail: AgentSessionDetail) => void;
};
export async function createCinematicAgentSession(input: CreateSessionInput, options: CreateCinematicSessionOptions = {}) {
const created = await createAgentSession(input);
throwIfAborted(options.signal);
options.onCreated?.(created);
return waitForCinematicAgentSession(created, options);
}
export async function resumeCinematicAgentSession(id: string, options: CinematicSessionWaitOptions = {}) {
throwIfAborted(options.signal);
const detail = await queryAgentSession(id);
return waitForCinematicAgentSession(detail, options);
}
export function cinematicAgentSessionOpsJson(detail: AgentSessionDetail) {
if (detail.session.status !== "completed") throw new Error("后端影视 Agent 会话尚未完成");
if (!detail.session.canvasOpsJson) throw new Error("后端影视 Agent 没有返回画布操作");
return detail.session.canvasOpsJson;
}
export function isAgentSessionPollingAbort(error: unknown) {
return error instanceof Error && error.name === "AbortError";
}
async function waitForCinematicAgentSession(initialDetail: AgentSessionDetail, options: CinematicSessionWaitOptions) {
let detail = initialDetail;
for (let attempt = 0; attempt < CINEMATIC_SESSION_MAX_POLLS; attempt += 1) {
throwIfAborted(options.signal);
if (detail.session.status === "completed") return detail;
if (detail.session.status === "failed") throw new Error(agentSessionFailureMessage(detail));
await abortableDelay(CINEMATIC_SESSION_POLL_INTERVAL_MS, options.signal);
detail = await queryAgentSession(initialDetail.session.id);
}
throw new Error("后端影视 Agent 会话超时");
}
function abortableDelay(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
throwIfAborted(signal);
const finish = () => {
signal?.removeEventListener("abort", abort);
resolve();
};
const abort = () => {
window.clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
const timer = window.setTimeout(finish, ms);
signal?.addEventListener("abort", abort, { once: true });
});
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
}

View File

@@ -274,10 +274,19 @@ export type CanvasAssistantMessage = {
references?: CanvasAssistantReference[];
};
export type CanvasAssistantPendingBackendSession = {
id: string;
kind: "cinematic";
messageId: string;
status: "pending";
startedAt: string;
};
export type CanvasAssistantSession = {
id: string;
title: string;
messages: CanvasAssistantMessage[];
pendingBackendSession?: CanvasAssistantPendingBackendSession;
createdAt: string;
updatedAt: string;
};