Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-assistant-panel.tsx
爱笑的毛毛虫 d6a42d53a1 refactor(canvas): 浮层体系重构 — z-index 14档+专注模式+工具栏click触发 #126 #127 #128 (#129)
按视觉层重设计 z-index(12→14 档),新增节点层/激活节点层/节点工具栏层等
语义 token,并下沉节点内部 stacking context(--node-z-content/overlay/handle)
与 dock 子面板(--dock-z-popover),消除硬编码 z 值。

新增专注模式(避让分屏可切换):use-focus-mode 管理 localStorage 偏好与小屏
(<1024px)强制避让;助手面板从 flex 改为 absolute + 垂直分槽(顶部 288px
让位活动任务面板),画布区按面板宽度动态 padding;顶栏新增 PanelRight
切换按钮并带强制态反馈。

节点工具栏由 hover+select 触发放为 click-only(选中即显),组件由
canvas-node-hover-toolbar 重命名为 canvas-node-toolbar;移除 project 与
render-model 中冗余的 toolbarNodeId 状态。

活动任务面板 z-index 由 --z-modal-overlay(110) 降至 --z-panel-floating(80),
归位浮动面板层;AGENTS.md Elevation 同步至 14 档。

Co-authored-by: sikongyue <3155157671@qq.com>
2026-08-04 20:46:25 +08:00

