diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 27bc173b8..8a8883af9 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -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(null); const snapshotRef = useRef(snapshot); const pendingToolContextRef = useRef(new Map()); + const cinematicSessionControllersRef = useRef(new Map()); 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, + 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): Promise => { + const executeOnlineTool = async (sessionId: string, name: string, args: Record): Promise => { 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 => { + const executeOnlineToolCall = async (sessionId: string, toolCall: ResponseToolCall): Promise => { 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" ? ( - setOnlineLogs([])} /> + setOnlineLogs([])} /> ) : messages.length ? ( <> {messages.map((message) => ( @@ -599,7 +751,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project {message.references?.length ? : null} ))} - {isRunning ? : null} + {agentBusy ? : null} ) : (
@@ -634,7 +786,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project ) : null} { - const requestConfig = resolveModelRequestConfig(config, config.textModel || config.model); - const created = await createAgentSession({ projectId, prompt, canvasSnapshot: compactSnapshot(snapshot) as unknown as Record, 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((resolve) => window.setTimeout(resolve, ms)); -} - function onlineToolToOps(name: string, input: Record, 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 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 }; diff --git a/web/src/lib/canvas/canvas-agent-session.ts b/web/src/lib/canvas/canvas-agent-session.ts new file mode 100644 index 000000000..a31e63197 --- /dev/null +++ b/web/src/lib/canvas/canvas-agent-session.ts @@ -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((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"); +} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 4c89dae5c..61c516eda 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -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; };