import { useEffect, useMemo, useRef, useState } from "react"; import copyToClipboard from "copy-to-clipboard"; import { Copy, Cpu, History, MessageSquareText, Plus, ScrollText, Settings2, Trash2, X } from "lucide-react"; import { Button, Modal, Segmented, Select, Tooltip } from "antd"; import { motion } from "motion/react"; import { modelDisplayName, modelOptionName, normalizeModelOptionValue, resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { nanoid } from "nanoid"; import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image"; 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 { navigateToSettings } from "@/lib/settings-navigation"; import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session"; import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary"; import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui"; import { AgentChatEmptyState, AgentPanelChrome } from "./canvas-agent-panel-chrome"; import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel"; import { NODE_DEFAULT_SIZE } from "@/constant/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 { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache"; import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context"; export const CANVAS_AGENT_PANEL_MOTION_MS = 500; const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000; const ONLINE_AGENT_MAX_STEPS = 4; const ONLINE_AGENT_PROMPT = "你是影策网页内置在线画布助手。当前画布 JSON 会随用户消息提供。首轮必须调用工具:只读问题调用 canvas_get_state,需要改动画布时调用和本地 Agent 一致的 infinite-canvas 工具。需要生成内容时直接调用 canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio 或 canvas_create_generation_flow;需要精确批量操作时调用 canvas_apply_ops。不要输出 JSON ops,不要编造执行结果。工具参数涉及已有节点时必须使用当前画布 JSON 中真实存在的 id;缺少必要 id 或用户意图不明确时直接说明需要用户明确选择或说明,不要猜测。工具返回结果后,再根据真实结果回答用户。"; const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true }; const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false }; const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false }; const NODE_TYPE_SCHEMA = { type: "string", enum: ["image", "text", "skill", "video", "audio"] }; const GENERATION_MODE_SCHEMA = { type: "string", enum: ["text", "image", "video", "audio"] }; const GENERATION_OPTION_PROPERTIES = { model: { type: "string" }, size: { type: "string" }, quality: { type: "string" }, transparentBackground: { type: "string", enum: ["true", "false"] }, count: { type: "number" }, seconds: { type: "string" }, vquality: { type: "string" }, generateAudio: { type: "string" }, watermark: { type: "string" }, audioVoice: { type: "string" }, audioFormat: { type: "string" }, audioSpeed: { type: "string" }, audioInstructions: { type: "string" }, }; const CANVAS_OP_SCHEMA = { type: "object", properties: { type: { type: "string", enum: ["add_node", "update_node", "delete_node", "delete_connections", "connect_nodes", "set_viewport", "select_nodes", "run_generation"] }, id: { type: "string" }, ids: { type: "array", items: { type: "string" } }, nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, position: POSITION_SCHEMA, metadata: JSON_RECORD_SCHEMA, patch: JSON_RECORD_SCHEMA, all: { type: "boolean" }, fromNodeId: { type: "string" }, toNodeId: { type: "string" }, viewport: VIEWPORT_SCHEMA, nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" }, }, required: ["type"], additionalProperties: false, }; const ONLINE_READ_TOOLS = new Set(["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"]); function toolDefinition(name: string, description: string, properties: Record, required: string[] = [], strict = false): ResponseFunctionTool { return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } }; } function generationToolDefinition(name: string, description: string, mode?: "text" | "image" | "video" | "audio") { return toolDefinition( name, description, { prompt: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, referenceNodeIds: { type: "array", items: { type: "string" } }, ...(mode ? {} : { mode: GENERATION_MODE_SCHEMA }), autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES }, ["prompt"], ); } const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [ toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}), toolDefinition("canvas_get_selection", "读取当前网页画布选中的节点。", {}), toolDefinition("canvas_export_snapshot", "导出当前画布快照,用于理解布局。", {}), toolDefinition("canvas_apply_ops", "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。", { ops: { type: "array", items: CANVAS_OP_SCHEMA } }, ["ops"], false), toolDefinition("canvas_create_node", "创建任意类型节点:text、image、video、audio。适合创建文本、媒体占位或自定义 metadata 节点。", { nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, metadata: JSON_RECORD_SCHEMA }, ["nodeType"]), toolDefinition("canvas_create_text_node", "在当前画布创建单个文本节点。", { text: { type: "string" }, x: { type: "number" }, y: { type: "number" }, title: { type: "string" }, width: { type: "number" }, height: { type: "number" } }), toolDefinition("canvas_create_text_nodes", "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。", { items: { type: "array", minItems: 1, items: { type: "object", properties: { text: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" } }, required: ["text"], additionalProperties: false } }, x: { type: "number" }, y: { type: "number" }, gap: { type: "number" }, direction: { type: "string", enum: ["row", "column"] } }, ["items"]), toolDefinition("canvas_create_cinematic_session", "把自然语言创作指令提交给后端影视 Agent 会话,后端拆解为剧本、场景、分镜、镜头和成片节点,并返回可写回画布的操作。", { prompt: { type: "string" } }, ["prompt"]), toolDefinition("canvas_create_image_prompt_flow", "创建提示词文本节点和图片目标节点并自动连线,可选择立即触发生图。", { prompt: { type: "string" }, x: { type: "number" }, y: { type: "number" }, autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES }, ["prompt"]), generationToolDefinition("canvas_create_generation_flow", "创建通用生成流程:提示词文本节点、对应类型的生成目标节点和参考节点连线,可用于文案、生图、视频或音频。"), generationToolDefinition("canvas_generate_text", "创建通用文本生成流程并立即触发生成。", "text"), generationToolDefinition("canvas_generate_image", "创建通用图片生成流程并立即触发生成。", "image"), generationToolDefinition("canvas_generate_video", "创建通用视频生成流程并立即触发生成。", "video"), generationToolDefinition("canvas_generate_audio", "创建通用音频生成流程并立即触发生成。", "audio"), toolDefinition("canvas_update_node", "更新节点基础字段或 metadata。", { id: { type: "string" }, patch: JSON_RECORD_SCHEMA, metadata: JSON_RECORD_SCHEMA }, ["id"]), toolDefinition("canvas_update_node_text", "更新文本节点内容和标题。", { id: { type: "string" }, text: { type: "string" }, title: { type: "string" } }, ["id", "text"]), toolDefinition("canvas_move_nodes", "移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。", { items: { type: "array", minItems: 1, items: { type: "object", properties: { id: { type: "string" }, x: { type: "number" }, y: { type: "number" }, dx: { type: "number" }, dy: { type: "number" } }, required: ["id"], additionalProperties: false } } }, ["items"]), toolDefinition("canvas_resize_node", "调整节点尺寸。", { id: { type: "string" }, width: { type: "number" }, height: { type: "number" }, freeResize: { type: "boolean" } }, ["id", "width", "height"]), toolDefinition("canvas_delete_nodes", "删除指定节点及相关连线。", { ids: { type: "array", items: { type: "string" }, minItems: 1 } }, ["ids"]), toolDefinition("canvas_connect_nodes", "批量连接节点。", { connections: { type: "array", minItems: 1, items: { type: "object", properties: { fromNodeId: { type: "string" }, toNodeId: { type: "string" } }, required: ["fromNodeId", "toNodeId"], additionalProperties: false } } }, ["connections"]), toolDefinition("canvas_select_nodes", "设置当前选中节点。", { ids: { type: "array", items: { type: "string" } } }, ["ids"]), toolDefinition("canvas_set_viewport", "调整画布视口。", { viewport: VIEWPORT_SCHEMA }, ["viewport"]), toolDefinition("canvas_run_generation", "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。", { nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" } }, ["nodeId"]), ]; type OnlineAgentTab = "setup" | "chat" | "history" | "log"; type OnlineAgentLog = { id: string; time: string; title: string; data?: unknown }; type OnlineAgentLogContext = { model: string; running: boolean; confirmTools: boolean; messages: number; nodes: number; connections: number }; type OnlineLoopContext = { step: number }; type OnlineToolResult = { ok: true; message: string; data?: unknown } | { ok: false; message: string }; type OnlineExecutedToolCall = { toolCallId: string; name: string; result: OnlineToolResult }; type PendingOnlineToolContext = { messages: ResponseInputMessage[]; toolCalls: ResponseToolCall[]; assistantId: string; step: number }; type CanvasAssistantPanelProps = { nodes: CanvasNodeData[]; selectedNodeIds: Set; snapshot: CanvasAgentSnapshot; projectId: string; sessions: CanvasAssistantSession[]; activeSessionId: string | null; onSelectNodeIds: (ids: Set) => void; onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void; onApplyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; canUndoOps: boolean; undoOpsCount: number; onUndoOps: () => CanvasAgentSnapshot | null; onPasteImage: (file: File) => void; agentMode: CanvasAgentMode; onAgentModeChange: (mode: CanvasAgentMode) => void; autoConnectLocal?: boolean; closing: boolean; onCollapse: () => void; cinematicEntry?: boolean; onCinematicEntryConsumed?: () => void; width: number; onWidthChange: (width: number) => void; focusMode: boolean; }; export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, projectId, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, undoOpsCount, onUndoOps, onPasteImage, agentMode, onAgentModeChange, autoConnectLocal, closing, onCollapse, cinematicEntry = false, onCinematicEntryConsumed, width, onWidthChange, focusMode }: CanvasAssistantPanelProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const user = useUserStore((state) => state.user); const effectiveConfig = useEffectiveConfig(); const cleanupImages = useAssetStore((state) => state.cleanupImages); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const updateConfig = useConfigStore((state) => state.updateConfig); const confirmTools = useCanvasAgentStore((state) => state.confirmTools); const setAgentState = useCanvasAgentStore((state) => state.setAgentState); const [view, setView] = useState("chat"); const [prompt, setPrompt] = useState(""); const [cinematicEntryActive, setCinematicEntryActive] = useState(cinematicEntry); const [isRunning, setIsRunning] = useState(false); const [deleteChatIds, setDeleteChatIds] = useState([]); const [onlineLogs, setOnlineLogs] = useState([]); const [resizing, setResizing] = useState(false); const [removedReferenceIds, setRemovedReferenceIds] = useState>(new Set()); const [localSessions, setLocalSessions] = useState(() => (sessions.length ? sessions : [createSession()])); const [localActiveSessionId, setLocalActiveSessionId] = useState(activeSessionId); const applyingExternalSessionsRef = useRef(false); const chatListRef = useRef(null); const snapshotRef = useRef(snapshot); const pendingToolContextRef = useRef(new Map()); const cinematicSessionControllersRef = useRef(new Map()); useEffect(() => { if (!sessions.length) return; if (sessions === localSessions && activeSessionId === localActiveSessionId) return; applyingExternalSessionsRef.current = true; setLocalSessions(sessions); setLocalActiveSessionId(activeSessionId); }, [activeSessionId, sessions]); useEffect(() => { snapshotRef.current = snapshot; }, [snapshot]); useEffect(() => () => { // 收起面板或刷新页面时只停止前端查询,后台任务由下次挂载根据持久化 ID 继续接管。 cinematicSessionControllersRef.current.forEach((controller) => controller.abort()); cinematicSessionControllersRef.current.clear(); }, []); useEffect(() => { if (applyingExternalSessionsRef.current) { applyingExternalSessionsRef.current = false; return; } if (sessions === localSessions && activeSessionId === localActiveSessionId) return; onSessionsChange(localSessions, localActiveSessionId); }, [activeSessionId, localActiveSessionId, localSessions, onSessionsChange, sessions]); const safeSessions = localSessions.length ? localSessions : [createSession()]; const activeSession = useMemo(() => safeSessions.find((session) => session.id === localActiveSessionId) || safeSessions[0] || null, [localActiveSessionId, safeSessions]); 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]); const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]); const contextSummary = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds), [nodes, selectedNodeIds]); const iconButtonStyle = { color: theme.node.muted }; useEffect(() => { if (agentMode !== "online" || view !== "chat") return; const frame = requestAnimationFrame(() => chatListRef.current?.scrollTo({ top: chatListRef.current.scrollHeight })); return () => cancelAnimationFrame(frame); }, [agentBusy, agentMode, localActiveSessionId, messages, view]); useEffect(() => { setRemovedReferenceIds(new Set()); }, [selectedNodeKey]); const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => { setLocalSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(session) : session))); }; const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => { updateSession(sessionId, (session) => ({ ...session, title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话", messages: [...session.messages, message], updatedAt: new Date().toISOString(), })); }; const addOnlineLog = (title: string, data?: unknown) => setOnlineLogs((prev) => [{ id: nanoid(), time: new Date().toLocaleTimeString(), title, data }, ...prev].slice(0, 80)); const upsertMessage = (sessionId: string, message: CanvasAssistantMessage) => { updateSession(sessionId, (session) => { const exists = session.messages.some((item) => item.id === message.id); return { ...session, title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话", messages: exists ? session.messages.map((item) => (item.id === message.id ? { ...item, ...message } : item)) : [...session.messages, message], updatedAt: new Date().toISOString(), }; }); }; 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 storyboardContext = resolveStoryboardGenerationContext(current.nodes); 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, projectStyle: storyboardContext.projectStyle, characters: storyboardContext.characters, 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); return; } const session = createSession(); setLocalSessions((prev) => [session, ...prev]); setLocalActiveSessionId(session.id); }; const removeSessions = (ids: string[]) => { const next = safeSessions.filter((session) => !ids.includes(session.id)); if (!next.length) { const session = createSession(); setLocalSessions([session]); setLocalActiveSessionId(session.id); } else { setLocalSessions(next); setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId); } cleanupImages({ sessions: next }); }; const clearSessions = () => { const session = createSession(); setLocalSessions([session]); setLocalActiveSessionId(session.id); cleanupImages({ sessions: [session] }); }; const sendMessage = async (text: string, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => { const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model }; if (!isAiConfigReady(requestConfig, requestConfig.model)) { navigateToSettings({ continueCreation: true }); return; } const session = activeSession || createSession(); if (!activeSession) { setLocalSessions([session]); setLocalActiveSessionId(session.id); } const refs = savedReferences || selectedReferences; const userMessage: CanvasAssistantMessage = { id: nanoid(), role: "user", text, references: refs }; const assistantId = nanoid(); appendMessage(session.id, userMessage); addOnlineLog("发送请求", { text, selectedNodeIds: snapshotRef.current.selectedNodeIds, nodeCount: snapshotRef.current.nodes.length, connectionCount: snapshotRef.current.connections.length }); setPrompt(""); setIsRunning(true); void runOnlineAgentStep(session.id, assistantId, history, userMessage, { step: 1 }); }; const runOnlineAgentStep = async (sessionId: string, assistantId: string, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, loop: OnlineLoopContext) => { const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model }; try { setIsRunning(true); const messages = await buildToolAgentMessages(snapshotRef.current, history, userMessage); addOnlineLog(`Agent Tool Loop ${loop.step} 开始`, { toolChoice: "required" }); let streamed = ""; const result = await requestToolResponse({ ...requestConfig, systemPrompt: "" }, messages, ONLINE_AGENT_TOOLS, "required", (text) => { streamed = text; if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); }, { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }); addOnlineLog("模型工具回复", result); if (result.toolCalls.length) { const writableCalls = result.toolCalls.filter(isWritableToolCall); if (confirmTools && writableCalls.length) { upsertMessage(sessionId, { id: assistantId, role: "assistant", text: result.content || streamed || "准备执行工具,等待确认。" }); const toolMessageId = nanoid(); pendingToolContextRef.current.set(toolMessageId, { messages, toolCalls: result.toolCalls, assistantId, step: loop.step }); const toolMessage: CanvasAssistantMessage = { id: toolMessageId, role: "tool", title: "确认工具调用", text: summarizeToolCalls(result.toolCalls), detail: { status: "pending", step: loop.step, toolCalls: result.toolCalls, impact: previewOnlineToolCalls(result.toolCalls, snapshotRef.current, effectiveConfig) } }; appendMessage(sessionId, toolMessage); addOnlineLog("等待用户确认", result.toolCalls); return; } await continueOnlineToolLoop(sessionId, assistantId, messages, result, loop.step); } else { if (!result.content.trim()) throw new Error("模型没有返回工具调用,画布操作未执行。"); upsertMessage(sessionId, { id: assistantId, role: "assistant", text: result.content || streamed || "没有返回内容。" }); addOnlineLog(`Agent Tool Loop ${loop.step} 结束`, { reply: result.content }); } } catch (error) { addOnlineLog("请求失败", error instanceof Error ? error.message : error); appendMessage(sessionId, { id: nanoid(), role: "error", title: "操作失败", text: error instanceof Error ? error.message : "操作失败" }); } finally { setIsRunning(false); } }; const continueOnlineToolLoop = async (sessionId: string, assistantId: string, messages: ResponseInputMessage[], result: { content: string; toolCalls: ResponseToolCall[] }, step: number) => { const toolResults = await executeOnlineToolCalls(sessionId, result.toolCalls); addOnlineLog("工具执行结果", toolResults); appendMessage(sessionId, { id: nanoid(), role: "tool", title: "工具自动执行完成", text: toolResults.map((item) => toolResultText(item.result)).join("\n"), detail: { status: "completed", step, toolCalls: result.toolCalls, results: toolResults }, }); await continueOnlineToolLoopAfterResults(sessionId, assistantId, messages, result.toolCalls, toolResults, step); }; const continueOnlineToolLoopAfterResults = async (sessionId: string, assistantId: string, messages: ResponseInputMessage[], toolCalls: ResponseToolCall[], toolResults: OnlineExecutedToolCall[], step: number) => { const nextMessages: ResponseInputMessage[] = [ ...messages, ...toolCalls.map(toolCallToResponseInput), ...toolResults.map((item) => ({ role: "tool" as const, tool_call_id: item.toolCallId, content: JSON.stringify(item.result) })), ]; if (step >= ONLINE_AGENT_MAX_STEPS) { upsertMessage(sessionId, { id: assistantId, role: "assistant", text: toolResults.map((item) => toolResultText(item.result)).join("\n") || "工具已执行。" }); addOnlineLog("Agent Tool Loop 达到步数上限", { maxSteps: ONLINE_AGENT_MAX_STEPS }); return; } const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model }; let streamed = ""; const next = await requestToolResponse({ ...requestConfig, systemPrompt: "" }, nextMessages, ONLINE_AGENT_TOOLS, "auto", (text) => { streamed = text; if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); }, { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }); addOnlineLog(`Agent Tool Loop ${step + 1} 回复`, next); if (next.toolCalls.length) { const writableCalls = next.toolCalls.filter(isWritableToolCall); if (confirmTools && writableCalls.length) { upsertMessage(sessionId, { id: assistantId, role: "assistant", text: next.content || streamed || "准备执行工具,等待确认。" }); const toolMessageId = nanoid(); pendingToolContextRef.current.set(toolMessageId, { messages: nextMessages, toolCalls: next.toolCalls, assistantId, step: step + 1 }); appendMessage(sessionId, { id: toolMessageId, role: "tool", title: "确认工具调用", text: summarizeToolCalls(next.toolCalls), detail: { status: "pending", step: step + 1, toolCalls: next.toolCalls, impact: previewOnlineToolCalls(next.toolCalls, snapshotRef.current, effectiveConfig) } }); addOnlineLog("等待用户确认", next.toolCalls); return; } await continueOnlineToolLoop(sessionId, assistantId, nextMessages, next, step + 1); return; } upsertMessage(sessionId, { id: assistantId, role: "assistant", text: next.content || streamed || toolResults.map((item) => toolResultText(item.result)).join("\n") || "工具已执行。" }); }; const executeOps = (ops: CanvasAgentOp[]) => { const beforeSnapshot = snapshotRef.current; const before = snapshotSignature(beforeSnapshot); const next = onApplyOps(ops); snapshotRef.current = next; const ranGeneration = ops.some((op) => op.type === "run_generation" && Boolean(op.nodeId)); const changed = before !== snapshotSignature(next) || ranGeneration; const noopReason = changed ? "" : explainNoop(ops, beforeSnapshot); return { changed, ops, ranGeneration, noopReason, before: JSON.parse(before), after: JSON.parse(snapshotSignature(next)) }; }; 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) }; if (name === "canvas_export_snapshot") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) }; if (name === "canvas_get_selection") { const ids = new Set(current.selectedNodeIds || []); 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 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 (sessionId: string, toolCall: ResponseToolCall): Promise => { try { 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 (sessionId: string, toolCalls: ResponseToolCall[]) => { const results: OnlineExecutedToolCall[] = []; let stopped = false; for (const toolCall of toolCalls) { if (stopped) { results.push({ toolCallId: toolCall.id, name: toolCall.function.name, result: { ok: false, message: "前一个工具调用失败,未继续执行。" } }); continue; } const result = await executeOnlineToolCall(sessionId, toolCall); results.push(result); if (!result.result.ok) stopped = true; } return results; }; const approveOnlineTool = async (messageId: string) => { const message = safeSessions.flatMap((session) => session.messages).find((item) => item.id === messageId); const detail = objectDetail(message?.detail); const pendingContext = pendingToolContextRef.current.get(messageId); const toolCalls = pendingContext?.toolCalls || toolCallsFromDetail(detail); const previousMessages = pendingContext?.messages || []; const session = safeSessions.find((session) => session.messages.some((item) => item.id === messageId)); addOnlineLog("批准工具", { messageId, toolCalls }); const assistantId = pendingContext?.assistantId || ""; if (!session) return; if (!toolCalls.length || !previousMessages.length || !assistantId) { upsertMessage(session.id, { id: messageId, role: "tool", title: "工具执行失败", text: "工具上下文不完整,无法执行。", detail: { ...detail, status: "failed" } }); return; } try { setIsRunning(true); 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); await continueOnlineToolLoopAfterResults(session.id, assistantId, previousMessages, toolCalls, results, pendingContext?.step || Number(detail.step) || 1); } catch (error) { addOnlineLog("工具续跑失败", error instanceof Error ? error.message : error); appendMessage(session.id, { id: nanoid(), role: "error", title: "操作失败", text: error instanceof Error ? error.message : "操作失败" }); } finally { setIsRunning(false); } }; const rejectOnlineTool = (messageId: string) => { const session = safeSessions.find((session) => session.messages.some((item) => item.id === messageId)); addOnlineLog("拒绝工具", { messageId }); pendingToolContextRef.current.delete(messageId); if (session) upsertMessage(session.id, { id: messageId, role: "tool", title: "已拒绝执行", text: "工具调用已取消", detail: { ...objectDetail(session.messages.find((item) => item.id === messageId)?.detail), status: "rejected" } }); }; const undoLastOnlineBatch = () => { const restored = onUndoOps(); if (!restored) return; snapshotRef.current = restored; if (activeSession) appendMessage(activeSession.id, { id: nanoid(), role: "tool", title: "已撤销 Agent 批次", text: "已恢复到本次写回前的画布状态", detail: { status: "completed", remainingUndoCount: Math.max(0, undoOpsCount - 1) } }); }; const submit = async () => { const text = prompt.trim(); if (!text || agentBusy) return; await sendMessage(text, messages); }; useEffect(() => { if (!cinematicEntry) return; setCinematicEntryActive(true); setView("chat"); setPrompt(""); onCinematicEntryConsumed?.(); }, [cinematicEntry, onCinematicEntryConsumed]); const submitCinematicProject = async (text: string) => { const value = text.trim(); if (!value || agentBusy) return; const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model }; if (!isAiConfigReady(requestConfig, requestConfig.model)) { navigateToSettings({ continueCreation: true }); return; } const session = activeSession || createSession(); if (!activeSession) { setLocalSessions([session]); setLocalActiveSessionId(session.id); } appendMessage(session.id, { id: nanoid(), role: "user", text: value }); setPrompt(""); setIsRunning(true); let backendSessionId = ""; try { const cinematic = await runCinematicSession(session.id, value, snapshotRef.current, effectiveConfig, (createdId) => { backendSessionId = createdId; }); const next = onApplyOps(cinematic.ops); snapshotRef.current = next; completeCinematicSession(session.id, cinematic.backendSessionId, cinematic.ops); setCinematicEntryActive(false); } catch (error) { 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); }; const startResize = () => { const move = (event: MouseEvent) => onWidthChange(Math.min(760, Math.max(320, window.innerWidth - event.clientX))); const stop = () => { setResizing(false); document.body.style.cursor = ""; document.body.style.userSelect = ""; document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", stop); }; setResizing(true); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", move); document.addEventListener("mouseup", stop); }; const collapse = () => { onCollapse(); }; const onlineContent = ( <> }, { value: "chat", label: "对话", icon: }, { value: "history", label: "历史", icon: , count: historySessions.length }, { value: "log", label: "记录", icon: , count: onlineLogs.length }, ]} onChange={setView} right={ <> {view === "history" ? ( } >

将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。

); // 垂直分槽:顶部 72px(顶栏)+ 200px(活动任务最大预估高)+ 16px(间距)= 288px,留给活动任务面板 const PANEL_SLOT_TOP_OFFSET = 288; const panelOffsetRight = focusMode ? 0 : 12; const panelOffsetBottom = focusMode ? 0 : 16; return ( ); } function OnlineAgentLogView({ logs, theme, context, onClear }: { logs: OnlineAgentLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: OnlineAgentLogContext; onClear: () => void }) { const [mode, setMode] = useState<"text" | "json">("text"); const textareaRef = useRef(null); const content = mode === "text" ? formatOnlineLogText(logs, context) : formatOnlineLogJson(logs, context); const lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${stringifyLog(item.data)}`)); const copy = async (value = content) => { if (await copyToClipboard(value)) return; textareaRef.current?.focus(); textareaRef.current?.select(); }; return (
setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
{logs.length} 条