1624 lines
94 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 { 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<string, unknown>, 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<string>;
snapshot: CanvasAgentSnapshot;
projectId: string;
sessions: CanvasAssistantSession[];
activeSessionId: string | null;
onSelectNodeIds: (ids: Set<string>) => 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<OnlineAgentTab>("chat");
const [prompt, setPrompt] = useState("");
const [cinematicEntryActive, setCinematicEntryActive] = useState(cinematicEntry);
const [isRunning, setIsRunning] = useState(false);
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
const [resizing, setResizing] = useState(false);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
const applyingExternalSessionsRef = useRef(false);
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;
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<string, unknown>,
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<string, unknown>): Promise<OnlineToolResult> => {
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<OnlineExecutedToolCall> => {
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 = (
<>
<AgentPanelTabs
value={view}
theme={theme}
items={[
{ value: "setup", label: "配置", icon: <Settings2 className="size-3.5" /> },
{ value: "chat", label: "对话", icon: <MessageSquareText className="size-3.5" /> },
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: historySessions.length },
{ value: "log", label: "记录", icon: <ScrollText className="size-3.5" />, count: onlineLogs.length },
]}
onChange={setView}
right={
<>
{view === "history" ? (
<Tooltip title="删除全部">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} />
</Tooltip>
) : null}
<Tooltip title="新对话">
<Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8"
style={iconButtonStyle}
icon={<Plus className="size-4" />}
disabled={!hasMessages}
onClick={() => {
startChatSession();
setView("chat");
}}
/>
</Tooltip>
</>
}
/>
{view === "setup" ? (
<OnlineAgentSetupView theme={theme} activeModel={activeModel} onOpenConfig={() => navigateToSettings({ continueCreation: true })} />
) : (
<div ref={chatListRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
{view === "history" ? (
<AssistantHistory
sessions={historySessions}
activeSession={activeSession}
onOpen={(id) => {
setLocalActiveSessionId(id);
setView("chat");
}}
onDelete={(id) => setDeleteChatIds([id])}
/>
) : view === "log" ? (
<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) => (
<div key={message.id} className="space-y-2">
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} />
{message.references?.length ? <MessageReferences message={message} /> : null}
</div>
))}
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<AgentChatEmptyState theme={theme} nodeCount={contextSummary.nodeCount} onSelect={setPrompt} />
)}
</div>
)}
{view === "chat" ? (
<>
{selectedReferences.length ? (
<div className="thin-scrollbar flex max-w-full gap-1.5 overflow-x-auto px-3 pb-1">
{selectedReferences.map((item, index) => (
<AssistantReferenceChip
key={item.id}
item={item}
label={assistantImageReferenceLabel(selectedReferences, index)}
onRemove={() => {
setRemovedReferenceIds((prev) => new Set(prev).add(item.id));
if (selectedNodeIds.has(item.id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== item.id)));
}}
/>
))}
</div>
) : null}
<AgentChatComposer
prompt={prompt}
sending={agentBusy}
placeholder={cinematicEntryActive ? "一句话描述题材、角色和核心冲突" : "描述你想让 Agent 如何操作画布"}
theme={theme}
onPromptChange={setPrompt}
onSubmit={cinematicEntryActive ? () => submitCinematicProject(prompt) : submit}
onAddFiles={addImagesToCanvas}
left={
<>
<AgentTextModelPicker config={effectiveConfig} value={effectiveConfig.textModel} onChange={(model) => updateConfig("textModel", model)} />
{cinematicEntryActive ? <span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[var(--fs-tiny)] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>影视项目</span> : null}
</>
}
/>
</>
) : null}
<Modal
title="删除对话记录?"
open={deleteChatIds.length > 0}
centered
onCancel={() => setDeleteChatIds([])}
footer={
<>
<Button onClick={() => setDeleteChatIds([])}>取消</Button>
<Button
danger
type="primary"
onClick={() => {
deleteChatIds.length === historySessions.length ? clearSessions() : removeSessions(deleteChatIds);
setDeleteChatIds([]);
}}
>
删除
</Button>
</>
}
>
<p className="text-sm opacity-60">将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。</p>
</Modal>
</>
);
// 垂直分槽:顶部 72px(顶栏)+ 200px(活动任务最大预估高)+ 16px(间距)= 288px,留给活动任务面板
const PANEL_SLOT_TOP_OFFSET = 288;
const panelOffsetRight = focusMode ? 0 : 12;
const panelOffsetBottom = focusMode ? 0 : 16;
return (
<motion.div
className="pointer-events-none absolute z-[var(--z-panel-floating)]"
initial={{ opacity: 0 }}
animate={{ opacity: closing ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{
top: PANEL_SLOT_TOP_OFFSET,
right: panelOffsetRight,
bottom: panelOffsetBottom,
width: closing ? 0 : width,
overflow: "clip",
pointerEvents: closing ? "none" : undefined,
}}
>
<motion.aside
className="relative flex shrink-0 flex-col overflow-hidden rounded-[var(--panel-radius)] border"
initial={{ x: 48, opacity: 0 }}
animate={{ x: closing ? 28 : 0, opacity: closing ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{
width,
height: "100%",
borderColor: theme.toolbar.border,
background: theme.spatial.elevated,
color: theme.node.text,
boxShadow: focusMode ? "none" : `0 24px 72px ${theme.spatial.shadow}`,
}}
>
<button type="button" className="absolute inset-y-0 left-0 z-[var(--node-z-overlay)] w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
<AgentPanelChrome
theme={theme}
mode={agentMode}
context={contextSummary}
referenceCount={selectedReferences.length}
confirmTools={confirmTools}
canUndo={agentMode === "online" && canUndoOps}
undoCount={agentMode === "online" ? undoOpsCount : 0}
onModeChange={onAgentModeChange}
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
onUndo={undoLastOnlineBatch}
onCollapse={collapse}
/>
{agentMode === "local" ? (
<CanvasLocalAgentPanel
embedded
snapshot={snapshot}
canUndoOps={canUndoOps}
undoOpsCount={undoOpsCount}
onApplyOps={onApplyOps}
onUndoOps={onUndoOps}
autoConnect={autoConnectLocal}
/>
) : (
onlineContent
)}
</motion.aside>
</motion.div>
);
}
function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; value: string; onChange: (model: string) => void }) {
const options = useMemo(() => Array.from(new Set([value, ...selectableModelsByCapability(config, "text")].filter(Boolean))), [config, value]);
const current = value || "";
return (
<div className="min-w-0 max-w-[240px]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Select<string>
size="small"
variant="borderless"
value={current || undefined}
className="agent-text-model-select w-full"
popupMatchSelectWidth={288}
options={options.map((model) => ({ value: model, label: `${modelDisplayName(config, model)} ${resolveModelChannel(config, model).name}` }))}
notFoundContent={<span className="block py-2 text-center text-xs text-foreground/48">暂无文本模型</span>}
optionRender={(option) => {
const model = String(option.value);
return <span className="flex min-w-0 items-center gap-2"><AgentModelIcon model={model} /><span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span><span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span></span>;
}}
labelRender={() => <span className="flex min-w-0 items-center gap-1.5"><AgentModelIcon model={current} /><span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>{current ? <span className="shrink-0 opacity-55">{resolveModelChannel(config, current).name}</span> : null}</span>}
onChange={onChange}
aria-label="选择 Agent 文本模型"
title={current ? `${modelDisplayName(config, current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
/>
</div>
);
}
function AgentModelIcon({ model }: { model: string }) {
const icon = resolveModelIcon(modelOptionName(model));
return icon ? <img src={icon} alt="" className="size-4 shrink-0 dark:invert" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
}
function resolveModelIcon(model: string) {
// 与 model-picker 保持同一套厂商图标规则。
const name = model.toLowerCase();
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
if (
name.includes("gemini") ||
name.includes("google") ||
name.includes("nano banana") ||
name.includes("nanobanana") ||
name.includes("imagen") ||
name.includes("veo") ||
name.includes("omni flash") ||
name.includes("omni-flash")
) {
return "/icons/gemini.svg";
}
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
if (name.includes("grok")) return "/icons/grok.svg";
if (name.includes("deepseek")) return "/icons/deepseek.svg";
if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg";
return "";
}
function AssistantHistory({
sessions,
activeSession,
onOpen,
onDelete,
}: {
sessions: CanvasAssistantSession[];
activeSession: CanvasAssistantSession | null;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="space-y-3">
<div className="text-sm" style={{ color: theme.node.muted }}>
{sessions.length ? `${sessions.length} 条历史` : "暂无历史"}
</div>
{sessions.map((session) => (
<div key={session.id} className="rounded-md px-2.5 py-2 transition-colors" style={{ background: session.id === activeSession?.id ? theme.accent.primarySoft : "transparent", color: theme.node.text }}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{session.id === activeSession?.id ? <span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
<div className="truncate text-sm font-medium leading-5">{session.title}</div>
</div>
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{sessionPreview(session)}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className="text-[var(--fs-tiny)] opacity-55">{formatSessionTime(session.updatedAt || session.createdAt)}</span>
<Button size="small" className="!h-6 !px-2" onClick={() => onOpen(session.id)}>
进入
</Button>
<Tooltip title="删除记录">
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(session.id)} />
</Tooltip>
</div>
</div>
</div>
))}
{!sessions.length ? (
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
网站 Agent 的对话记录会显示在这里
</div>
) : null}
</div>
);
}
function OnlineAgentSetupView({ theme, activeModel, onOpenConfig }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; activeModel: string; onOpenConfig: () => void }) {
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div>
<div className="text-base font-semibold leading-6">连接配置</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
网站 Agent 直接使用当前网页配置的文本模型和 API。
</div>
</div>
<div className="rounded-md p-3" style={{ background: theme.spatial.surface }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-5">文本模型</div>
<div className="mt-1 truncate text-xs leading-5" style={{ color: theme.node.muted }}>
{activeModel || "未配置模型"}
</div>
</div>
<Button className="!h-8 !px-3" type="primary" icon={<Settings2 className="size-4" />} onClick={onOpenConfig}>
配置
</Button>
</div>
</div>
</div>
</div>
);
}
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<HTMLTextAreaElement>(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 (
<div className="flex min-h-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Segmented size="small" value={mode} onChange={(value) => setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: theme.node.muted }}>{logs.length} 条</span>
<Button size="small" icon={<Copy className="size-3.5" />} disabled={!logs.length} onClick={() => void copy()}>复制</Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatOnlineLogText([lastError], context))}>最近错误</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>清空</Button>
</div>
</div>
<textarea
ref={textareaRef}
readOnly
value={content}
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-md border-0 p-3 font-mono text-xs leading-5 outline-none"
style={{ background: theme.spatial.surface, color: theme.node.text }}
onFocus={(event) => event.currentTarget.select()}
/>
</div>
);
}
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
return (
<div className={`flex max-w-[88%] flex-wrap gap-2 ${message.role === "user" ? "ml-auto justify-end" : "ml-11 justify-start"}`}>
{message.references?.map((item, index, references) => (
<AssistantReferenceChip key={item.id} item={item} label={assistantImageReferenceLabel(references, index)} />
))}
</div>
);
}
function AssistantReferenceChip({ item, label, onRemove }: { item: CanvasAssistantReference; label?: string; onRemove?: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
return (
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
{item.dataUrl ? (
<span className="relative block size-8 shrink-0">
<img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" />
{label ? <span className="absolute left-0.5 top-0.5 rounded bg-black/60 px-1 py-0.5 text-[var(--fs-micro)] font-medium leading-none text-white">{label}</span> : null}
</span>
) : (
<span className="grid size-8 place-items-center rounded-md text-sm font-medium" style={{ background: theme.spatial.surface }}>
{text}
</span>
)}
{onRemove ? (
<button
type="button"
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}
onClick={onRemove}
aria-label="移除引用"
>
<X className="size-3" />
</button>
) : null}
</div>
);
}
function assistantImageReferenceLabel(references: CanvasAssistantReference[], index: number) {
if (!references[index]?.dataUrl) return undefined;
const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl).length - 1;
return imageIndex >= 0 ? imageReferenceLabel(imageIndex) : undefined;
}
function assistantMessageToChatMessage(message: CanvasAssistantMessage): CanvasAgentChatMessage {
return { id: message.id, role: message.role, title: message.title, text: message.text, meta: message.meta, detail: message.detail };
}
function formatSessionTime(value?: string) {
return value ? new Date(value).toLocaleString() : "";
}
function sessionPreview(session: CanvasAssistantSession) {
return session.messages.at(-1)?.text || `${session.messages.length} 条消息`;
}
function objectDetail(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function stringifyLog(value: unknown) {
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function formatOnlineLogText(logs: OnlineAgentLog[], context: OnlineAgentLogContext) {
const head = [
"影策网站 Agent 诊断日志",
`model: ${context.model || "none"}`,
`running: ${context.running}`,
`confirmTools: ${context.confirmTools}`,
`messages: ${context.messages}`,
`nodes: ${context.nodes}`,
`connections: ${context.connections}`,
`logs: ${logs.length}`,
].join("\n");
const body = logs.map((log, index) => [`#${index + 1} ${log.time} ${log.title}`, log.data === undefined ? "" : stringifyLog(log.data)].filter(Boolean).join("\n")).join("\n\n---\n\n");
return [head, body || "暂无事件日志"].join("\n\n");
}
function formatOnlineLogJson(logs: OnlineAgentLog[], context: OnlineAgentLogContext) {
return JSON.stringify({ context, logs: logs.map(({ time, title, data }) => ({ time, title, data })) }, null, 2);
}
function describeCanvasSnapshot(snapshot: CanvasAgentSnapshot) {
const counts = snapshot.nodes.reduce<Record<string, number>>((acc, node) => {
acc[node.type] = (acc[node.type] || 0) + 1;
return acc;
}, {});
return `当前画布有 ${snapshot.nodes.length} 个节点、${snapshot.connections.length} 条连线。背板 ${counts[CanvasNodeType.Frame] || 0} 个,文本 ${counts[CanvasNodeType.Text] || 0} 个,绘图 ${counts[CanvasNodeType.Drawing] || 0} 个,分镜脚本 ${counts[CanvasNodeType.Script] || 0} 个,技能 ${counts[CanvasNodeType.Skill] || 0} 个,图片 ${counts[CanvasNodeType.Image] || 0} 个,生成配置 ${counts[CanvasNodeType.Config] || 0} 个,视频 ${counts[CanvasNodeType.Video] || 0} 个,音频 ${counts[CanvasNodeType.Audio] || 0} 个。`;
}
function parseToolArguments(value: string) {
try {
const parsed = JSON.parse(value || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("工具参数必须是 JSON 对象");
return parsed as Record<string, unknown>;
} catch {
throw new Error("工具参数不是合法 JSON 对象");
}
}
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") {
const nodeType = requireNodeType(input.nodeType);
const x = numberOr(input.x, nextCanvasX(snapshot));
const y = numberOr(input.y, 0);
return [{ type: "add_node", nodeType, title: stringOptional(input.title), position: { x, y }, width: numberOptional(input.width), height: numberOptional(input.height), metadata: recordOptional(input.metadata) as CanvasNodeData["metadata"] }];
}
if (name === "canvas_create_text_node") return [textNodeOp(input, numberOr(input.x, nextCanvasX(snapshot)), numberOr(input.y, 0))];
if (name === "canvas_create_text_nodes") {
const items = requireRecordArray(input.items, "items");
const x = numberOr(input.x, nextCanvasX(snapshot));
const y = numberOr(input.y, 0);
const gap = numberOr(input.gap, 40);
const direction = input.direction === "row" ? "row" : "column";
return items.map((item, index) => textNodeOp({ ...item, text: requireString(item.text, "text") }, numberOr(item.x, direction === "row" ? x + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + gap) : x), numberOr(item.y, direction === "row" ? y : y + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].height + gap))));
}
if (name === "canvas_create_image_prompt_flow") return generationFlowOps({ ...input, mode: "image" }, snapshot, config);
if (name === "canvas_create_generation_flow") return generationFlowOps(input, snapshot, config);
if (name === "canvas_generate_text") return generationFlowOps({ ...input, mode: "text", autoRun: true }, snapshot, config);
if (name === "canvas_generate_image") return generationFlowOps({ ...input, mode: "image", autoRun: true }, snapshot, config);
if (name === "canvas_generate_video") return generationFlowOps({ ...input, mode: "video", autoRun: true }, snapshot, config);
if (name === "canvas_generate_audio") return generationFlowOps({ ...input, mode: "audio", autoRun: true }, snapshot, config);
if (name === "canvas_update_node") return [{ type: "update_node", id: requireString(input.id, "id"), patch: recordOptional(input.patch) as Partial<CanvasNodeData> | undefined, metadata: recordOptional(input.metadata) as CanvasNodeData["metadata"] }];
if (name === "canvas_update_node_text") return [{ type: "update_node", id: requireString(input.id, "id"), patch: stringOptional(input.title) ? { title: stringOptional(input.title) } : undefined, metadata: { content: requireString(input.text, "text"), status: "success" } }];
if (name === "canvas_move_nodes") {
return requireRecordArray(input.items, "items").map((item) => {
const id = requireString(item.id, "id");
const current = snapshot.nodes.find((node) => node.id === id);
return { type: "update_node", id, patch: { position: { x: numberOr(item.x, (current?.position.x || 0) + numberOr(item.dx, 0)), y: numberOr(item.y, (current?.position.y || 0) + numberOr(item.dy, 0)) } } };
});
}
if (name === "canvas_resize_node") return [{ type: "update_node", id: requireString(input.id, "id"), patch: { width: requireNumber(input.width, "width"), height: requireNumber(input.height, "height") }, metadata: typeof input.freeResize === "boolean" ? { freeResize: input.freeResize } : undefined }];
if (name === "canvas_delete_nodes") return [{ type: "delete_node", ids: requireStringArray(input.ids, "ids") }];
if (name === "canvas_connect_nodes") return requireRecordArray(input.connections, "connections").map((connection) => ({ type: "connect_nodes", fromNodeId: requireString(connection.fromNodeId, "fromNodeId"), toNodeId: requireString(connection.toNodeId, "toNodeId") }));
if (name === "canvas_select_nodes") return [{ type: "select_nodes", ids: requireStringArray(input.ids, "ids") }];
if (name === "canvas_set_viewport") return [{ type: "set_viewport", viewport: requireViewport(input.viewport) }];
if (name === "canvas_run_generation") return [runGenerationOp(requireString(input.nodeId, "nodeId"), generationMode(input.mode), stringOptional(input.prompt))];
throw new Error(`不支持的工具:${name}`);
}
function generationFlowOps(input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
const mode = generationMode(input.mode);
const prompt = requireString(input.prompt, "prompt");
const x = numberOr(input.x, nextCanvasX(snapshot));
const y = numberOr(input.y, 0);
const textId = `text-${nanoid()}`;
const targetId = `${mode}-${nanoid()}`;
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
return [
textNodeOp({ id: textId, text: prompt, title: stringOptional(input.title) || "提示词" }, x, y),
generationTargetNodeOp(targetId, { ...input, prompt: tokens.join("\n") }, x + NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + 80, y, config),
{ type: "connect_nodes", fromNodeId: textId, toNodeId: targetId },
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes" as const, fromNodeId, toNodeId: targetId })),
{ type: "select_nodes", ids: [targetId] },
...(input.autoRun ? [runGenerationOp(targetId, mode, tokens.join("\n"))] : []),
];
}
function textNodeOp(input: Record<string, unknown>, x: number, y: number): CanvasAgentOp {
return { type: "add_node", id: stringOptional(input.id), nodeType: CanvasNodeType.Text, title: stringOptional(input.title), position: { x, y }, width: numberOptional(input.width), height: numberOptional(input.height), metadata: { content: stringOptional(input.text), status: "success", fontSize: 14 } };
}
function generationTargetNodeOp(id: string, input: Record<string, unknown>, x: number, y: number, config: AiConfig): CanvasAgentOp {
const mode = generationMode(input.mode);
const prompt = stringOptional(input.prompt);
const nodeType = generationNodeType(mode);
return {
type: "add_node",
id,
nodeType,
title: stringOptional(input.title) || generationTitle(mode),
position: { x, y },
width: numberOptional(input.width),
height: numberOptional(input.height),
metadata: cleanRecord({
content: "",
fontSize: nodeType === CanvasNodeType.Text ? 14 : undefined,
generationMode: mode,
composerContent: prompt,
prompt,
status: "idle",
model: resolveGenerationModel(config, mode, stringOptional(input.model)),
size: stringOptional(input.size) || config.size,
quality: stringOptional(input.quality) || config.quality,
transparentBackground: stringOptional(input.transparentBackground) || config.transparentBackground,
count: numberOptional(input.count) ?? generationCount(mode === "image" ? config.canvasImageCount || config.count : config.count),
seconds: stringOptional(input.seconds) || config.videoSeconds,
vquality: stringOptional(input.vquality) || config.vquality,
generateAudio: stringOptional(input.generateAudio) || config.videoGenerateAudio,
watermark: stringOptional(input.watermark) || config.videoWatermark,
audioVoice: stringOptional(input.audioVoice) || config.audioVoice,
audioFormat: stringOptional(input.audioFormat) || config.audioFormat,
audioSpeed: stringOptional(input.audioSpeed) || config.audioSpeed,
audioInstructions: stringOptional(input.audioInstructions) || config.audioInstructions,
}) as CanvasNodeData["metadata"],
};
}
function generationNodeType(mode: "text" | "image" | "video" | "audio") {
if (mode === "text") return CanvasNodeType.Text;
if (mode === "video") return CanvasNodeType.Video;
if (mode === "audio") return CanvasNodeType.Audio;
return CanvasNodeType.Image;
}
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string): CanvasAgentOp {
return { type: "run_generation", nodeId, mode, prompt };
}
function isWritableToolCall(call: ResponseToolCall) {
return !ONLINE_READ_TOOLS.has(call.function.name);
}
function toolCallsFromDetail(detail: Record<string, unknown>): ResponseToolCall[] {
return Array.isArray(detail.toolCalls) ? (detail.toolCalls.filter(isResponseToolCall) as ResponseToolCall[]) : [];
}
function isResponseToolCall(value: unknown): value is ResponseToolCall {
const item = objectDetail(value);
const fn = objectDetail(item.function);
return typeof item.id === "string" && item.type === "function" && typeof fn.name === "string" && typeof fn.arguments === "string";
}
function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) };
}
function summarizeToolCalls(calls: ResponseToolCall[]) {
return calls.map((call) => toolCallLabel(call.function.name)).join(",") || "工具调用";
}
function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOperationImpact {
const ops: CanvasAgentOp[] = [];
let deferredCinematicCount = 0;
calls.filter(isWritableToolCall).forEach((call) => {
if (call.function.name === "canvas_create_cinematic_session") {
deferredCinematicCount += 1;
return;
}
try {
ops.push(...onlineToolToOps(call.function.name, parseToolArguments(call.function.arguments), snapshot, config));
} catch {
// 参数错误会在真正执行时显式失败;预览阶段只展示可确定的影响。
}
});
const impact = previewCanvasAgentOps(ops, snapshot);
if (!deferredCinematicCount) return impact;
return {
...impact,
operationCount: impact.operationCount + deferredCinematicCount,
items: [...impact.items, "启动影视 Agent,会话完成后将剧本、分镜和生成节点写回当前画布"].slice(0, 8),
warning: [impact.warning, "影视 Agent 的具体写回范围将在后端完成拆解后确定。"].filter(Boolean).join(" "),
};
}
function toolCallLabel(name: string) {
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_cinematic_session") return "创建影视项目";
if (name === "canvas_create_node") return "创建节点";
if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_text_nodes") return "批量创建文本";
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
if (name === "canvas_create_generation_flow") return "创建生成流程";
if (name === "canvas_generate_text") return "生成文本";
if (name === "canvas_generate_image") return "生成图片";
if (name === "canvas_generate_video") return "生成视频";
if (name === "canvas_generate_audio") return "生成音频";
if (name === "canvas_update_node") return "更新节点";
if (name === "canvas_update_node_text") return "更新文本";
if (name === "canvas_move_nodes") return "移动节点";
if (name === "canvas_resize_node") return "调整节点尺寸";
if (name === "canvas_delete_nodes") return "删除节点";
if (name === "canvas_connect_nodes") return "连接节点";
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
return name;
}
function toolResultText(result: OnlineToolResult) {
return result.message;
}
function requireStringArray(value: unknown, field: string): string[] {
if (!Array.isArray(value)) throw new Error(`${field} 必须是字符串数组`);
if (!value.every((item) => typeof item === "string" && Boolean(item))) throw new Error(`${field} 必须只包含非空字符串`);
return value as string[];
}
function requireOps(value: unknown): CanvasAgentOp[] {
if (!Array.isArray(value)) throw new Error("ops 必须是数组");
return value.map(toCanvasAgentOp);
}
function toCanvasAgentOp(value: unknown): CanvasAgentOp {
const item = objectDetail(value);
const type = item.type;
if (type === "add_node") {
return {
type,
id: stringOptional(item.id),
nodeType: item.nodeType ? requireNodeType(item.nodeType) : undefined,
title: stringOptional(item.title),
position: recordOptional(item.position) ? { x: requireNumber(objectDetail(item.position).x, "position.x"), y: requireNumber(objectDetail(item.position).y, "position.y") } : undefined,
x: numberOptional(item.x),
y: numberOptional(item.y),
width: numberOptional(item.width),
height: numberOptional(item.height),
metadata: recordOptional(item.metadata) as CanvasNodeData["metadata"],
};
}
if (type === "update_node") return { type, id: requireString(item.id, "id"), patch: recordOptional(item.patch) as Partial<CanvasNodeData> | undefined, metadata: recordOptional(item.metadata) as CanvasNodeData["metadata"] };
if (type === "delete_node") return { type, id: stringOptional(item.id), ids: Array.isArray(item.ids) ? requireStringArray(item.ids, "ids") : undefined };
if (type === "delete_connections") return { type, id: stringOptional(item.id), ids: Array.isArray(item.ids) ? requireStringArray(item.ids, "ids") : undefined, all: typeof item.all === "boolean" ? item.all : undefined };
if (type === "connect_nodes") return { type, id: stringOptional(item.id), fromNodeId: requireString(item.fromNodeId, "fromNodeId"), toNodeId: requireString(item.toNodeId, "toNodeId") };
if (type === "set_viewport") return { type, viewport: requireViewport(item.viewport) };
if (type === "select_nodes") return { type, ids: requireStringArray(item.ids, "ids") };
if (type === "run_generation") return { type, nodeId: requireString(item.nodeId, "nodeId"), mode: generationMode(item.mode), prompt: stringOptional(item.prompt) };
throw new Error("不支持的画布操作类型");
}
function requireRecordArray(value: unknown, field: string): Record<string, unknown>[] {
if (!Array.isArray(value)) throw new Error(`${field} 必须是数组`);
return value.map((item) => {
const record = objectDetail(item);
if (!Object.keys(record).length) throw new Error(`${field} 必须只包含对象`);
return record;
});
}
function requireString(value: unknown, field: string) {
if (typeof value !== "string" || !value) throw new Error(`${field} 必须是非空字符串`);
return value;
}
function requireNumber(value: unknown, field: string) {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${field} 必须是数字`);
return value;
}
function requireNodeType(value: unknown): CanvasNodeType {
if (Object.values(CanvasNodeType).includes(value as CanvasNodeType)) return value as CanvasNodeType;
throw new Error("节点类型必须是 text、image、config、video 或 audio");
}
function requireViewport(value: unknown) {
const item = objectDetail(value);
return { x: requireNumber(item.x, "viewport.x"), y: requireNumber(item.y, "viewport.y"), k: requireNumber(item.k, "viewport.k") };
}
function recordOptional(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function stringOptional(value: unknown) {
return typeof value === "string" ? value : "";
}
function numberOptional(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function numberOr(value: unknown, fallback: number) {
return numberOptional(value) ?? fallback;
}
function nextCanvasX(snapshot: CanvasAgentSnapshot) {
return snapshot.nodes.length ? Math.max(...snapshot.nodes.map((node) => node.position.x + node.width)) + 80 : 0;
}
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
return value === "text" || value === "video" || value === "audio" ? value : "image";
}
function generationTitle(mode: "text" | "image" | "video" | "audio") {
if (mode === "text") return "文本生成";
if (mode === "video") return "视频生成";
if (mode === "audio") return "音频生成";
return "图片生成";
}
function defaultGenerationModel(config: AiConfig, mode: "text" | "image" | "video" | "audio") {
if (mode === "image") return config.imageModel || config.model;
if (mode === "video") return config.videoModel || config.model;
if (mode === "audio") return config.audioModel || config.model;
return config.textModel || config.model;
}
function resolveGenerationModel(config: AiConfig, mode: "text" | "image" | "video" | "audio", model?: string) {
const normalized = normalizeModelOptionValue(model, config.channels);
return normalized && selectableModelsByCapability(config, mode).includes(normalized) ? normalized : defaultGenerationModel(config, mode);
}
function generationCount(value: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1)));
}
function cleanRecord(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
}
function snapshotSignature(snapshot: CanvasAgentSnapshot) {
return JSON.stringify({ nodes: snapshot.nodes, connections: snapshot.connections, selectedNodeIds: snapshot.selectedNodeIds, viewport: snapshot.viewport });
}
function explainNoop(ops: CanvasAgentOp[], snapshot: CanvasAgentSnapshot) {
if (!ops.length) return "模型没有返回可执行的画布操作。";
const nodeIds = new Set(snapshot.nodes.map((node) => node.id));
const connectionIds = new Set(snapshot.connections.map((conn) => conn.id));
const deleteConnectionOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_connections" }> => op.type === "delete_connections");
const connectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "connect_nodes" }> => op.type === "connect_nodes");
const deleteNodeOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_node" }> => op.type === "delete_node");
const updateOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "update_node" }> => op.type === "update_node");
const selectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "select_nodes" }> => op.type === "select_nodes");
const generationOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation");
if (deleteConnectionOps.length && !snapshot.connections.length) return "画布当前没有连线可删除。";
if (deleteConnectionOps.length && deleteConnectionOps.every((op) => !op.all && [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !connectionIds.has(id)))) return "没有找到要删除的连线。";
if (connectOps.length && connectOps.every((op) => snapshot.connections.some((conn) => conn.fromNodeId === op.fromNodeId && conn.toNodeId === op.toNodeId))) return "这些节点已经存在对应连线,无需重复连接。";
if (connectOps.length && connectOps.every((op) => !nodeIds.has(op.fromNodeId) || !nodeIds.has(op.toNodeId))) return "没有找到要连接的节点。";
if (deleteNodeOps.length && deleteNodeOps.every((op) => op.nodeType === CanvasNodeType.Config) && !snapshot.nodes.some((node) => node.type === CanvasNodeType.Config)) return "画布当前没有生成配置节点可删除。";
if (deleteNodeOps.length && deleteNodeOps.every((op) => [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !nodeIds.has(id)))) return "没有找到要删除的节点。";
if (updateOps.length && updateOps.every((op) => !nodeIds.has(op.id))) return "没有找到要更新的节点。";
if (selectOps.length && selectOps.every((op) => !(op.ids || []).some((id) => nodeIds.has(id)))) return "没有找到要选择的节点。";
if (generationOps.length && generationOps.every((op) => !nodeIds.has(op.nodeId))) return "没有找到要触发生成的节点。";
if (ops.every((op) => op.type === "set_viewport")) return "视图已经是目标状态。";
if (selectOps.length && selectOps.every((op) => JSON.stringify(op.ids || []) === JSON.stringify(snapshot.selectedNodeIds))) return "选区已经是目标状态。";
return "工具已执行,但画布状态没有变化;请在日志 tab 查看工具参数和执行前后状态。";
}
function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) {
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
}
if (node.type === CanvasNodeType.Text && node.metadata?.content) {
return { id: node.id, type: node.type, title: node.title, text: node.metadata.content };
}
if (node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot) {
return { id: node.id, type: node.type, title: node.title, text: [node.metadata.skillSnapshot.name, node.metadata.skillSnapshot.template, node.metadata.skillSnapshot.outputContract].filter(Boolean).join("\n\n") };
}
return null;
}
function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<string>) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
return Array.from(selectedNodeIds)
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node))
.map(nodeToReference)
.filter((item): item is CanvasAssistantReference => Boolean(item));
}
async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage): Promise<ResponseInputMessage[]> {
const refs = userMessage.references || [];
return [
{ role: "system", content: ONLINE_AGENT_PROMPT },
...history
.filter((message) => message.role === "user" || message.role === "assistant" || message.role === "system")
.slice(-8)
.map((message): ResponseInputMessage => ({ role: message.role as "system" | "user" | "assistant", content: message.text })),
{
role: "user",
content: [
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: `选中节点 ${item.title}:${item.text}` }] : [])),
{ type: "text", text: `当前画布:${JSON.stringify(compactSnapshot(snapshot))}\n\n用户需求:${userMessage.text}` },
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
],
},
];
}
function compactSnapshot(snapshot: CanvasAgentSnapshot) {
return {
title: snapshot.title,
viewport: snapshot.viewport,
selectedNodeIds: snapshot.selectedNodeIds,
nodes: snapshot.nodes.map((node) => ({
id: node.id,
type: node.type,
title: node.title,
position: node.position,
width: node.width,
height: node.height,
metadata: compactMetadata(node.metadata || {}),
})),
connections: snapshot.connections,
};
}
function compactMetadata(metadata: CanvasNodeData["metadata"]) {
return {
content: String(metadata?.content || "").slice(0, 500),
prompt: String(metadata?.prompt || metadata?.composerContent || "").slice(0, 500),
status: metadata?.status,
skillName: metadata?.skillSnapshot?.name,
skillVersion: metadata?.skillSnapshot?.version,
generationMode: metadata?.generationMode,
model: metadata?.model,
size: metadata?.size,
assetTags: metadata?.assetTags,
workflowKind: metadata?.workflowKind,
workflowTitle: metadata?.workflowTitle,
workflowDescription: metadata?.workflowDescription,
characterName: metadata?.characterName,
characterAssetId: metadata?.characterAssetId,
characterVersionId: metadata?.characterVersionId,
chapterId: metadata?.chapterId,
chapterTitle: metadata?.chapterTitle,
shotIndex: metadata?.shotIndex,
};
}
function backendAgentProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
return {
apiFormat: config.apiFormat,
interfaceType: config.interfaceType,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
secretKey: config.secretKey,
model: config.model,
size: config.size,
quality: config.quality,
transparentBackground: config.transparentBackground,
count: config.count,
videoSeconds: config.videoSeconds,
vquality: config.vquality,
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
systemPrompt: config.systemPrompt,
};
}
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 };
}