Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-assistant-panel.tsx
T

2018 lines
116 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 { Button, Modal, Segmented, Select } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState } from "react";
import copyToClipboard from "copy-to-clipboard";
import { Copy, Cpu, Settings2, Trash2, X } from "lucide-react";
import { motion } from "motion/react";
import { modelDisplayName, modelIcon, normalizeModelOptionValue, resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { nanoid } from "nanoid";
import { type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
import { runBackendToolGenerationTask } from "@/services/api/generation-task";
import { imageToDataUrl } from "@/services/image-storage";
import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe";
import { activeGenerationConsumerController } from "@/services/generation-consumer-lifecycle";
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 { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
import { AgentChatComposer, AgentChatMessage, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { ModelLogo } from "@/components/model-logo";
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 { canvasAgentPostconditionMessage, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { buildSkillMentionReferences, SKILL_RUNTIME_AGENT_GUIDANCE, skillRuntime } from "@/services/skill-runtime";
export const CANVAS_AGENT_PANEL_MOTION_MS = 500;
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
const ONLINE_AGENT_MAX_STEPS = 8;
const ONLINE_AGENT_PROMPT =
`你是当前创作工作台内置的在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。${SKILL_RUNTIME_AGENT_GUIDANCE}`;
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 WORKFLOW_NODE_KIND_SCHEMA = { type: "string", enum: ["text", "script", "image", "video", "audio", "character_cards", "character_three_view", "storyboard_video"] };
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" },
retry: { type: "boolean" },
},
required: ["type"],
additionalProperties: false,
};
const ONLINE_READ_TOOLS = new Set([...skillRuntime.agentToolNames("onlineAgent"), "canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_node", "canvas_get_connection", "canvas_get_generation_tasks", "canvas_get_resources", "canvas_validate_ops", "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[] = [
...skillRuntime.agentTools("onlineAgent"),
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}),
toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }),
toolDefinition("canvas_get_node", "按真实节点 id 精确读取单个节点、资源状态和关联连线。", { id: { type: "string" } }, ["id"]),
toolDefinition("canvas_get_connection", "按真实连线 id 精确读取端点节点和 handle 信息。", { id: { type: "string" } }, ["id"]),
toolDefinition("canvas_get_generation_tasks", "读取当前画布绑定的生成任务观察状态,不主动轮询上游。", { status: { type: "string" }, nodeIds: { type: "array", items: { type: "string" } }, limit: { type: "number" } }),
toolDefinition("canvas_get_resources", "读取画布媒体资源引用、类型、尺寸、大小、时长和就绪状态,不返回媒体 URL。", { nodeIds: { type: "array", items: { type: "string" } }, status: { type: "string" }, limit: { type: "number" } }),
toolDefinition("canvas_validate_ops", "在写入前校验节点 id、连接关系和批量操作参数。", { ops: { type: "array", items: CANVAS_OP_SCHEMA } }, ["ops"]),
toolDefinition("canvas_get_selection", "读取当前网页画布选中的节点。", {}),
toolDefinition("canvas_export_snapshot", "导出当前画布快照,用于理解布局。", {}),
toolDefinition(
"canvas_apply_ops",
"批量操作当前网页画布。复杂写操作应先 canvas_validate_ops;可传 canvas_get_context 返回的 expectedStateHash 防止基于过期状态写入。",
{ ops: { type: "array", items: CANVAS_OP_SCHEMA }, expectedRevision: { type: "number" }, expectedStateHash: { type: "string" } },
["ops"],
false,
),
toolDefinition(
"canvas_create_workflow",
"创建语义化工作流/流水线:节点使用真实的文本、脚本、图片、视频或音频类型;character_cards=角色拆分图片卡片,character_three_view=角色三视图,storyboard_video=分镜剧情视频。工具会自动生成唯一 id、按节点实际尺寸布局、创建 edges/referenceRefs/referenceNodeIds 连线、选择新节点并复核重叠。媒体节点必须提供有意义的 prompt 或 content;已有素材先 canvas_find_nodes/canvas_get_resources,再把真实 node id 放入 referenceNodeIds。不要用 canvas_create_text_nodes 代替工作流。",
{
title: { type: "string" },
description: { type: "string" },
nodes: {
type: "array",
minItems: 1,
items: {
type: "object",
properties: {
ref: { type: "string" },
kind: WORKFLOW_NODE_KIND_SCHEMA,
title: { type: "string" },
content: { type: "string" },
prompt: { type: "string" },
description: { type: "string" },
referenceRefs: { type: "array", items: { type: "string" } },
referenceNodeIds: { type: "array", items: { type: "string" } },
runGeneration: { type: "boolean" },
width: { type: "number" },
height: { type: "number" },
},
required: ["ref", "kind", "title"],
additionalProperties: false,
},
},
edges: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"], additionalProperties: false } },
direction: { type: "string", enum: ["horizontal", "vertical"] },
start: POSITION_SCHEMA,
gap: { type: "number" },
autoRun: { type: "boolean" },
},
["nodes"],
),
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", "触发指定节点生成;对已有生成任务明确重试时传 retry=true。", { nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" }, retry: { type: "boolean" } }, ["nodeId"]),
];
type OnlineAgentTab = "chat" | "history";
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[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise<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;
resizing?: boolean;
};
export type CinematicContinuationFailureDisposition = "abort" | "durable-ack" | "provider-failed";
type CinematicContinuationLiveSessionState = { sessions: CanvasAssistantSession[]; activeChatId: string | null };
type CanvasCinematicContinuationBoundaryInput<T> = {
projectId: string;
effectKey?: string;
signal?: AbortSignal;
readSnapshot: () => CanvasAgentSnapshot;
executeOps: () => Promise<T>;
completeSession: (effectKey?: string) => CanvasAssistantSession[];
readLiveSessionState: () => CinematicContinuationLiveSessionState;
restoreLiveSessions: (sessions: CanvasAssistantSession[], activeChatId: string | null) => void;
restoreLiveSnapshot: (state: Pick<CanvasAgentSnapshot, "nodes" | "connections">) => void;
failProvider: (error: unknown) => void;
onFailureDisposition?: (disposition: CinematicContinuationFailureDisposition, error: unknown) => void;
persistContinuation?: typeof persistCanvasCinematicSessionContinuationEffect;
};
export function handleCinematicContinuationFailure(error: unknown, failProvider: (error: unknown) => void): CinematicContinuationFailureDisposition {
if (isAgentSessionPollingAbort(error)) return "abort";
if (isCanvasGenerationDurableAckError(error)) return "durable-ack";
failProvider(error);
return "provider-failed";
}
export async function runCanvasCinematicContinuationBoundary<T>(input: CanvasCinematicContinuationBoundaryInput<T>) {
const previousSnapshot = input.readSnapshot();
const previousSessionState = input.readLiveSessionState();
try {
if (input.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
const result = await input.executeOps();
const attemptedSnapshot = input.readSnapshot();
input.completeSession(input.effectKey);
const attemptedSessionState = input.readLiveSessionState();
if (input.effectKey) {
await (input.persistContinuation ?? persistCanvasCinematicSessionContinuationEffect)({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes: previousSnapshot.nodes,
nodes: attemptedSnapshot.nodes,
previousConnections: previousSnapshot.connections,
connections: attemptedSnapshot.connections,
previousChatSessions: previousSessionState.sessions,
chatSessions: attemptedSessionState.sessions,
previousActiveChatId: previousSessionState.activeChatId,
activeChatId: attemptedSessionState.activeChatId,
signal: input.signal,
readLiveSessionState: input.readLiveSessionState,
restoreLiveSessions: input.restoreLiveSessions,
restoreLiveSnapshot: input.restoreLiveSnapshot,
});
}
return result;
} catch (error) {
const disposition = handleCinematicContinuationFailure(error, input.failProvider);
input.onFailureDisposition?.(disposition, error);
throw error;
}
}
export const canvasCinematicContinuationEntryAdapters = {
"online-tool": runCanvasCinematicContinuationBoundary,
"submit-cinematic": runCanvasCinematicContinuationBoundary,
"resume-cinematic": runCanvasCinematicContinuationBoundary,
} as const;
export function CanvasAssistantPanel({
nodes,
selectedNodeIds,
snapshot,
projectId,
sessions,
activeSessionId,
onSelectNodeIds,
onSessionsChange,
onApplyOps,
canUndoOps,
undoOpsCount,
onUndoOps,
onPasteImage,
agentMode,
onAgentModeChange,
autoConnectLocal,
closing,
onCollapse,
cinematicEntry = false,
onCinematicEntryConsumed,
resizing = false,
}: 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 [composerSkills, setComposerSkills] = useState<Skill[]>([]);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
const localSessionsRef = useRef(localSessions);
const [localActiveSessionId, setLocalActiveSessionIdState] = useState<string | null>(activeSessionId);
const localActiveSessionIdRef = useRef(localActiveSessionId);
const setLocalActiveSessionId = (activeId: string | null) => {
localActiveSessionIdRef.current = activeId;
setLocalActiveSessionIdState(activeId);
};
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>());
const generationConsumerControllerRef = useRef(new AbortController());
useEffect(() => {
let cancelled = false;
listAddedSkills()
.then((result) => {
if (!cancelled) setComposerSkills(result.skills || []);
})
.catch(() => {
if (!cancelled) setComposerSkills([]);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!sessions.length) return;
if (sessions === localSessions && activeSessionId === localActiveSessionId) return;
applyingExternalSessionsRef.current = true;
localSessionsRef.current = sessions;
setLocalSessions(sessions);
setLocalActiveSessionId(activeSessionId);
}, [activeSessionId, sessions]);
useEffect(() => {
snapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
generationConsumerControllerRef.current = activeGenerationConsumerController(generationConsumerControllerRef.current);
return () => {
// 收起面板或刷新页面时只停止前端查询,后台任务由下次挂载根据持久化 ID 继续接管。
cinematicSessionControllersRef.current.forEach((controller) => controller.abort());
cinematicSessionControllersRef.current.clear();
generationConsumerControllerRef.current.abort();
};
}, []);
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 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) => {
const next = localSessionsRef.current.map((session) => (session.id === sessionId ? updater(session) : session));
localSessionsRef.current = next;
setLocalSessions(next);
return next;
};
const readCinematicSessionState = (): CinematicContinuationLiveSessionState => ({ sessions: localSessionsRef.current, activeChatId: localActiveSessionIdRef.current });
const restoreCinematicSessions = (sessions: CanvasAssistantSession[], activeId: string | null) => {
localSessionsRef.current = sessions;
setLocalSessions(sessions);
setLocalActiveSessionId(activeId);
};
const restoreCinematicSnapshot = (state: Pick<CanvasAgentSnapshot, "nodes" | "connections">) => {
snapshotRef.current = { ...snapshotRef.current, nodes: state.nodes, connections: state.connections };
};
const hasAgentGenerationEffect = (sessionId: string, effectKey?: string) => {
const session = localSessionsRef.current.find((candidate) => candidate.id === sessionId);
return Boolean(session && generationEffectApplied(session, effectKey));
};
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, effectKey?: string) => {
return updateSession(sessionId, (session) => {
const pending = session.pendingBackendSession;
if (pending?.id !== backendSessionId) return session;
const completedAt = new Date().toISOString();
const summary = summarizeCanvasAgentOps(ops) || "影视项目已写回当前画布。";
const completed = {
...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,
};
return effectKey ? applyGenerationConsumerEffect(completed, effectKey, (current) => current).value : completed;
});
};
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))),
continuationTask: [...detail.tasks].reverse().find((task) => task.status === "succeeded"),
};
} 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);
}
void cleanupImages({ sessions: next });
};
const clearSessions = () => {
const session = createSession();
setLocalSessions([session]);
setLocalActiveSessionId(session.id);
void 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, composerSkills);
addOnlineLog(`Agent Tool Loop ${loop.step} 开始`, { toolChoice: "required" });
let streamed = "";
const result = await requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, messages, "required", userMessage.text, (text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
});
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) {
if (isAgentSessionPollingAbort(error)) return;
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 requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, nextMessages, "auto", "继续处理画布工具结果", (text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
});
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 = async (ops: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => {
const beforeSnapshot = snapshotRef.current;
const validation = validateCanvasAgentOps(beforeSnapshot, ops);
if (!validation.ok) {
throw new Error(`画布操作校验失败:${validation.issues.filter((item) => item.severity === "error").map((item) => item.message).join(";")}`);
}
const before = snapshotSignature(beforeSnapshot);
const next = await onApplyOps(ops, context);
snapshotRef.current = next;
const verification = verifyCanvasAgentOps(beforeSnapshot, next, ops);
const noopReason = verification.changed ? "" : explainNoop(ops, beforeSnapshot);
return { ...verification, verification, snapshot: next, ops, noopReason, before: JSON.parse(before), after: JSON.parse(snapshotSignature(next)) };
};
const executeOnlineTool = async (sessionId: string, name: string, args: Record<string, unknown>, messageId?: string): Promise<OnlineToolResult> => {
const current = snapshotRef.current;
try {
const expectedRevision = typeof args.expectedRevision === "number" ? args.expectedRevision : undefined;
if (expectedRevision !== undefined && expectedRevision !== (current.revision ?? 0)) return { ok: false, message: "画布 revision 已变化,请重新读取 canvas_get_context 后再执行写操作。" };
const expectedStateHash = typeof args.expectedStateHash === "string" ? args.expectedStateHash : "";
if (expectedStateHash && expectedStateHash !== buildCanvasAgentContext(current).stateHash) return { ok: false, message: "画布状态已变化,请重新读取 canvas_get_context 后再执行写操作。" };
const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, composerSkills);
if (skillToolResult) return skillToolResult;
if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
if (name === "canvas_get_context") return { ok: true, message: "已读取语义化画布上下文。", data: buildCanvasAgentContext(current) };
if (name === "canvas_find_nodes") return { ok: true, message: "已按条件检索真实节点。", data: findCanvasAgentNodes(current, args as Parameters<typeof findCanvasAgentNodes>[1]) };
if (name === "canvas_get_node") {
const data = getCanvasAgentNode(current, { id: requireString(args.id, "id") });
return { ok: true, message: data.found ? "已精确读取节点。" : "未找到指定节点。", data };
}
if (name === "canvas_get_connection") {
const data = getCanvasAgentConnection(current, { id: requireString(args.id, "id") });
return { ok: true, message: data.found ? "已精确读取连线。" : "未找到指定连线。", data };
}
if (name === "canvas_get_generation_tasks") return { ok: true, message: "已读取画布生成任务观察状态。", data: getCanvasAgentGenerationTasks(current, args as Parameters<typeof getCanvasAgentGenerationTasks>[1]) };
if (name === "canvas_get_resources") return { ok: true, message: "已读取画布资源清单。", data: getCanvasAgentResources(current, args as Parameters<typeof getCanvasAgentResources>[1]) };
if (name === "canvas_validate_ops") {
const result = validateCanvasAgentOps(current, requireOps(args.ops));
return { ok: result.ok, message: result.ok ? "操作校验通过。" : "操作校验失败。", data: result };
}
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);
let continuationResult: OnlineToolResult | undefined;
const applyContinuation = async ({ effectKey, signal }: { effectKey?: string; signal?: AbortSignal } = {}) => {
if (hasAgentGenerationEffect(sessionId, effectKey)) return;
const result = await canvasCinematicContinuationEntryAdapters["online-tool"]({
projectId,
effectKey,
signal,
readSnapshot: () => snapshotRef.current,
executeOps: () => executeOps(cinematic.ops),
completeSession: (key) => completeCinematicSession(sessionId, cinematic.backendSessionId, cinematic.ops, false, key),
readLiveSessionState: readCinematicSessionState,
restoreLiveSessions: restoreCinematicSessions,
restoreLiveSnapshot: restoreCinematicSnapshot,
failProvider: (failure) => failCinematicSession(sessionId, cinematic.backendSessionId, failure),
});
continuationResult = { ok: result.changed, message: result.changed ? summarizeCanvasAgentOps(cinematic.ops) || "后端影视 Agent 已写回画布。" : result.noopReason, data: result };
};
if (cinematic.continuationTask) {
await consumeGenerationTaskAgent(cinematic.continuationTask, cinematic.backendSessionId, applyContinuation, { signal: generationConsumerControllerRef.current.signal });
} else {
await applyContinuation();
}
return continuationResult ?? { ok: true, message: "后端影视 Agent 已完成。" };
}
const ops = onlineToolToOps(name, args, current, effectiveConfig);
const result = await executeOps(ops, { source: "online", conversationId: sessionId, messageId: messageId || sessionId });
const { snapshot: _snapshot, before: _before, after: _after, ...cleanData } = result;
return { ok: result.ok, message: result.changed ? canvasAgentPostconditionMessage(result) : result.noopReason, data: cleanData };
} 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), toolCall.id);
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) {
if (isAgentSessionPollingAbort(error)) return;
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);
};
const submitQuickAction = (text: string) => {
if (!text.trim() || agentBusy) return;
void sendMessage(text.trim(), 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 = "";
let continuationFailureDisposition: CinematicContinuationFailureDisposition | undefined;
try {
const cinematic = await runCinematicSession(session.id, value, snapshotRef.current, effectiveConfig, (createdId) => {
backendSessionId = createdId;
});
const applyContinuation = async ({ effectKey, signal }: { effectKey?: string; signal?: AbortSignal } = {}) => {
if (hasAgentGenerationEffect(session.id, effectKey)) return;
await canvasCinematicContinuationEntryAdapters["submit-cinematic"]({
projectId,
effectKey,
signal,
readSnapshot: () => snapshotRef.current,
executeOps: async () => {
const next = await onApplyOps(cinematic.ops);
snapshotRef.current = next;
return next;
},
completeSession: (key) => completeCinematicSession(session.id, cinematic.backendSessionId, cinematic.ops, false, key),
readLiveSessionState: readCinematicSessionState,
restoreLiveSessions: restoreCinematicSessions,
restoreLiveSnapshot: restoreCinematicSnapshot,
failProvider: (failure) => failCinematicSession(session.id, cinematic.backendSessionId, failure),
onFailureDisposition: (disposition) => {
continuationFailureDisposition = disposition;
},
});
setCinematicEntryActive(false);
};
if (cinematic.continuationTask) {
await consumeGenerationTaskAgent(cinematic.continuationTask, cinematic.backendSessionId, applyContinuation, { signal: generationConsumerControllerRef.current.signal });
} else {
await applyContinuation();
}
} catch (error) {
if (continuationFailureDisposition) return;
handleCinematicContinuationFailure(error, (failure) => {
if (backendSessionId) failCinematicSession(session.id, backendSessionId, failure);
else appendMessage(session.id, { id: nanoid(), role: "error", title: "影视项目生成失败", text: failure instanceof Error ? failure.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 });
let continuationFailureDisposition: CinematicContinuationFailureDisposition | undefined;
try {
const detail = await resumeCinematicAgentSession(pending.id, { signal: controller.signal });
const ops = requireOps(JSON.parse(cinematicAgentSessionOpsJson(detail)));
const continuationTask = [...detail.tasks].reverse().find((task) => task.status === "succeeded");
const applyContinuation = async ({ effectKey, signal }: { effectKey?: string; signal?: AbortSignal } = {}) => {
if (hasAgentGenerationEffect(sessionId, effectKey)) return;
await canvasCinematicContinuationEntryAdapters["resume-cinematic"]({
projectId,
effectKey,
signal,
readSnapshot: () => snapshotRef.current,
executeOps: () => executeOps(ops),
completeSession: (key) => completeCinematicSession(sessionId, pending.id, ops, true, key),
readLiveSessionState: readCinematicSessionState,
restoreLiveSessions: restoreCinematicSessions,
restoreLiveSnapshot: restoreCinematicSnapshot,
failProvider: (failure) => {
failCinematicSession(sessionId, pending.id, failure);
addOnlineLog("后端影视 Agent 会话恢复失败", failure instanceof Error ? failure.message : failure);
},
onFailureDisposition: (disposition, error) => {
continuationFailureDisposition = disposition;
if (disposition === "durable-ack") addOnlineLog("后端影视 Agent 会话持久化失败,保留待恢复状态", error instanceof Error ? error.message : error);
},
});
addOnlineLog("后端影视 Agent 会话恢复完成", { backendSessionId: pending.id });
};
if (continuationTask) {
await consumeGenerationTaskAgent(continuationTask, pending.id, applyContinuation, { signal: controller.signal });
} else {
await applyContinuation();
}
} catch (error) {
if (continuationFailureDisposition) return;
const disposition = handleCinematicContinuationFailure(error, (failure) => {
failCinematicSession(sessionId, pending.id, failure);
addOnlineLog("后端影视 Agent 会话恢复失败", failure instanceof Error ? failure.message : failure);
});
if (disposition === "durable-ack") 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 collapse = () => {
onCollapse();
};
const onlineContent = (
<>
{view === "history" ? (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<div className="text-xs font-medium" style={{ color: theme.node.muted }}>历史会话</div>
<Tooltip title="清空历史">
<Button type="text" shape="circle" className="!h-7 !w-7 !min-w-7" style={iconButtonStyle} icon={<X className="size-3.5" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} aria-label="清空历史会话" />
</Tooltip>
</div>
<AssistantHistory
sessions={historySessions}
activeSession={activeSession}
onOpen={(id) => {
setLocalActiveSessionId(id);
setView("chat");
}}
onDelete={(id) => setDeleteChatIds([id])}
/>
</div>
) : (
<div ref={chatListRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
{messages.length ? (
<>
{messages.map((message) => (
<div key={message.id} className="space-y-2">
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} isStreaming={agentBusy && message.id === messages.at(-1)?.id && message.role === "assistant"} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} onQuickAction={submitQuickAction} />
{message.references?.length ? <MessageReferences message={message} /> : null}
</div>
))}
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<AgentChatEmptyState
theme={theme}
nodeCount={contextSummary.nodeCount}
onSelect={(text) => {
setPrompt(text);
void sendMessage(text, messages);
}}
/>
)}
</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}
references={buildSkillMentionReferences(composerSkills)}
slashSkills={composerSkills}
onPromptChange={setPrompt}
onSubmit={cinematicEntryActive ? () => submitCinematicProject(prompt) : submit}
onAddFiles={addImagesToCanvas}
left={
<>
<VoiceRecordingButton disabled={agentBusy} onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))} />
<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>
</>
);
return (
<motion.aside
className="pointer-events-auto relative flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]"
initial={{ x: 48, opacity: 0 }}
animate={{ x: closing ? 28 : 0, opacity: closing ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{
background: theme.spatial.elevated,
color: theme.node.text,
boxShadow: `-18px 0 48px ${theme.spatial.shadow}, 0 24px 72px ${theme.spatial.shadow}`,
}}
>
<AgentPanelChrome
theme={theme}
mode={agentMode}
context={contextSummary}
referenceCount={selectedReferences.length}
confirmTools={confirmTools}
canUndo={agentMode === "online" ? canUndoOps : false}
undoCount={agentMode === "online" ? undoOpsCount : 0}
onModeChange={onAgentModeChange}
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
onUndo={undoLastOnlineBatch}
onCollapse={collapse}
historyCount={agentMode === "online" ? historySessions.length : 0}
historyActive={agentMode === "online" && view === "history"}
onOpenHistory={agentMode === "online" ? () => setView((current) => current === "history" ? "chat" : "history") : undefined}
onNewChat={agentMode === "online" ? () => { startChatSession(); setView("chat"); } : undefined}
newChatDisabled={false}
/>
{agentMode === "local" ? <CanvasLocalAgentPanel embedded snapshot={snapshot} canUndoOps={canUndoOps} undoOpsCount={undoOpsCount} onApplyOps={onApplyOps} onUndoOps={onUndoOps} autoConnect={autoConnectLocal} /> : onlineContent}
</motion.aside>
);
}
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: agentModelLabel(config, model) }))}
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 config={config} model={model} />
<span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span>
{agentModelSource(config, model) ? <span className="shrink-0 text-xs opacity-55">{agentModelSource(config, model)}</span> : null}
</span>
);
}}
labelRender={() => (
<span className="flex min-w-0 items-center gap-1.5">
<AgentModelIcon config={config} model={current} />
<span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>
{current && agentModelSource(config, current) ? <span className="shrink-0 opacity-55">{agentModelSource(config, current)}</span> : null}
</span>
)}
onChange={onChange}
aria-label="选择 Agent 文本模型"
title={current ? agentModelLabel(config, current) : "选择文本模型"}
/>
</div>
);
}
function agentModelSource(config: AiConfig, model: string) {
const channel = resolveModelChannel(config, model);
return channel.scope === "system" ? "" : channel.name;
}
function agentModelLabel(config: AiConfig, model: string) {
const source = agentModelSource(config, model);
return source ? `${modelDisplayName(config, model)} · ${source}` : modelDisplayName(config, model);
}
function AgentModelIcon({ config, model }: { config: AiConfig; model: string }) {
const icon = modelIcon(config, model);
return icon ? <span className="inline-flex size-4 shrink-0 items-center justify-center"><ModelLogo icon={icon} size={16} /></span> : <Cpu className="size-4 shrink-0 opacity-70" />;
}
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 对象");
}
}
export 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_workflow") return buildCanvasWorkflowOps(input as unknown as CanvasWorkflowInput, snapshot, config);
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 textBatch = items.map((item) => `${String(item.title || "")} ${String(item.text || "")}`).join(" ");
if (looksLikeWorkflowRequest(textBatch)) throw new Error("检测到流水线/工作流意图,请使用 canvas_create_workflow 创建真实类型节点和连线。" );
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), input.retry === true)];
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 promptNode: CanvasNodeData = {
id: textId,
type: CanvasNodeType.Text,
title: stringOptional(input.title) || "提示词",
position: { x, y },
width: NODE_DEFAULT_SIZE[CanvasNodeType.Text].width,
height: NODE_DEFAULT_SIZE[CanvasNodeType.Text].height,
metadata: { content: prompt },
};
const referenceNodes = referenceNodeIds.flatMap((id) => {
const node = snapshot.nodes.find((candidate) => candidate.id === id);
return node ? [node] : [];
});
const tokens = buildOrderedCanvasResourceReferences([promptNode, ...referenceNodes]).map(canvasResourceMentionToken);
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, retry?: boolean): CanvasAgentOp {
return { type: "run_generation", nodeId, mode, prompt, ...(retry ? { retry: true } : {}) };
}
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 } : {}) };
}
// 上游兼容兜底:DeepSeek 等 OpenAI 兼容渠道的思考/推理模式不允许
// tool_choice=required(后端归类文案见 providerPayloadErrorCategory)。
// required 只用于对话首步把智能体拖入工具循环;遇到该类拒绝时降级为 auto
// 重试一次——auto 在思考模式下可用,对话与工具调用均不受影响。
// 语义与 image.ts 对非 auto tool_choice 的兼容降级策略一致。
const THINKING_MODE_TOOL_CHOICE_REJECTION = "不支持强制工具调用";
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void) {
const submit = (choice: "auto" | "required") => runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice: choice, onDelta });
try {
return await submit(toolChoice);
} catch (error) {
if (toolChoice !== "required" || !(error instanceof Error) || !error.message.includes(THINKING_MODE_TOOL_CHOICE_REJECTION)) {
throw error;
}
return submit("auto");
}
}
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_list_skills") return "列出技能";
if (name === "canvas_get_skill") return "读取技能入口";
if (name === "canvas_list_skill_files") return "列出技能文件";
if (name === "canvas_read_skill_file") return "读取技能文件";
if (name === "canvas_search_skill_files") return "搜索技能文件";
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_context") return "读取上下文";
if (name === "canvas_find_nodes") return "检索节点";
if (name === "canvas_get_node") return "读取节点";
if (name === "canvas_get_connection") return "读取连线";
if (name === "canvas_get_generation_tasks") return "读取生成任务";
if (name === "canvas_get_resources") return "读取资源";
if (name === "canvas_validate_ops") return "校验操作";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_cinematic_session") return "创建影视项目";
if (name === "canvas_create_workflow") 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), ...(item.retry === true ? { retry: true } : {}) };
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, skills: Skill[] = []): Promise<ResponseInputMessage[]> {
const refs = userMessage.references || [];
const skillCatalog = skills
.filter((skill) => skill.is_added)
.slice(0, 40)
.map((skill) => `- ${skill.skill_name}(${skill.skill_id},v${skill.version || "1"},${skill.file_count || 1} 文件):${skill.description}`)
.join("\n");
const systemContent = [ONLINE_AGENT_PROMPT, skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n");
return [
{ role: "system", content: systemContent },
...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 {
channelId: config.channelId,
apiFormat: config.apiFormat,
interfaceType: config.interfaceType,
baseUrl: config.baseUrl,
allowLocalChannel: config.allowLocalChannel === true,
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 };
}