From f69535c93f80f79b0a402fd52bbbeac45678ec05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BF=97=E4=BC=9F?= Date: Tue, 8 Sep 2026 20:56:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E5=88=9B=E4=BD=9C=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=20-=20=E6=8E=A5=E9=80=9A=E4=B8=93=E4=B8=9A=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=8F=91=E7=8E=B0=E4=B8=8E=E6=8F=92=E4=BB=B6=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/plugins/ai-art-critique.mdx | 2 + docs/design/agent-capabilities.mdx | 58 +++++++++++++++ .../art-critique/ai-art-critique-modal.tsx | 58 +++++++-------- .../canvas/canvas-assistant-panel.tsx | 71 ++++++++++++++++--- .../canvas/canvas-style-picker-modal.tsx | 2 +- web/src/lib/art-critique/contracts.ts | 1 + web/src/lib/art-critique/pipeline.ts | 7 +- web/src/lib/canvas/canvas-agent-context.ts | 4 ++ web/src/lib/canvas/canvas-agent-ops.ts | 21 +++--- web/src/lib/canvas/canvas-agent-storyboard.ts | 52 ++++++++++++++ web/src/lib/canvas/canvas-agent-workflow.ts | 28 ++++++-- .../lib/canvas/node-registry/node-registry.ts | 6 ++ web/src/lib/creation/creative-agent-state.ts | 12 ++-- web/src/lib/creation/creative-agent-tools.ts | 4 +- web/src/lib/plugins/analysis-node-action.ts | 24 +++++++ .../lib/plugins/builtin/ai-art-critique.ts | 14 +++- .../lib/plugins/builtin/portrait-clearance.ts | 12 +++- web/src/lib/plugins/plugin-registry.ts | 5 ++ web/src/lib/plugins/plugin-types.ts | 11 +++ web/src/pages/canvas/project.tsx | 4 +- .../pages/canvas/use-canvas-style-workflow.ts | 42 ++++++----- web/src/services/agent-capabilities.ts | 52 ++++++++++++++ web/src/services/api/generation-task.ts | 5 +- web/src/services/art-critique-execution.ts | 52 ++++++++++++++ web/src/services/skill-runtime.ts | 12 ++-- web/test/agent-capabilities.test.ts | 41 +++++++++++ web/test/agent-storyboard.test.ts | 50 +++++++++++++ web/test/art-critique-execution.test.ts | 46 ++++++++++++ web/test/skill-runtime.test.ts | 9 +++ 29 files changed, 617 insertions(+), 88 deletions(-) create mode 100644 docs/design/agent-capabilities.mdx create mode 100644 web/src/lib/canvas/canvas-agent-storyboard.ts create mode 100644 web/src/lib/plugins/analysis-node-action.ts create mode 100644 web/src/services/agent-capabilities.ts create mode 100644 web/src/services/art-critique-execution.ts create mode 100644 web/test/agent-capabilities.test.ts create mode 100644 web/test/agent-storyboard.test.ts create mode 100644 web/test/art-critique-execution.test.ts diff --git a/docs/content/docs/plugins/ai-art-critique.mdx b/docs/content/docs/plugins/ai-art-critique.mdx index 15ea1d93d..a16657a94 100644 --- a/docs/content/docs/plugins/ai-art-critique.mdx +++ b/docs/content/docs/plugins/ai-art-critique.mdx @@ -7,6 +7,8 @@ description: 面向图片创作的 AI 视觉分析、问题定位和修改建议 > 状态:MVP 已实现,当前审美标准为 `2026-08-v1`。 +执行入口已统一到 `art-critique-execution`:手动分析各阶段通过后台文本任务提交,原图使用资源引用,节点记录阶段任务 ID。一次分析最多 9 个不同模型阶段,不自动重复提交。停止观察不等于取消后台任务,已提交阶段仍可在任务中心查看;自动费用审批及整条管线刷新恢复仍待接入。Agent 当前支持准备输入和读取已有报告,尚未开放自动启动收费分析。 + ## 1. 先说结论 这个节点的定位不是“图片生成器”,而是一个视觉老师:用户把图片接进来,它负责看图、指出问题、解释原因,并给出可以执行的修改方向。 diff --git a/docs/design/agent-capabilities.mdx b/docs/design/agent-capabilities.mdx new file mode 100644 index 000000000..8aba19f0f --- /dev/null +++ b/docs/design/agent-capabilities.mdx @@ -0,0 +1,58 @@ +--- +title: Agent 能力发现与插件动作 +description: 在线画布助手的节点发现、技能渐进读取和插件动作接入合同 +--- + +# Agent 能力发现与插件动作 + +在线画布助手通过 `canvas_list_capabilities` 查询实时节点注册表和有效启用的插件。内置节点及插件贡献不再依赖助手中的固定节点类型枚举。发现返回名称、来源、输入约束、默认数据和插件提供的动作;发现到声明不代表专用执行器已经存在。 + +## 技能自动发现 + +`canvas_list_skills` 支持 `query`、`offset`、`limit`,默认每页 40 条、最多 100 条,返回 `items`、`total`、`nextOffset`。助手先根据任务检索元数据,再读取 `SKILL.md`,按入口引用搜索、读取关联文件。无需用户手动选中技能;是否选用由模型结合任务判断,不保证每次都调用技能。 + +在线技能工具执行时重新取得当前已加入技能目录,复用账号隔离缓存和安装、移除时的缓存失效机制。插件文档可通过 `canvas_read_plugin` 按已发现 ID 读取。文档和技能说明不扩大用户授权,也不意味着浏览器可以执行技能包中的任意脚本。 + +## 专业短剧节点 + +创作方案和通用工作流支持 `styleboard`、`story_input`、`script`,复用手动短剧入口的数据结构。画风节点初始为空,显示系统已有的选择入口;故事节点使用专用编辑卡片;分镜节点使用真实表格。 + +`script.shots` 可填写逐镜 `durationSeconds`、`videoMotionPrompt`、`dialogue`,最多 100 行,写入稳定行 ID。方案连到分镜节点时使用 `storyboard:context` 输入。仅创建这些节点不产生媒体生成项;图像、视频仍沿用既有方案和费用确认。 + +`canvas_list_styles` 按 `source=system|user` 查询系统预设或当前用户保存的风格。`canvas_apply_style` 只接受该目录返回的真实 ID,复用页面的异步画风保存入口:关联项目时先保存项目设置,再应用画布节点;不关联项目时走原画布保存机制。异步返回时校验画布、项目及用户上下文,避免写到新页面。该动作是原画风选择逻辑,不冒充普通节点撤销能够回滚服务端项目设置;需要改回时重新应用原风格。 + +`canvas_read_storyboard` 返回真实行 ID、镜头内容及素材关联。`canvas_edit_storyboard` 支持 `append`、`update`、`remove`,按行 ID 操作;仅允许镜头文本与正数时长字段,不允许伪造任务状态或素材 ID。移除行保留生成素材,剩余行保留 ID 并重排序号。分镜编辑进入原画布操作、撤销与历史记录链路,不自动重新生成媒体。 + +## 插件动作接入 + +已在宿主注册的 `RegisteredPlugin` 可提供 `agentActions`: + +```ts +agentActions: [{ + id: "create-analysis-node", + description: "创建图片分析节点", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + buildOperations(input, snapshot) { + // 在此校验本动作入参,仅返回操作,禁止请求模型或产生其他副作用。 + return [{ type: "add_node", nodeType: "my-analysis-node" }]; + }, +}] +``` + +模型根据目录通过 `canvas_plugin_action` 提交 `pluginId`、`actionId`、`input`。动作构建器负责业务入参校验;宿主核对插件有效启用状态、动作存在性、`canvas.write` 权限、操作数量及生成操作的 `generation.run` 声明,再交给原画布执行链路。工具调用和结果沿用原会话记录。 + +构建器必须是纯函数:输入和快照以副本传入,返回 1 至 100 个操作,不自行网络请求、收费或写入资源。`inputSchema` 用于向模型说明合同,不替代动作构建器的校验。仅上传带节点声明的 JSON 插件不会凭空获得 JavaScript 执行器;执行器需要通过宿主支持的插件运行时接入。本接口没有增加任意远程代码执行能力。 + +插件还可提供 `readAgentNode(node, snapshot)`,将节点状态转换为有限的结构化结果,通过 `canvas_read_plugin_node` 读取。必须校验插件启用及 `canvas.read` 声明,不返回内嵌媒体和密钥。审美批改、肖像排查已接入 `prepare-input` 动作和状态读取;同一图片已有对应节点时复用,不重复创建。审美报告仅在当前唯一输入与报告指纹一致时返回,否则标为过期。 + +节点类型冲突会拒绝注册。插件停用后不再出现在能力目录,新建节点和调用插件动作都会失败,旧作品保留。未知节点类型不能降级为普通文本。 + +## 当前边界与后续接入 + +审美分析的手动入口已接入 `art-critique-execution` 统一执行边界。各阶段通过现有后台文本任务运行,原图先准备为资源引用,任务正文不内嵌媒体。最多提交 9 个不同阶段,不自动重复提交同一阶段;节点保存 `stageTaskIds`,任务元数据记录节点、执行和阶段标识。底层 pipeline 支持注入阶段执行器,费用审批可在这一入口接入,不复制审美判断与解析逻辑。 + +停止观察不代表后台任务取消或未收费。已提交任务保留在任务中心;本批没有自动恢复整条管线或重新提交失败阶段。画布输入改变后停止后续观察,不将旧结果写到新输入。Agent 目前仍不能自动启动该收费分析,须先接通具体阶段报价、用户批准和任务接续。 + +本批接入在线画布助手。首页尚未改造成通用工具循环,本机 Agent 仍使用原技能包交付机制。现有插件只有声明而没有 `agentActions` 时,可发现贡献并创建节点,不宣称能自动执行它的专用分析、编辑或导出。 + +已补齐画风预设的模型选择与项目同步、分镜专用增删改。审美批改与肖像排查的新分析仍通过插件自己的入口执行:前者涉及多次模型调用,后者涉及本机任务,尚未接入统一 Agent 费用和任务编排。后续继续接通这些执行器,以及首页与本机端的同一能力目录。不得以任意 `metadata` 更新替代具有业务校验和费用语义的执行接口。 diff --git a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx index dd0e09d59..a89d1e4c8 100644 --- a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx +++ b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx @@ -21,9 +21,10 @@ import { type ArtCritiqueReport, } from "@/lib/art-critique/contracts"; import { ART_CRITIQUE_CATEGORY_COLORS, ART_CRITIQUE_SEVERITY_COLORS, layoutArtCritiqueLabels, repairIssueTarget, targetBounds } from "@/lib/art-critique/annotation"; -import { runArtCritiquePipeline } from "@/lib/art-critique/pipeline"; +import { executeArtCritique } from "@/services/art-critique-execution"; +import { getActiveUserScope } from "@/lib/user-scope"; import { useCopyText } from "@/hooks/use-copy-text"; -import { imageToDataUrl, resolveImageUrl } from "@/services/image-storage"; +import { resolveImageUrl } from "@/services/image-storage"; import { modelOptionLabel, useEffectiveConfig } from "@/stores/use-config-store"; import { usePluginStore } from "@/stores/use-plugin-store"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -78,7 +79,8 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat const [hoveredIssueId, setHoveredIssueId] = useState(null); const [draftReportVisible, setDraftReportVisible] = useState(false); const abortRef = useRef(null); - const preparedImageRef = useRef<{ fingerprint: string; dataUrl: string } | null>(null); + const currentRunTargetRef = useRef(""); + currentRunTargetRef.current = `${getActiveUserScope()}:${node?.id || ""}:${currentFingerprint}`; useEffect(() => { if (!open || !input) { @@ -110,6 +112,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat }, [node?.id, currentFingerprint, open]); useEffect(() => () => abortRef.current?.abort(), []); + useEffect(() => () => abortRef.current?.abort(), [node?.id, currentFingerprint]); const issues = visibleState.report?.issues || []; const reportOptions = visibleState.report?.options || []; @@ -160,6 +163,11 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat const runId = `art-critique-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const controller = new AbortController(); abortRef.current = controller; + const runTarget = currentRunTargetRef.current; + const runScope = getActiveUserScope(); + const publishState = (next: ArtCritiqueNodeState) => { + if (getActiveUserScope() === runScope && currentRunTargetRef.current === runTarget && abortRef.current === controller) onUpdateState(node.id, next); + }; const baseState: ArtCritiqueNodeState = { ...state, schemaVersion: ART_CRITIQUE_SCHEMA_VERSION, @@ -168,41 +176,32 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat sourceNodeId: input.id, sourceFingerprint, lastRunId: runId, + stageTaskIds: {}, errorCode: undefined, errorMessage: undefined, updatedAt: new Date().toISOString(), }; - onUpdateState(node.id, baseState); + publishState(baseState); setRunning(true); setActiveStage("preparing"); setLocalError(""); let latestStage: ArtCritiquePipelineStage = "preparing"; let runningReport: ArtCritiqueReport | undefined = state.report; + const stageTaskIds: Record = {}; try { - const source = input.metadata?.content || input.metadata?.previewContent || ""; - const cachedImage = preparedImageRef.current; - const dataUrl = - cachedImage?.fingerprint === sourceFingerprint - ? cachedImage.dataUrl - : await imageToDataUrl({ - dataUrl: source.startsWith("data:") ? source : undefined, - url: source.startsWith("data:") ? undefined : source, - storageKey: input.metadata?.storageKey, - name: input.title, - mimeType: input.metadata?.mimeType, - }); - if (!dataUrl) throw new Error("无法读取输入图片"); - if (!controller.signal.aborted) preparedImageRef.current = { fingerprint: sourceFingerprint, dataUrl }; - const report = await runArtCritiquePipeline( - effectiveConfig, - { dataUrl, title: input.title, sourceFingerprint }, - { + const report = await executeArtCritique({ + nodeId: node.id, runId, source: input, config: effectiveConfig, signal: controller.signal, + onTaskCreated: (stage, taskId) => { + stageTaskIds[stage] = taskId; + publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, analysisStage: latestStage, updatedAt: new Date().toISOString() }); + }, onStage: (analysisStage) => { latestStage = analysisStage; setActiveStage(analysisStage); - onUpdateState(node.id, { + publishState({ ...baseState, + stageTaskIds: { ...stageTaskIds }, ...(runningReport ? { report: { ...runningReport, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) } } : {}), analysisStage, updatedAt: new Date().toISOString(), @@ -212,19 +211,20 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat if (controller.signal.aborted || abortRef.current !== controller) return; runningReport = draftReport; setDraftReportVisible(true); - onUpdateState(node.id, { + publishState({ ...baseState, + stageTaskIds: { ...stageTaskIds }, status: "running", analysisStage: latestStage, report: { ...draftReport, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) }, updatedAt: new Date().toISOString(), }); }, - }, - ); + }); setDraftReportVisible(false); - onUpdateState(node.id, { + publishState({ ...baseState, + stageTaskIds: { ...stageTaskIds }, status: "completed", analysisStage: "completed", report: { ...report, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) }, @@ -235,14 +235,14 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat if (controller.signal.aborted) { setActiveStage(undefined); setDraftReportVisible(false); - onUpdateState(node.id, { ...baseState, status: "idle", analysisStage: undefined, errorCode: undefined, errorMessage: undefined, updatedAt: new Date().toISOString() }); + publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, status: "failed", analysisStage: "failed", errorCode: "analysis_interrupted", errorMessage: "分析观察已停止;已提交阶段保留在任务中心,不会自动重复提交。", updatedAt: new Date().toISOString() }); return; } const errorMessage = error instanceof Error ? error.message : "AI 批改失败,请稍后重试"; setActiveStage("failed"); setDraftReportVisible(false); setLocalError(errorMessage); - onUpdateState(node.id, { ...baseState, status: "failed", analysisStage: "failed", errorCode: error instanceof Error ? error.message : "art_critique_failed", errorMessage, updatedAt: new Date().toISOString() }); + publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, status: "failed", analysisStage: "failed", errorCode: error instanceof Error ? error.message : "art_critique_failed", errorMessage, updatedAt: new Date().toISOString() }); } finally { if (abortRef.current === controller) abortRef.current = null; setRunning(false); diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 86f9cafd4..cfd933b89 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -13,6 +13,14 @@ import { nanoid } from "nanoid"; import { type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image"; import { runBackendToolGenerationTask } from "@/services/api/generation-task"; import { inspectAgentImage } from "@/services/agent-image-preview"; +import { AGENT_CAPABILITY_GUIDANCE, buildAgentPluginOperations, listAgentCapabilities, readAgentPluginDocumentation, readAgentPluginNode } from "@/services/agent-capabilities"; +import { getNodeDefinition } from "@/lib/canvas/node-registry"; +import { buildAgentStoryboardOperations, readAgentStoryboard } from "@/lib/canvas/canvas-agent-storyboard"; +import { canvasStylePresets, userStylePreset, type CanvasStylePreset } from "./canvas-style-picker-modal"; +import { listStyleProfiles } from "@/services/api/style-profiles"; +import { getActiveUserScope } from "@/lib/user-scope"; +import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store"; +import type { CanvasNodeTypeId } from "@/types/canvas"; import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer"; import { consumeGenerationTaskAgent } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe"; @@ -58,8 +66,8 @@ const ONLINE_AGENT_PROMPT = 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 NODE_TYPE_SCHEMA = { type: "string", description: "使用 canvas_list_capabilities 返回的真实节点类型 id" }; +const WORKFLOW_NODE_KIND_SCHEMA = { type: "string", enum: ["text", "script", "styleboard", "story_input", "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" }, @@ -128,6 +136,14 @@ function generationToolDefinition(name: string, description: string, mode?: "tex } const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [ + toolDefinition("canvas_read_plugin_node", "读取已启用插件提供的真实节点状态与已有分析结果,不启动新任务。", { nodeId: { type: "string" } }, ["nodeId"]), + toolDefinition("canvas_list_styles", "读取真实项目画风目录;source=system 为系统预设,source=user 为当前用户保存的风格。返回 ID 和说明,不能编造 ID。", { source: { type: "string", enum: ["system", "user"] }, query: { type: "string" } }), + toolDefinition("canvas_apply_style", "应用从画风目录发现的真实风格,复用页面画风保存逻辑;关联项目时同步项目设置。已有画风且无需改变时直接沿用。", { source: { type: "string", enum: ["system", "user"] }, id: { type: "string" } }, ["source", "id"]), + toolDefinition("canvas_read_storyboard", "读取真实分镜行 ID、内容及已有素材关联,修改前先读取。", { nodeId: { type: "string" } }, ["nodeId"]), + toolDefinition("canvas_edit_storyboard", "按真实行 ID 新增、修改或移除分镜。patch 仅接受 durationSeconds 与镜头文本字段,不改素材 ID 或生成状态;移除行保留生成素材,不自动重新生成视频。", { nodeId: { type: "string" }, action: { type: "string", enum: ["append", "update", "remove"] }, rowId: { type: "string" }, patch: JSON_RECORD_SCHEMA }, ["nodeId", "action"]), + toolDefinition("canvas_read_plugin", "按发现的插件 id 读取当前版本用法文档。", { pluginId: { type: "string" } }, ["pluginId"]), + toolDefinition("canvas_plugin_action", "执行能力目录中插件已挂载的画布动作。input 遵循动作的 inputSchema;操作沿用画布确认、校验和结果记录。", { pluginId: { type: "string" }, actionId: { type: "string" }, input: JSON_RECORD_SCHEMA }, ["pluginId", "actionId", "input"]), + toolDefinition("canvas_list_capabilities", "发现当前已注册内置节点和已启用插件。返回真实类型、默认数据、输入约束与能力边界;query 可按业务名称检索。", { query: { type: "string" } }), ...CREATIVE_AGENT_TOOLS, ...skillRuntime.agentTools("onlineAgent"), toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}), @@ -171,6 +187,7 @@ const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [ runGeneration: { type: "boolean" }, width: { type: "number" }, height: { type: "number" }, + shots: { type: "array", maxItems: 100, items: { type: "object", properties: { durationSeconds: { type: "number" }, videoMotionPrompt: { type: "string" }, dialogue: { type: "string" } }, required: ["durationSeconds", "videoMotionPrompt"], additionalProperties: false } }, }, required: ["ref", "kind", "title"], additionalProperties: false, @@ -186,7 +203,7 @@ const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [ ), toolDefinition( "canvas_create_node", - "创建任意类型节点:text、image、video、audio。适合创建文本、媒体占位或自定义 metadata 节点。", + "创建 canvas_list_capabilities 中已注册且可用的节点类型。使用返回的真实 id 和数据合同;专业短剧结构优先通过方案或 canvas_create_workflow 创建。", { nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, metadata: JSON_RECORD_SCHEMA }, ["nodeType"], ), @@ -268,6 +285,7 @@ type CanvasAssistantPanelProps = { onSelectNodeIds: (ids: Set) => void; onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void; onApplyOps: (ops?: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise; + onApplyStyle: (preset: CanvasStylePreset) => Promise; canUndoOps: boolean; undoOpsCount: number; onUndoOps: () => CanvasAgentSnapshot | null; @@ -359,6 +377,7 @@ export function CanvasAssistantPanel({ onSelectNodeIds, onSessionsChange, onApplyOps, + onApplyStyle, canUndoOps, undoOpsCount, onUndoOps, @@ -836,8 +855,31 @@ export function CanvasAssistantPanel({ 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); + const currentSkills = skillRuntime.agentToolNames("onlineAgent").has(name) ? (await listAddedSkills()).skills : composerSkills; + const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, currentSkills); if (skillToolResult) return skillToolResult; + if (name === "canvas_read_plugin_node") return { ok: true, message: "已读取插件节点状态。", data: readAgentPluginNode(current, requireString(args.nodeId, "nodeId")) }; + if (name === "canvas_read_storyboard") { + const { node, rows } = readAgentStoryboard(current, requireString(args.nodeId, "nodeId")); + return { ok: true, message: "已读取分镜表。", data: { nodeId: node.id, title: node.title, rows } }; + } + if (name === "canvas_list_styles" || name === "canvas_apply_style") { + const originScope = getActiveUserScope(); + const source = args.source || "system"; + if (source !== "system" && source !== "user") throw new Error("画风来源必须是 system 或 user"); + const presets = source === "system" ? canvasStylePresets.map((preset) => ({ id: preset.id, preset })) : (await listStyleProfiles()).profiles.flatMap((entity) => { const preset = userStylePreset(entity); return preset ? [{ id: entity.id, preset }] : []; }); + if (getActiveUserScope() !== originScope || snapshotRef.current.projectId !== current.projectId || generationConsumerControllerRef.current.signal.aborted) throw new Error("画布或账号已切换,请重新读取画风"); + if (name === "canvas_list_styles") { + const query = typeof args.query === "string" ? args.query.toLocaleLowerCase().trim() : ""; + return { ok: true, message: "已读取画风目录。", data: presets.filter(({ preset }) => !query || `${preset.title} ${preset.description} ${preset.tags.join(" ")}`.toLocaleLowerCase().includes(query)).map(({ id, preset }) => ({ id, source, title: preset.title, description: preset.description, tags: preset.tags })) }; + } + const selected = presets.find((item) => item.id === args.id); + if (!selected) throw new Error("画风不存在或已移除,请先读取真实画风目录"); + await onApplyStyle(selected.preset); + return { ok: true, message: `已应用画风“${selected.preset.title}”。`, data: { source, id: selected.id, presetId: selected.preset.id, prompt: selected.preset.prompt } }; + } + if (name === "canvas_read_plugin") return { ok: true, message: "已读取插件用法。", data: readAgentPluginDocumentation(requireString(args.pluginId, "pluginId")) }; + if (name === "canvas_list_capabilities") return { ok: true, message: "已读取当前能力目录。", data: listAgentCapabilities(typeof args.query === "string" ? args.query : "") }; if (name === "canvas_inspect_image") { const readSignal = generationConsumerControllerRef.current.signal; const node = current.nodes.find((item) => item.id === args.id && item.type === "image"); @@ -1500,6 +1542,8 @@ function parseToolArguments(value: string) { } export function onlineToolToOps(name: string, input: Record, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] { + if (name === "canvas_edit_storyboard") return buildAgentStoryboardOperations(snapshot, input); + if (name === "canvas_plugin_action") return buildAgentPluginOperations(requireString(input.pluginId, "pluginId"), requireString(input.actionId, "actionId"), recordOptional(input.input) || {}, snapshot); 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") { @@ -1652,7 +1696,7 @@ function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "aud } function isWritableToolCall(call: ResponseToolCall) { - return !ONLINE_READ_TOOLS.has(call.function.name); + return !["canvas_list_capabilities", "canvas_read_plugin", "canvas_list_styles", "canvas_read_storyboard", "canvas_read_plugin_node"].includes(call.function.name) && !ONLINE_READ_TOOLS.has(call.function.name); } function toolCallsFromDetail(detail: Record): ResponseToolCall[] { @@ -1717,6 +1761,14 @@ function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgent } function toolCallLabel(name: string) { + if (name === "canvas_read_plugin_node") return "读取插件结果"; + if (name === "canvas_list_styles") return "查找项目画风"; + if (name === "canvas_apply_style") return "应用项目画风"; + if (name === "canvas_read_storyboard") return "读取分镜表"; + if (name === "canvas_edit_storyboard") return "调整分镜"; + if (name === "canvas_list_capabilities") return "查找可用能力"; + if (name === "canvas_read_plugin") return "读取插件用法"; + if (name === "canvas_plugin_action") return "执行插件操作"; if (name === "canvas_inspect_image") return "观察图片"; if (name === "canvas_list_skills") return "列出技能"; if (name === "canvas_get_skill") return "读取技能入口"; @@ -1818,9 +1870,10 @@ function requireNumber(value: unknown, field: string) { 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 requireNodeType(value: unknown): CanvasNodeTypeId { + const definition = typeof value === "string" ? getNodeDefinition(value) : undefined; + if (definition && (!definition.plugin || isPluginEffectivelyEnabled(definition.plugin.pluginId))) return definition.type; + throw new Error("节点未注册或插件不可用,请先读取 canvas_list_capabilities"); } function requireViewport(value: unknown) { @@ -1970,7 +2023,7 @@ async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: Ca const executionGuidance = confirmTools ? "当前普通画布工具由程序展示执行确认。用户目标和操作范围明确时直接提交工具,程序会处理确认,不要在工具确认之前再用文字问一遍。" : "当前普通画布工具的逐次确认已关闭。用户请求范围内的操作可以直接调用,不要先征求重复授权;这不代表可以擅自扩大删除范围或跳过结构化方案、具体费用的批准。"; - const systemContent = [ONLINE_AGENT_PROMPT, executionGuidance, creativeScenarioPrompt(seed.scene), skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n"); + const systemContent = [ONLINE_AGENT_PROMPT, AGENT_CAPABILITY_GUIDANCE, executionGuidance, creativeScenarioPrompt(seed.scene), skillCatalog ? `当前可按需加载的技能摘要(完整目录可调用 canvas_list_skills 检索):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n"); return budgetCanvasAgentHistory( { role: "system", content: systemContent }, history diff --git a/web/src/components/canvas/canvas-style-picker-modal.tsx b/web/src/components/canvas/canvas-style-picker-modal.tsx index 379117dbc..2d2248d4d 100644 --- a/web/src/components/canvas/canvas-style-picker-modal.tsx +++ b/web/src/components/canvas/canvas-style-picker-modal.tsx @@ -654,7 +654,7 @@ function EmptyStyleCenter({ tab, loading, failed, color, onCreate, onBrowse }: { return

{loading ? "正在加载风格库" : failed && tab !== "system" ? "我的风格加载失败,请检查登录状态或后端服务" : tab === "mine" ? "还没有自己的风格" : tab === "favorites" ? "还没有收藏风格" : tab === "recent" ? "还没有使用记录" : "没有匹配的系统风格"}

{tab === "mine" && !failed ? : null}
; } -function userStylePreset(entity: UserStyleProfile): CanvasStylePreset | null { +export function userStylePreset(entity: UserStyleProfile): CanvasStylePreset | null { const profile = parseStyleProfile(entity.profileJson); if (!profile) return null; return { id: profile.presetId, title: profile.title, category: "我的风格", description: profile.description, tags: [...profile.tags], prompt: profile.prompt, imageUrl: profile.coverUrl || entity.coverUrl || "/short-drama-styles/real-life.jpg", profile }; diff --git a/web/src/lib/art-critique/contracts.ts b/web/src/lib/art-critique/contracts.ts index 8b41820b3..1b53d12ef 100644 --- a/web/src/lib/art-critique/contracts.ts +++ b/web/src/lib/art-critique/contracts.ts @@ -125,6 +125,7 @@ export type ArtCritiqueReport = { export type ArtCritiqueNodeStatus = "idle" | "running" | "completed" | "failed" | "stale"; export type ArtCritiqueNodeState = { + stageTaskIds?: Record; schemaVersion: typeof ART_CRITIQUE_SCHEMA_VERSION; status: ArtCritiqueNodeStatus; sourceNodeId?: string; diff --git a/web/src/lib/art-critique/pipeline.ts b/web/src/lib/art-critique/pipeline.ts index 911423e52..241ce2dac 100644 --- a/web/src/lib/art-critique/pipeline.ts +++ b/web/src/lib/art-critique/pipeline.ts @@ -1,4 +1,4 @@ -import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage } from "@/services/api/image"; +import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ToolResponseResult } from "@/services/api/image"; import type { AiConfig } from "@/stores/use-config-store"; import { @@ -31,6 +31,7 @@ const GROUNDING_CONFIDENCE_THRESHOLD = 0.65; const VERIFICATION_REJECTION_THRESHOLD = 0.75; export type ArtCritiquePipelineOptions = { + requestStage?: (input: { config: AiConfig; messages: ResponseInputMessage[]; tool: ResponseFunctionTool; toolName: string; signal?: AbortSignal }) => Promise; signal?: AbortSignal; onStage?: (stage: ArtCritiquePipelineStage) => void; onDraftReport?: (report: ArtCritiqueReport) => void; @@ -557,7 +558,9 @@ export function applyEditPrompts(issues: readonly ArtCritiqueIssue[], prompts: r async function requestPipelineStage(config: AiConfig, messages: ResponseInputMessage[], tool: ResponseFunctionTool, toolName: string, parse: (value: unknown) => T, options: ArtCritiquePipelineOptions) { throwIfAborted(options.signal); - const response = await requestToolResponse(config, messages, [tool], { type: "function", name: toolName }, undefined, { signal: options.signal }); + const response = options.requestStage + ? await options.requestStage({ config, messages, tool, toolName, signal: options.signal }) + : await requestToolResponse(config, messages, [tool], { type: "function", name: toolName }, undefined, { signal: options.signal }); const raw = response.toolCalls.find((candidate) => candidate.function.name === toolName)?.function.arguments || response.content; if (!raw?.trim()) throw new Error(`${toolName}_missing`); let value: unknown; diff --git a/web/src/lib/canvas/canvas-agent-context.ts b/web/src/lib/canvas/canvas-agent-context.ts index 08bb435a9..c3cc17dc6 100644 --- a/web/src/lib/canvas/canvas-agent-context.ts +++ b/web/src/lib/canvas/canvas-agent-context.ts @@ -1,5 +1,7 @@ import { hashCanvasAgentSnapshot, type CanvasAgentOp, type CanvasAgentSnapshot } from "./canvas-agent-ops"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { getNodeDefinition } from "./node-registry"; +import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store"; export type CanvasAgentResource = { nodeId: string; @@ -151,6 +153,8 @@ export function validateCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops: Canva }; ops.forEach((op, index) => { if (op.type === "add_node") { + const definition = getNodeDefinition(op.nodeType || CanvasNodeType.Text); + if (!definition || (definition.plugin && !isPluginEffectivelyEnabled(definition.plugin.pluginId))) issues.push({ index, severity: "error", message: "节点未注册或插件不可用,请重新发现能力" }); validateNodeNumbers(issues, index, op as unknown as Record); if (op.id && liveNodeIds.has(op.id)) issues.push({ index, severity: "error", message: `新增节点 id「${op.id}」重复` }); if (op.id) liveNodeIds.add(op.id); diff --git a/web/src/lib/canvas/canvas-agent-ops.ts b/web/src/lib/canvas/canvas-agent-ops.ts index 24a3b8912..023f6b8dc 100644 --- a/web/src/lib/canvas/canvas-agent-ops.ts +++ b/web/src/lib/canvas/canvas-agent-ops.ts @@ -1,12 +1,14 @@ import { nanoid } from "nanoid"; import { getNodeSpec } from "@/constant/canvas"; -import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas"; +import { getNodeDefinition } from "./node-registry"; +import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store"; +import { CanvasNodeType, type CanvasNodeTypeId, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas"; export type CanvasAgentOp = - | { type: "add_node"; id?: string; nodeType?: CanvasNodeType; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata } + | { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata } | { type: "update_node"; id: string; patch?: Partial; metadata?: CanvasNodeMetadata } - | { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeType } + | { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId } | { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean } | { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string; fromHandleId?: string; toHandleId?: string } | { type: "set_viewport"; viewport: ViewportTransform } @@ -320,8 +322,10 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA (Array.isArray(ops) ? ops : []).forEach((op, index) => { if (!op?.type) return; if (op.type === "add_node") { - const nodeType = Object.values(CanvasNodeType).includes(op.nodeType as CanvasNodeType) ? op.nodeType! : CanvasNodeType.Text; - const spec = getNodeSpec(nodeType); + const nodeType = op.nodeType || CanvasNodeType.Text; + const definition = getNodeDefinition(nodeType); + if (!definition || (definition.plugin && !isPluginEffectivelyEnabled(definition.plugin.pluginId))) throw new Error(`节点类型未注册或插件不可用:${nodeType}`); + const spec = getNodeSpec(nodeType as CanvasNodeType) || { title: definition.defaultTitle, ...definition.defaultSize, metadata: definition.defaultMetadata }; const node: CanvasNodeData = { id: op.id || `${nodeType}-${Date.now()}-${index}`, type: nodeType, @@ -329,7 +333,7 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA position: op.position || { x: op.x ?? index * 36, y: op.y ?? index * 36 }, width: op.width || spec.width, height: op.height || spec.height, - metadata: { ...spec.metadata, ...op.metadata }, + metadata: { ...spec.metadata, ...op.metadata, ...(definition.plugin ? { pluginId: definition.plugin.pluginId, pluginNodeId: nodeType } : {}) }, }; nodes = [...nodes, node]; selectedNodeIds = [node.id]; @@ -390,7 +394,7 @@ function opLabel(type: string) { return type; } -function canvasNodeTypeLabel(type?: CanvasNodeType) { +function canvasNodeTypeLabel(type?: CanvasNodeTypeId) { if (type === CanvasNodeType.Image) return "图片节点"; if (type === CanvasNodeType.Video) return "视频节点"; if (type === CanvasNodeType.Audio) return "音频节点"; @@ -399,8 +403,7 @@ function canvasNodeTypeLabel(type?: CanvasNodeType) { if (type === CanvasNodeType.Frame) return "背板"; if (type === CanvasNodeType.Drawing) return "绘图节点"; if (type === CanvasNodeType.Skill) return "技能节点"; - if (type === CanvasNodeType.MediaConversion) return "转换节点"; - return "文本节点"; + return type ? `${getNodeDefinition(type)?.label || type}节点` : "文本节点"; } function generationModeLabel(mode?: "text" | "image" | "video" | "audio") { diff --git a/web/src/lib/canvas/canvas-agent-storyboard.ts b/web/src/lib/canvas/canvas-agent-storyboard.ts new file mode 100644 index 000000000..68a0e20e4 --- /dev/null +++ b/web/src/lib/canvas/canvas-agent-storyboard.ts @@ -0,0 +1,52 @@ +import type { CanvasAgentOp, CanvasAgentSnapshot } from "./canvas-agent-ops"; +import { createStoryboardRow } from "./canvas-project-domain"; +import type { StoryboardRow } from "@/types/canvas"; + +const textFields = ["plotDescription", "dialogue", "videoMotionPrompt", "imageGenerationPrompt", "camera", "motion", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "narrativeIntent", "viewerPOV", "performanceBlocking", "timeBeats", "continuityOut", "negativePrompt"] as const; + +export function readAgentStoryboard(snapshot: CanvasAgentSnapshot, nodeId: string) { + const node = snapshot.nodes.find((item) => item.id === nodeId && item.type === "script"); + if (!node) throw new Error("未找到当前画布的分镜脚本节点,请先查找真实节点"); + return { node, rows: node.metadata?.storyboard?.rows || [] }; +} + +export function buildAgentStoryboardOperations(snapshot: CanvasAgentSnapshot, input: Record): CanvasAgentOp[] { + if (typeof input.nodeId !== "string") throw new Error("需要分镜节点 nodeId"); + const { node, rows } = readAgentStoryboard(snapshot, input.nodeId); + const action = input.action; + const rowId = typeof input.rowId === "string" ? input.rowId : ""; + const index = rows.findIndex((row) => row.id === rowId); + let next = rows.slice(); + if (action === "remove") { + if (index < 0) throw new Error("分镜行不存在,请重新读取分镜表"); + // Removing a row never deletes its existing generated assets. + next.splice(index, 1); + } else if (action === "append" || action === "update") { + if (action === "update" && index < 0) throw new Error("分镜行不存在,请重新读取分镜表"); + const raw = input.patch; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("需要分镜内容 patch"); + const data = raw as Record; + const patch: Partial = {}; + for (const [key, value] of Object.entries(data)) { + if (key === "durationSeconds") { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error("分镜时长必须是大于零的数字"); + patch.durationSeconds = value; + } else if (textFields.includes(key as typeof textFields[number])) { + if (typeof value !== "string" || value.length > 20_000) throw new Error(`分镜字段 ${key} 需要不超过 20000 字符的文本`); + patch[key as typeof textFields[number]] = value; + } else throw new Error(`不能通过分镜编辑修改字段 ${key};已有素材关联与任务状态由系统维护`); + } + if (!Object.keys(patch).length) throw new Error("未提供需要修改的分镜内容"); + if (action === "append") { + if (rows.length >= 100) throw new Error("单个分镜表最多 100 行"); + if (!patch.videoMotionPrompt?.trim() && !patch.plotDescription?.trim()) throw new Error("新增镜头需要画面描述或视频提示词"); + next.push(createStoryboardRow(rows.length + 1, patch)); + } else next[index] = { ...rows[index], ...patch }; + } else throw new Error("分镜操作必须是 append、update 或 remove"); + next = next.map((row, i) => ({ ...row, shotNumber: i + 1 })); + return [{ type: "update_node", id: node.id, metadata: { storyboard: { + rows: next, + visibleColumns: node.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"], + referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [], + } } }]; +} diff --git a/web/src/lib/canvas/canvas-agent-workflow.ts b/web/src/lib/canvas/canvas-agent-workflow.ts index 0b1f99f46..3d5f48310 100644 --- a/web/src/lib/canvas/canvas-agent-workflow.ts +++ b/web/src/lib/canvas/canvas-agent-workflow.ts @@ -4,10 +4,14 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas"; import type { AiConfig } from "@/stores/use-config-store"; import type { CanvasAgentOp, CanvasAgentSnapshot } from "./canvas-agent-ops"; +import { createShortDramaPipeline } from "./canvas-short-drama"; +import { createStoryboardRow } from "./canvas-project-domain"; export type CanvasWorkflowNodeKind = | "text" | "script" + | "styleboard" + | "story_input" | "image" | "video" | "audio" @@ -28,6 +32,7 @@ export type CanvasWorkflowNodeInput = { runGeneration?: boolean; width?: number; height?: number; + shots?: Array<{ durationSeconds: number; videoMotionPrompt: string; dialogue?: string }>; }; export type CanvasWorkflowInput = { @@ -102,6 +107,18 @@ export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: Can ], } : {}), }; + if (["styleboard", "story_input", "script"].includes(node.kind)) { + const template = createShortDramaPipeline({ x: 0, y: 0 }); + const source = template.nodes[node.kind === "styleboard" ? 0 : node.kind === "story_input" ? 1 : 2]; + Object.assign(metadata, source.metadata, { content: node.kind === "styleboard" ? "" : node.content || "", composerContent: prompt }); + if (node.kind === "script") { + if (node.shots && (!Array.isArray(node.shots) || node.shots.length > 100)) throw new Error("分镜最多 100 行"); + metadata.storyboard = { ...source.metadata!.storyboard!, rows: (node.shots || []).map((shot, rowIndex) => { + if (!Number.isFinite(shot.durationSeconds) || shot.durationSeconds <= 0 || typeof shot.videoMotionPrompt !== "string" || !shot.videoMotionPrompt.trim()) throw new Error("每条分镜需要有效时长和视频提示词"); + return createStoryboardRow(rowIndex + 1, { id: `${id}:shot:${rowIndex + 1}`, durationSeconds: shot.durationSeconds, videoMotionPrompt: shot.videoMotionPrompt, dialogue: typeof shot.dialogue === "string" ? shot.dialogue : "" }); + }) }; + } + } ops.push({ type: "add_node", id, nodeType: type, title: node.title, position, width: size.width, height: size.height, metadata }); }); @@ -113,20 +130,20 @@ export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: Can const key = `${edge.from}\0${edge.to}`; if (edgeKeys.has(key)) continue; edgeKeys.add(key); - ops.push({ type: "connect_nodes", fromNodeId: ids.get(edge.from)!, toNodeId: ids.get(edge.to)! }); + ops.push({ type: "connect_nodes", fromNodeId: ids.get(edge.from)!, toNodeId: ids.get(edge.to)!, ...(input.nodes.find((node) => node.ref === edge.to)?.kind === "script" ? { toHandleId: "storyboard:context" } : {}) }); } for (const node of input.nodes) { for (const referenceRef of node.referenceRefs || []) { const key = `${referenceRef}\0${node.ref}`; if (edgeKeys.has(key)) continue; edgeKeys.add(key); - ops.push({ type: "connect_nodes", fromNodeId: ids.get(referenceRef)!, toNodeId: ids.get(node.ref)! }); + ops.push({ type: "connect_nodes", fromNodeId: ids.get(referenceRef)!, toNodeId: ids.get(node.ref)!, ...(node.kind === "script" ? { toHandleId: "storyboard:context" } : {}) }); } for (const referenceNodeId of node.referenceNodeIds || []) { const key = `${referenceNodeId}\0${node.ref}`; if (edgeKeys.has(key)) continue; edgeKeys.add(key); - ops.push({ type: "connect_nodes", fromNodeId: referenceNodeId, toNodeId: ids.get(node.ref)! }); + ops.push({ type: "connect_nodes", fromNodeId: referenceNodeId, toNodeId: ids.get(node.ref)!, ...(node.kind === "script" ? { toHandleId: "storyboard:context" } : {}) }); } } const targetIds = input.nodes.map((node) => ids.get(node.ref)!); @@ -177,7 +194,8 @@ function nodeTypeForWorkflowKind(kind: CanvasWorkflowNodeKind) { if (kind === "image" || kind === "character_cards" || kind === "character_three_view") return CanvasNodeType.Image; if (kind === "video" || kind === "storyboard_video") return CanvasNodeType.Video; if (kind === "audio") return CanvasNodeType.Audio; - return CanvasNodeType.Text; + if (["text", "styleboard", "story_input"].includes(kind)) return CanvasNodeType.Text; + throw new Error(`未知工作流节点类型:${kind}`); } function nodeSize(type: CanvasNodeType, kind: CanvasWorkflowNodeKind, width?: number, height?: number) { @@ -187,7 +205,7 @@ function nodeSize(type: CanvasNodeType, kind: CanvasWorkflowNodeKind, width?: nu : kind === "storyboard_video" ? { width: 640, height: 360 } : defaults; - return { width: width || preferred.width, height: height || preferred.height }; + return { width: width || (kind === "styleboard" ? 360 : kind === "story_input" ? 420 : preferred.width), height: height || (kind === "styleboard" ? 220 : kind === "story_input" ? 260 : preferred.height) }; } function workflowKindForNode(kind: CanvasWorkflowNodeKind): CanvasNodeMetadata["workflowKind"] { diff --git a/web/src/lib/canvas/node-registry/node-registry.ts b/web/src/lib/canvas/node-registry/node-registry.ts index 7b9510aa1..74d0edc62 100644 --- a/web/src/lib/canvas/node-registry/node-registry.ts +++ b/web/src/lib/canvas/node-registry/node-registry.ts @@ -13,6 +13,12 @@ const FALLBACK_MIN_SIZE = { width: 220, height: 160 } as const; /** 批量注册节点定义 */ export function registerNodeDefinitions(defs: CanvasNodeDefinition[], ownerId = "builtin") { + const ids = new Set(); + for (const def of defs) { + const owner = ownerByType.get(def.type); + if (ids.has(def.type) || (owner && owner !== ownerId)) throw new Error(`节点类型重复或归属冲突:${def.type}`); + ids.add(def.type); + } for (const def of defs) { definitions.set(def.type, def); ownerByType.set(def.type, ownerId); diff --git a/web/src/lib/creation/creative-agent-state.ts b/web/src/lib/creation/creative-agent-state.ts index 4dd61b0c6..e1fb78e5c 100644 --- a/web/src/lib/creation/creative-agent-state.ts +++ b/web/src/lib/creation/creative-agent-state.ts @@ -59,7 +59,7 @@ export function normalizeCreativeProposal(raw: unknown, id: string, version: num if (!Array.isArray(workflow.nodes) || workflow.nodes.length < 1 || workflow.nodes.length > 20) throw new Error("方案应包含 1 至 20 个有明确用途的节点"); const nodes = workflow.nodes.map((value) => { const node = record(value), kind = str(node.kind); - if (!["text", "image", "video"].includes(kind)) throw new Error("此方案包含本期不支持的节点类型"); + if (!["text", "image", "video", "styleboard", "story_input", "script"].includes(kind)) throw new Error("方案节点类型不可用,请读取能力目录"); const content = str(node.content), prompt = str(node.prompt); const assetId = str(node.assetId), ref = required(node.ref, "节点引用"); if (assetId) { @@ -67,14 +67,16 @@ export function normalizeCreativeProposal(raw: unknown, id: string, version: num if (kind !== "image" || !asset) throw new Error("方案引用的已有图片不可用"); existingAssets[ref] = asset; } - if (!assetId && (kind === "text" ? !content : !prompt)) throw new Error("文本节点需要正文,媒体节点需要生成提示词"); - return { ref: required(node.ref, "节点引用"), kind: kind as "text" | "image" | "video", title: required(node.title, "节点标题"), content, prompt, runGeneration: false, referenceRefs: Array.isArray(node.referenceRefs) ? node.referenceRefs.map(String) : [], referenceNodeIds: Array.isArray(node.referenceNodeIds) ? node.referenceNodeIds.map(String) : [] }; + if (!assetId && (["text", "story_input"].includes(kind) ? !content : ["image", "video"].includes(kind) && !prompt)) throw new Error("文本节点需要正文,媒体节点需要生成提示词"); + const shots = Array.isArray(node.shots) ? node.shots.map((value) => { const shot = record(value); const durationSeconds = Number(shot.durationSeconds); if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) throw new Error("分镜时长必须大于零"); return { durationSeconds, videoMotionPrompt: required(shot.videoMotionPrompt, "分镜视频提示词"), dialogue: str(shot.dialogue) }; }) : undefined; + if (shots && shots.length > 100) throw new Error("分镜最多 100 行"); + return { ref: required(node.ref, "节点引用"), kind: kind as "text" | "image" | "video" | "styleboard" | "story_input" | "script", title: required(node.title, "节点标题"), content, prompt, shots, runGeneration: false, referenceRefs: Array.isArray(node.referenceRefs) ? node.referenceRefs.map(String) : [], referenceNodeIds: Array.isArray(node.referenceNodeIds) ? node.referenceNodeIds.map(String) : [] }; }); const refs = new Set(nodes.map((node) => node.ref)); if (refs.size !== nodes.length) throw new Error("方案节点引用重复"); const generationItems: CreativeGenerationItem[] = (Array.isArray(data.generationItems) ? data.generationItems : []).map((value) => { const item = record(value), ref = required(item.ref, "生成目标"), node = nodes.find((candidate) => candidate.ref === ref); - if (!node || node.kind === "text" || node.kind !== item.mode || existingAssets[ref]) throw new Error("生成任务与媒体节点不匹配,已有素材不能重复计费"); + if (!node || (node.kind !== "image" && node.kind !== "video") || node.kind !== item.mode || existingAssets[ref]) throw new Error("生成任务与媒体节点不匹配,已有素材不能重复计费"); const model = required(item.model, "生成模型"); if (!selectableModelsByCapability(config, node.kind).includes(model)) throw new Error(`节点“${node.title}”需要${node.kind === "video" ? "视频" : "图片"}模型,所选模型不支持该用途或已不可用:${model}。请从 availableModels 中选择 mode=${node.kind} 的模型重新提交方案。`); const referenceRefs: string[] = []; @@ -104,7 +106,7 @@ export function normalizeCreativeProposal(raw: unknown, id: string, version: num }); const mediaRefs = generationItems.map((item) => item.ref); if (new Set(mediaRefs).size !== mediaRefs.length) throw new Error("方案存在重复的 generationItems.ref,每个待生成媒体节点只能对应一个生成项"); - const missing = nodes.filter((node) => node.kind !== "text" && !existingAssets[node.ref] && !mediaRefs.includes(node.ref)); + const missing = nodes.filter((node) => (node.kind === "image" || node.kind === "video") && !existingAssets[node.ref] && !mediaRefs.includes(node.ref)); if (missing.length) throw new Error(`方案缺少媒体生成配置:${missing.map((node) => `${node.title}(ref=${node.ref},mode=${node.kind})`).join("、")}。请补齐 generationItems;配置用于方案校验与报价,不代表立即生成,仍需用户确认。`); // 引用图的循环依赖会造成永远没有可执行批次。 const visited = new Set(), visiting = new Set(); diff --git a/web/src/lib/creation/creative-agent-tools.ts b/web/src/lib/creation/creative-agent-tools.ts index 1455aaaec..5ef8a8ddd 100644 --- a/web/src/lib/creation/creative-agent-tools.ts +++ b/web/src/lib/creation/creative-agent-tools.ts @@ -5,7 +5,7 @@ const strings = { type: "array", items: string }; const object = (properties: Record, required: string[] = []) => ({ type: "object", properties, required, additionalProperties: false }); const option = object({ id: string, label: string, description: string }, ["id", "label"]); const question = object({ id: string, field: string, type: { type: "string", enum: ["single", "multiple", "text", "asset"] }, title: string, description: string, required: { type: "boolean" }, allowCustom: { type: "boolean" }, options: { type: "array", items: option } }, ["id", "field", "type", "title", "required", "allowCustom", "options"]); -const workflowNode = object({ ref: string, kind: { type: "string", enum: ["text", "image", "video"] }, title: string, content: string, prompt: string, assetId: string, referenceRefs: strings, referenceNodeIds: strings }, ["ref", "kind", "title"]); +const workflowNode = object({ ref: string, kind: { type: "string", enum: ["text", "image", "video", "styleboard", "story_input", "script"] }, title: string, content: string, prompt: string, assetId: string, referenceRefs: strings, referenceNodeIds: strings, shots: { type: "array", maxItems: 100, items: object({ durationSeconds: { type: "number" }, videoMotionPrompt: string, dialogue: string }, ["durationSeconds", "videoMotionPrompt"]) } }, ["ref", "kind", "title"]); const generation = object({ ref: string, mode: { type: "string", enum: ["image", "video"] }, model: string, size: string, seconds: string, quality: string, referenceRefs: strings }, ["ref", "mode", "model"]); // 模型只描述内容;批准、费用提交、任务状态和页面跳转不作为模型工具暴露。 @@ -31,7 +31,7 @@ export const CREATIVE_AGENT_SYSTEM_PROMPT = `你是影策的通用创作助手 多步骤创作使用 creative_respond。先结合全部需求摘要、会话、用户素材、专业角色指导、选中技能与真实画布,提取已知信息;只有缺失或冲突且确实影响执行的信息才补问,通常1至3题,确需一起决定时最多6题,不凑题。允许自定义安全的需求字段,优先复用已有字段,不通过改名重复询问。用户直接输入的自然语言回答与卡片回答同等有效,可以同时更新多个字段。不得将猜测写成用户确认。用户允许自主决定时可以直接给方案。 多步骤任务可携带 plan,按本次实际工作命名步骤,例如“确定结尾情绪”“设计商品构图”“制作首张主图”;不套固定步骤数。每步 ref 在同一工作持续时保持稳定,新工作使用新ref。phase只用于程序关联真实状态:questions=需求选择,proposal=内容方案,canvas=节点落地,media=产物生成(mediaRefs关联方案生成项ref)。可以调整未完成步骤并在reason说明原因;不编造完成状态。普通咨询不创建计划。已完成工作不可抹掉,新增任务不能复用已完成步骤ref。 问题与方案不能在同一次响应同时推进。已经确认的题材/时长/数量/比例/风格不再问。覆盖已确认字段必须在 evidence 引用最新用户消息中明确修改该字段的原文;不能将建议或旧消息作为修改授权。素材题的 option.id 只能使用上下文提供的真实素材ID。 -方案应是可阅读的完整 Markdown,包含内容、已有素材、待生成产物、规格和真实依赖。workflow 仅用现有 text/image/video 节点;媒体必须有完整提示词,文本必须有正文。每个待生成媒体节点有且仅有一项 generationItems,使用可用模型的完整 model ID。用户已有图片优先用 image 节点的 assetId 指向真实 references.id,此类节点不写 generationItems、不重复生成,可被其他节点用 referenceRefs 引用。 +方案应是可阅读的完整 Markdown,包含内容、已有素材、待生成产物、规格和真实依赖。workflow 支持 text/image/video/styleboard/story_input/script;搭建短剧工作流时使用真实的 styleboard 项目画风选择节点、story_input 故事梗概和 script 分镜表,已有节点优先沿用。script.shots 可填写逐镜 durationSeconds、videoMotionPrompt、dialogue,不用普通文本模拟表格。styleboard 是待选择入口,不把任意文字当作已应用画风;用户已有画风时不重复创建。只有 image/video 填写 generationItems;创建分镜表和画风入口不代表生成媒体。媒体必须有完整提示词,文本必须有正文。每个待生成媒体节点有且仅有一项 generationItems,使用可用模型的完整 model ID。用户已有图片优先用 image 节点的 assetId 指向真实 references.id,此类节点不写 generationItems、不重复生成,可被其他节点用 referenceRefs 引用。 generationItems 是方案的声明式生成配置,用于校验和之后的报价,不会立即调用媒体模型。因此“确认后再生成”仍必须填写它,不能省略。每项 ref 必须对应节点 ref,mode 必须等于节点 kind,model 必须来自 availableModels 中相同 mode 的条目;图片模型不能用于视频。上下文若有 rejectedProposal,先根据其中真实校验错误修正,保留已知需求并返回完整方案。结构化方案和费用的批准以程序记录为准;若仍缺按钮确认,直接显示对应卡片,不再先问一遍是否同意、再让用户点卡片。 引用已有画布图片时,将真实 references.id 放到 workflow.nodes[].referenceNodeIds;referenceRefs 优先用于本次方案内其他图片节点的 ref。不要为引用现有图片再生成一张副本,不要在两个字段重复放同一张图。程序会核实真实资源并整理引用,未知ID不能通过。 单段视频可以在正文描述多个叙事节奏,不把它们拆成未合成的几段视频。用户要求15秒时只选实际支持15秒的模型;不存在则解释缺少能力并询问可接受替代,绝不能偷偷缩短。图像引用数量、视频输入角色、尺寸均须符合上下文能力。边表达真实依赖,不为了好看造线。 diff --git a/web/src/lib/plugins/analysis-node-action.ts b/web/src/lib/plugins/analysis-node-action.ts new file mode 100644 index 000000000..9df96f89a --- /dev/null +++ b/web/src/lib/plugins/analysis-node-action.ts @@ -0,0 +1,24 @@ +import { nanoid } from "nanoid"; +import type { CanvasNodeMetadata } from "@/types/canvas"; +import type { PluginAgentAction } from "./plugin-types"; + +export function prepareAnalysisNodeAction(nodeType: string, title: string, metadata: () => CanvasNodeMetadata): PluginAgentAction { + return { + id: "prepare-input", + description: `为已有图片准备${title}节点及连线,复用相同输入的已有节点;仅准备输入,不启动分析或收费。`, + inputSchema: { type: "object", properties: { sourceNodeId: { type: "string" } }, required: ["sourceNodeId"], additionalProperties: false }, + buildOperations(input, snapshot) { + if (Object.keys(input).some((key) => key !== "sourceNodeId")) throw new Error("只接受 sourceNodeId"); + const source = snapshot.nodes.find((node) => node.id === input.sourceNodeId && node.type === "image"); + if (!source || !(source.metadata?.storageKey || source.metadata?.content || source.metadata?.previewContent)) throw new Error("请提供当前画布已有图片节点的真实 ID"); + const existing = snapshot.nodes.find((node) => node.type === nodeType && snapshot.connections.some((edge) => edge.fromNodeId === source.id && edge.toNodeId === node.id)); + if (existing) return [{ type: "select_nodes", ids: [existing.id] }]; + const id = `${nodeType}-${nanoid()}`; + return [ + { type: "add_node", id, nodeType, title, position: { x: Math.max(...snapshot.nodes.map((node) => node.position.x + node.width)) + 120, y: source.position.y }, metadata: metadata() }, + { type: "connect_nodes", fromNodeId: source.id, toNodeId: id }, + { type: "select_nodes", ids: [id] }, + ]; + }, + }; +} diff --git a/web/src/lib/plugins/builtin/ai-art-critique.ts b/web/src/lib/plugins/builtin/ai-art-critique.ts index 6c8829374..451f6c757 100644 --- a/web/src/lib/plugins/builtin/ai-art-critique.ts +++ b/web/src/lib/plugins/builtin/ai-art-critique.ts @@ -1,7 +1,8 @@ import { registerPlugin } from "@/lib/plugins/plugin-registry"; import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types"; -import { ART_CRITIQUE_NODE_TYPE, ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts"; +import { ART_CRITIQUE_NODE_TYPE, ART_CRITIQUE_PLUGIN_ID, artCritiqueSourceFingerprint, createDefaultArtCritiqueState } from "@/lib/art-critique/contracts"; +import { prepareAnalysisNodeAction } from "../analysis-node-action"; const manifest: PluginManifest = { apiVersion: "yingce.plugin/v1", @@ -30,6 +31,15 @@ const manifest: PluginManifest = { }, }; -export const artCritiquePlugin: RegisteredPlugin = { manifest }; +export const artCritiquePlugin: RegisteredPlugin = { + manifest, + agentActions: [prepareAnalysisNodeAction(ART_CRITIQUE_NODE_TYPE, "AI 审美批改", () => ({ artCritique: createDefaultArtCritiqueState() }))], + readAgentNode: (node, snapshot) => { + const state = node.metadata?.artCritique; + const sources = snapshot.nodes.filter((item) => item.type === "image" && snapshot.connections.some((edge) => edge.fromNodeId === item.id && edge.toNodeId === node.id)); + const currentReport = state?.report && sources.length === 1 && state.report.sourceFingerprint === artCritiqueSourceFingerprint(sources[0]) ? state.report : undefined; + return { status: state?.report && !currentReport ? "stale" : state?.status || "idle", stage: state?.analysisStage, stageTaskIds: state?.stageTaskIds, sourceNodeId: state?.sourceNodeId, summary: currentReport?.summary, strengths: currentReport?.strengths, issues: currentReport?.issues, options: currentReport?.options, error: state?.errorMessage, execution: "已有报告仅在输入指纹一致时返回;新分析请使用节点的分析入口,当前 Agent 动作仅准备输入。" }; + }, +}; registerPlugin(artCritiquePlugin); diff --git a/web/src/lib/plugins/builtin/portrait-clearance.ts b/web/src/lib/plugins/builtin/portrait-clearance.ts index 1fabd1616..af99dcbdc 100644 --- a/web/src/lib/plugins/builtin/portrait-clearance.ts +++ b/web/src/lib/plugins/builtin/portrait-clearance.ts @@ -1,7 +1,8 @@ import { registerPlugin } from "@/lib/plugins/plugin-registry"; import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types"; -import { PORTRAIT_CLEARANCE_NODE_TYPE, PORTRAIT_CLEARANCE_PLUGIN_ID } from "@/lib/portrait-clearance/contracts"; +import { PORTRAIT_CLEARANCE_NODE_TYPE, PORTRAIT_CLEARANCE_PLUGIN_ID, createDefaultPortraitClearanceState } from "@/lib/portrait-clearance/contracts"; +import { prepareAnalysisNodeAction } from "../analysis-node-action"; const manifest: PluginManifest = { apiVersion: "yingce.plugin/v1", @@ -35,6 +36,13 @@ const manifest: PluginManifest = { }, }; -export const portraitClearancePlugin: RegisteredPlugin = { manifest }; +export const portraitClearancePlugin: RegisteredPlugin = { + manifest, + agentActions: [prepareAnalysisNodeAction(PORTRAIT_CLEARANCE_NODE_TYPE, "肖像可识别性排查", () => ({ portraitClearance: createDefaultPortraitClearanceState() }))], + readAgentNode: (node) => { + const state = node.metadata?.portraitClearance; + return { mode: state?.mode, task: state?.task, lastResult: state?.lastResult, execution: "仅返回已观测任务和结果;新分析仍通过插件自己的任务入口启动。" }; + }, +}; registerPlugin(portraitClearancePlugin); diff --git a/web/src/lib/plugins/plugin-registry.ts b/web/src/lib/plugins/plugin-registry.ts index d0c272882..94cbae490 100644 --- a/web/src/lib/plugins/plugin-registry.ts +++ b/web/src/lib/plugins/plugin-registry.ts @@ -51,6 +51,11 @@ function assertManifestV2(manifest: PluginManifestV2) { export function registerPlugin(plugin: RegisteredPlugin) { assertManifest(plugin.manifest); + const actionIds = new Set(); + for (const action of plugin.agentActions || []) { + if (!action.id.trim() || !action.description.trim() || actionIds.has(action.id) || typeof action.buildOperations !== "function") throw new Error("插件 Agent 动作需要唯一 ID、说明和操作构建器"); + actionIds.add(action.id); + } const existing = registeredPlugins.get(plugin.manifest.id); if (existing && existing.manifest.version !== plugin.manifest.version) { throw new Error(`插件 ${plugin.manifest.id} 已注册其他版本`); diff --git a/web/src/lib/plugins/plugin-types.ts b/web/src/lib/plugins/plugin-types.ts index 374a16b9a..efc6a413f 100644 --- a/web/src/lib/plugins/plugin-types.ts +++ b/web/src/lib/plugins/plugin-types.ts @@ -317,7 +317,18 @@ export type AssetSourceProvider = { openAsset?: (item: ExternalAssetItem) => Promise; }; +export type PluginAgentAction = { + id: string; + description: string; + inputSchema: Record; + /** Pure planner: validate input and return operations, never perform side effects here. */ + buildOperations: (input: Record, snapshot: import("@/lib/canvas/canvas-agent-ops").CanvasAgentSnapshot) => import("@/lib/canvas/canvas-agent-ops").CanvasAgentOp[]; +}; + export type RegisteredPlugin = { + agentActions?: PluginAgentAction[]; + /** Pure, bounded projection: never return embedded media or provider credentials. */ + readAgentNode?: (node: import("@/types/canvas").CanvasNodeData, snapshot: import("@/lib/canvas/canvas-agent-ops").CanvasAgentSnapshot) => Record; /** v1 或 v2 插件清单;v2 清单结构为 v1 超集(含 editorSlots 声明)。 */ manifest: PluginManifest | PluginManifestV2; source?: "bundled" | "uploaded" | string; diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 516ba3dc4..9388d48b8 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1526,7 +1526,8 @@ function InfiniteCanvasPage() { focusSelection: fitCanvasSelection, }); - const { selectCanvasStyle, styleApplying } = useCanvasStyleWorkflow({ + const { selectCanvasStyle, applyCanvasStyleAsync, styleApplying } = useCanvasStyleWorkflow({ + canvasId: projectId, domainProjectId: currentProject?.projectId, nodesRef, selectedNodeIdsRef, @@ -2484,6 +2485,7 @@ function InfiniteCanvasPage() { onSelectNodeIds={setSelectedNodeIds} onSessionsChange={handleAssistantSessionsChange} onApplyOps={applyAgentOps} + onApplyStyle={applyCanvasStyleAsync} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onUndoOps={undoAgentOps} diff --git a/web/src/pages/canvas/use-canvas-style-workflow.ts b/web/src/pages/canvas/use-canvas-style-workflow.ts index 24a2fb71d..efb3c59c0 100644 --- a/web/src/pages/canvas/use-canvas-style-workflow.ts +++ b/web/src/pages/canvas/use-canvas-style-workflow.ts @@ -1,4 +1,4 @@ -import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { App } from "antd"; @@ -7,8 +7,10 @@ import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; import { createStyleProfileSnapshot, serializeStyleProfile } from "@/lib/canvas/style-profile"; import { updateProject as updateDomainProject } from "@/services/api/projects"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas"; +import { getActiveUserScope } from "@/lib/user-scope"; type UseCanvasStyleWorkflowOptions = { + canvasId: string; domainProjectId?: string; nodesRef: { current: CanvasNodeData[] }; selectedNodeIdsRef: { current: Set }; @@ -20,9 +22,13 @@ type UseCanvasStyleWorkflowOptions = { setStylePickerOpen: Dispatch>; }; -export function useCanvasStyleWorkflow({ domainProjectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setStylePickerOpen }: UseCanvasStyleWorkflowOptions) { +export function useCanvasStyleWorkflow({ canvasId, domainProjectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setStylePickerOpen }: UseCanvasStyleWorkflowOptions) { const { message } = App.useApp(); const queryClient = useQueryClient(); + const liveContext = useRef({ canvasId, domainProjectId, mounted: true }); + liveContext.current = { canvasId, domainProjectId, mounted: liveContext.current.mounted }; + const applyingRef = useRef(false); + useEffect(() => { liveContext.current.mounted = true; return () => { liveContext.current.mounted = false; }; }, []); const applyCanvasStyle = useCallback( (preset: CanvasStylePreset, profileJson: string) => { @@ -66,25 +72,29 @@ export function useCanvasStyleWorkflow({ domainProjectId, nodesRef, selectedNode if (!domainProjectId) throw new Error("画布尚未关联项目"); return updateDomainProject(domainProjectId, { stylePresetId: preset.id, styleProfileJson: profileJson }); }, - onSuccess: (_project, { preset, profileJson }) => { - applyCanvasStyle(preset, profileJson); - void queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] }); - }, - onError: (error) => message.error(error instanceof Error ? error.message : "项目画风保存失败"), }); + const applyCanvasStyleAsync = useCallback(async (preset: CanvasStylePreset) => { + if (applyingRef.current) throw new Error("画风正在保存,请稍后重试"); + const scope = getActiveUserScope(); + applyingRef.current = true; + try { + const profileJson = serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset)); + if (domainProjectId) { + await persistStyleMutation.mutateAsync({ preset, profileJson }); + void queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] }); + } + if (!liveContext.current.mounted || liveContext.current.canvasId !== canvasId || liveContext.current.domainProjectId !== domainProjectId || getActiveUserScope() !== scope) throw new Error("页面或账号已变化,未向当前画布应用画风;如项目保存已完成,请回原项目查看"); + applyCanvasStyle(preset, profileJson); + } finally { applyingRef.current = false; } + }, [canvasId, domainProjectId, persistStyleMutation.mutateAsync, queryClient, applyCanvasStyle]); + const selectCanvasStyle = useCallback( (preset: CanvasStylePreset) => { - if (persistStyleMutation.isPending) return; - const profileJson = serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset)); - if (!domainProjectId) { - applyCanvasStyle(preset, profileJson); - return; - } - persistStyleMutation.mutate({ preset, profileJson }); + void applyCanvasStyleAsync(preset).catch((error) => message.error(error instanceof Error ? error.message : "项目画风保存失败")); }, - [applyCanvasStyle, domainProjectId, persistStyleMutation], + [applyCanvasStyleAsync, message], ); - return { selectCanvasStyle, styleApplying: persistStyleMutation.isPending }; + return { selectCanvasStyle, applyCanvasStyleAsync, styleApplying: persistStyleMutation.isPending }; } diff --git a/web/src/services/agent-capabilities.ts b/web/src/services/agent-capabilities.ts new file mode 100644 index 000000000..57d34c28d --- /dev/null +++ b/web/src/services/agent-capabilities.ts @@ -0,0 +1,52 @@ +import { getNodeDefinition, listNodeDefinitions } from "@/lib/canvas/node-registry"; +import { getRegisteredPlugin, listRegisteredPlugins } from "@/lib/plugins/plugin-registry"; +import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store"; +import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; + +// Query live registries on every discovery; installation is not execution availability. +export function listAgentCapabilities(query = "") { + const nodes = listNodeDefinitions().filter((node) => !node.plugin || isPluginEffectivelyEnabled(node.plugin.pluginId)).map((node) => ({ + id: node.type, name: node.label, source: node.plugin?.pluginId || "builtin", + scope: "canvas", actions: node.type === "script" ? ["create", "canvas_read_storyboard", "canvas_edit_storyboard"] : ["create", "read", "update"], + defaultMetadata: node.defaultMetadata, defaultSize: node.defaultSize, + inputKind: node.acceptsInputKind, schema: node.plugin?.schema, + execution: node.plugin ? "仅开放节点创建和数据更新;插件专用执行需提供动作适配器,不能用媒体生成替代。" : "通过已有画布工具操作;专业节点不等同于普通媒体生成。", + })); + const plugins = listRegisteredPlugins().filter((plugin) => isPluginEffectivelyEnabled(plugin.manifest.id)).map(({ manifest, agentActions, readAgentNode }) => ({ + id: manifest.id, name: manifest.name, source: manifest.id, version: manifest.version, + description: manifest.description, contributions: manifest.contributes, + actions: (agentActions || []).map(({ id, description, inputSchema }) => ({ id, description, inputSchema })), + readTool: readAgentNode ? "canvas_read_plugin_node" : undefined, + execution: agentActions?.length ? "使用 canvas_plugin_action 调用 actions 中的真实动作。" : "贡献声明是发现信息,不是任意命令执行入口。只调用已挂载工具。", + })); + const terms = query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean); + return [...nodes, ...plugins, { id: "project-style", name: "项目画风", source: "builtin", actions: ["canvas_list_styles", "canvas_apply_style"], scope: "canvas", description: "读取系统或个人风格,通过同一保存入口应用画风并同步关联项目。" }].filter((item) => terms.every((term) => JSON.stringify(item).toLocaleLowerCase().includes(term))); +} + +export function readAgentPluginNode(snapshot: CanvasAgentSnapshot, nodeId: string) { + const node = snapshot.nodes.find((item) => item.id === nodeId); + const owner = node && getNodeDefinition(node.type)?.plugin?.pluginId; + const plugin = owner && isPluginEffectivelyEnabled(owner) ? getRegisteredPlugin(owner) : undefined; + if (!node || !plugin?.readAgentNode || !plugin.manifest.permissions.includes("canvas.read")) throw new Error("当前插件未启用或未提供节点读取能力"); + return { nodeId, pluginId: owner, version: plugin.manifest.version, data: plugin.readAgentNode(structuredClone(node), structuredClone(snapshot)) }; +} + +export function readAgentPluginDocumentation(pluginId: string) { + const plugin = getRegisteredPlugin(pluginId); + if (!plugin || !isPluginEffectivelyEnabled(pluginId)) throw new Error("插件不可用,请重新发现能力"); + return { id: pluginId, version: plugin.manifest.version, documentation: plugin.manifest.documentation || plugin.manifest.description }; +} + +export function buildAgentPluginOperations(pluginId: string, actionId: string, input: Record, snapshot: CanvasAgentSnapshot) { + const plugin = getRegisteredPlugin(pluginId); + if (!plugin || !isPluginEffectivelyEnabled(pluginId)) throw new Error("插件已停用或不可用"); + const action = plugin.agentActions?.find((item) => item.id === actionId); + if (!action) throw new Error("插件未提供此 Agent 动作,请先读取能力目录"); + if (!plugin.manifest.permissions.includes("canvas.write")) throw new Error("插件未声明画布写入权限"); + const operations = action.buildOperations(structuredClone(input), structuredClone(snapshot)); + if (!Array.isArray(operations) || !operations.length || operations.length > 100) throw new Error("插件必须返回 1 至 100 个画布操作"); + if (operations.some((op) => op.type === "run_generation") && !plugin.manifest.permissions.includes("generation.run")) throw new Error("插件未声明生成权限"); + return operations; +} + +export const AGENT_CAPABILITY_GUIDANCE = "处理专业任务前先调用 canvas_list_capabilities 查找系统现有节点和已启用插件,并用 canvas_list_skills 检索相关专业技能、读取 SKILL.md 后按需使用;用户不必手动选择技能。不要用普通文本模拟分镜表或插件结果。发现信息不代表已经执行,未挂载的专用动作须如实说明。已授权范围内自主推进,不重复征求技能读取或普通操作许可。"; diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index ac24f23df..5da06aa4e 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -143,6 +143,8 @@ type BackendToolGenerationOptions = { toolChoice: ToolChoice; signal?: AbortSignal; onDelta?: (text: string) => void; + onTaskCreated?: (task: GenerationTask) => void; + metadata?: { source: string; nodeId?: string; runId?: string; stage?: string }; }; // 报价和执行复用完全相同的任务协议,准备阶段不提交模型任务。 @@ -174,7 +176,7 @@ export function prepareBackendToolGenerationTask(options: BackendToolGenerationO config: backendProviderConfig(options.config, "text"), agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice, options.config), referenceImages: [...imageKeys].map((storageKey) => ({ storageKey })), - metadata: { source: "canvas-online-agent" }, + metadata: options.metadata || { source: "canvas-online-agent" }, }, }; if (new Blob([JSON.stringify(task)]).size > 15 * 1024 * 1024) { @@ -185,6 +187,7 @@ export function prepareBackendToolGenerationTask(options: BackendToolGenerationO export async function runBackendToolGenerationTask(options: BackendToolGenerationOptions): Promise { const task = await createGenerationTask(prepareBackendToolGenerationTask(options)); + options.onTaskCreated?.(task); const completed = await waitForGenerationTask(task.id, { signal: options.signal, initialTask: task, onTextDelta: options.onDelta }); const result = parseBackendGenerationResult(completed); return { diff --git a/web/src/services/art-critique-execution.ts b/web/src/services/art-critique-execution.ts new file mode 100644 index 000000000..892c0252c --- /dev/null +++ b/web/src/services/art-critique-execution.ts @@ -0,0 +1,52 @@ +import { inspectAgentImage } from "./agent-image-preview"; +import { runBackendToolGenerationTask } from "./api/generation-task"; +import { runArtCritiquePipeline, type ArtCritiquePipelineOptions } from "@/lib/art-critique/pipeline"; +import { artCritiqueSourceFingerprint, isArtCritiqueImageInput } from "@/lib/art-critique/contracts"; +import type { CanvasNodeData } from "@/types/canvas"; +import type { AiConfig } from "@/stores/use-config-store"; +import { getActiveUserScope } from "@/lib/user-scope"; + +export const ART_CRITIQUE_MAX_MODEL_CALLS = 9; + +// One execution boundary for manual UI and future approved Agent runs. +// The executor may be replaced by an approval-aware task submitter; parsing stays in the pipeline. +export async function executeArtCritique(input: { + nodeId: string; + runId: string; + source: CanvasNodeData; + config: AiConfig; + signal: AbortSignal; + onTaskCreated: (stage: string, taskId: string) => void; + onStage?: ArtCritiquePipelineOptions["onStage"]; + onDraftReport?: ArtCritiquePipelineOptions["onDraftReport"]; +}, dependencies = { inspectImage: inspectAgentImage, runTask: runBackendToolGenerationTask, runPipeline: runArtCritiquePipeline }) { + if (!input.nodeId || !input.runId || !isArtCritiqueImageInput(input.source)) throw new Error("分析需要真实目标节点、执行标识和已有图片"); + const scope = getActiveUserScope(); + const assertLive = () => { + if (input.signal.aborted || getActiveUserScope() !== scope) throw new DOMException("分析已停止或账号已切换", "AbortError"); + }; + assertLive(); + const content = input.source.metadata?.content || input.source.metadata?.previewContent || ""; + const image = await dependencies.inspectImage({ storageKey: input.source.metadata?.storageKey, ...(content.startsWith("data:") ? { dataUrl: content } : { url: content }), name: input.source.title }, "original"); + assertLive(); + const calledStages = new Set(); + return dependencies.runPipeline(input.config, { dataUrl: image.storageKey, title: input.source.title, sourceFingerprint: artCritiqueSourceFingerprint(input.source) }, { + signal: input.signal, + onStage: (stage) => { assertLive(); input.onStage?.(stage); }, + onDraftReport: (report) => { assertLive(); input.onDraftReport?.(report); }, + requestStage: async ({ config, messages, tool, toolName, signal }) => { + assertLive(); + if (calledStages.has(toolName) || calledStages.size >= ART_CRITIQUE_MAX_MODEL_CALLS) throw new Error("分析阶段重复或超过本次调用数量上限"); + calledStages.add(toolName); + const result = await dependencies.runTask({ + config, messages, tools: [tool], toolChoice: { type: "function", name: toolName }, + prompt: `图片审美分析:${toolName}`, signal, + metadata: { source: "art-critique", nodeId: input.nodeId, runId: input.runId, stage: toolName }, + // A task can be accepted while the user stops observing; retain that receipt. + onTaskCreated: (task) => { if (getActiveUserScope() === scope) input.onTaskCreated(toolName, task.id); }, + }); + assertLive(); + return result; + }, + }); +} diff --git a/web/src/services/skill-runtime.ts b/web/src/services/skill-runtime.ts index e8e6fa273..e68715f89 100644 --- a/web/src/services/skill-runtime.ts +++ b/web/src/services/skill-runtime.ts @@ -130,7 +130,7 @@ function functionTool(name: string, description: string, properties: Record, skills: Skill[]) => Promise> = { - canvas_list_skills: async (_args, skills) => { - const data = skills.filter((skill) => skill.is_added).map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, tag: skill.tag, version: skill.version, fileCount: skill.file_count, sourceType: skill.source_type })); - return { ok: true, message: data.length ? "已列出当前可用技能。" : "当前没有已加入技能。", data }; + canvas_list_skills: async (args, skills) => { + const terms = typeof args.query === "string" ? args.query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) : []; + const matches = skills.filter((skill) => skill.is_added && terms.every((term) => `${skill.skill_name} ${skill.description} ${skill.tag}`.toLocaleLowerCase().includes(term))); + const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0; + const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.max(1, Math.min(100, Math.floor(args.limit))) : 40; + const items = matches.slice(offset, offset + limit).map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, tag: skill.tag, version: skill.version, fileCount: skill.file_count, sourceType: skill.source_type })); + return { ok: true, message: items.length ? "已发现相关技能,请按需读取入口;无匹配时可缩短关键词或查询完整目录。" : "没有匹配技能,可调整关键词。", data: { items, total: matches.length, nextOffset: offset + items.length < matches.length ? offset + items.length : null } }; }, canvas_get_skill: async (args, skills) => { const skill = requireAddedSkill(skills, args); diff --git a/web/test/agent-capabilities.test.ts b/web/test/agent-capabilities.test.ts new file mode 100644 index 000000000..68ec18fca --- /dev/null +++ b/web/test/agent-capabilities.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, test } from "bun:test"; +import { registerPlugin, unregisterPlugin } from "../src/lib/plugins/plugin-registry"; +import { usePluginStore } from "../src/stores/use-plugin-store"; +import { listAgentCapabilities, buildAgentPluginOperations } from "../src/services/agent-capabilities"; +import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops"; +import { normalizeCreativeProposal, creativeProposalOps } from "../src/lib/creation/creative-agent-state"; +import { defaultConfig } from "../src/stores/use-config-store"; + +const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } }; +const pluginId = "agent-extension-test"; +afterEach(() => { unregisterPlugin(pluginId); usePluginStore.setState({ runtimeStatuses: {} }); }); + +test("专业方案创建可编辑分镜与上下文连线,不伪造画风已选择或收费任务", () => { + const proposal = normalizeCreativeProposal({ title: "短剧", summary: "搭建", markdown: "完整方案", workflow: { nodes: [ + { ref: "style", kind: "styleboard", title: "项目画风" }, + { ref: "story", kind: "story_input", title: "故事", content: "两个人重逢" }, + { ref: "shots", kind: "script", title: "分镜", shots: [{ durationSeconds: 5, videoMotionPrompt: "推近人物", dialogue: "好久不见" }] }, + ], edges: [{ from: "style", to: "shots" }, { from: "story", to: "shots" }] }, generationItems: [] }, "p", 1, defaultConfig); + const result = applyCanvasAgentOps(snapshot, creativeProposalOps("run", proposal, snapshot, defaultConfig)); + expect(result.nodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.content).toBe(""); + const script = result.nodes.find((node) => node.type === "script")!; + expect(script.metadata?.storyboard?.rows[0]?.dialogue).toBe("好久不见"); + expect(result.connections.every((edge) => edge.toHandleId === "storyboard:context")).toBe(true); + expect(proposal.generationItems).toHaveLength(0); +}); + +test("插件注册后可发现和创建真实类型,停用后立即拒绝新调用", () => { + registerPlugin({ manifest: { id: pluginId, name: "测试扩展", version: "1", apiVersion: "yingce.plugin/v1", description: "测试", surfaces: ["node"], permissions: ["canvas.write"], runtime: { web: "declarative" }, contributes: { canvasNodes: [{ id: "test-special-node", label: "专用节点", defaultTitle: "测试", defaultSize: { width: 400, height: 300 }, schema: {}, renderer: "declarative" }] } }, agentActions: [{ id: "create", description: "创建", inputSchema: {}, buildOperations: () => [{ type: "add_node", nodeType: "test-special-node" }] }] }); + usePluginStore.setState({ runtimeStatuses: { [pluginId]: "enabled" } }); + expect(listAgentCapabilities("专用节点").length).toBeGreaterThan(0); + const ops = buildAgentPluginOperations(pluginId, "create", {}, snapshot); + expect(applyCanvasAgentOps(snapshot, ops).nodes[0]?.type).toBe("test-special-node"); + usePluginStore.setState({ runtimeStatuses: { [pluginId]: "disabled" } }); + expect(listAgentCapabilities("专用节点")).toHaveLength(0); + expect(() => buildAgentPluginOperations(pluginId, "create", {}, snapshot)).toThrow(); + expect(() => applyCanvasAgentOps(snapshot, ops)).toThrow(); +}); + +test("未知节点不能静默降级成文本", () => { + expect(() => applyCanvasAgentOps(snapshot, [{ type: "add_node", nodeType: "missing-plugin-node" }])).toThrow(); +}); diff --git a/web/test/agent-storyboard.test.ts b/web/test/agent-storyboard.test.ts new file mode 100644 index 000000000..4b3cf9662 --- /dev/null +++ b/web/test/agent-storyboard.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { buildAgentStoryboardOperations } from "../src/lib/canvas/canvas-agent-storyboard"; +import { createShortDramaPipeline } from "../src/lib/canvas/canvas-short-drama"; +import { createStoryboardRow } from "../src/lib/canvas/canvas-project-domain"; +import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops"; +import { prepareAnalysisNodeAction } from "../src/lib/plugins/analysis-node-action"; + +function fixture() { + const pipeline = createShortDramaPipeline({ x: 0, y: 0 }); + const script = pipeline.nodes.find((node) => node.type === "script")!; + script.metadata!.storyboard!.rows = [createStoryboardRow(1, { id: "row-1", videoNodeId: "video-1", imageNodeId: "image-1", dialogue: "旧对白" }), createStoryboardRow(2, { id: "row-2" })]; + const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: pipeline.nodes, connections: pipeline.connections, selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } }; + return { snapshot, script }; +} + +test("按行修改保留其他镜头及已有生成素材关联", () => { + const { snapshot, script } = fixture(); + const next = applyCanvasAgentOps(snapshot, buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch: { dialogue: "新对白", durationSeconds: 8 } })); + const rows = next.nodes.find((node) => node.id === script.id)!.metadata!.storyboard!.rows; + expect(rows[0]).toMatchObject({ id: "row-1", dialogue: "新对白", durationSeconds: 8, imageNodeId: "image-1", videoNodeId: "video-1" }); + expect(rows[1]).toEqual(script.metadata!.storyboard!.rows[1]); +}); + +test("移除分镜只更新表格,保留行 ID 并重排序号", () => { + const { snapshot, script } = fixture(); + const ops = buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "row-1" }); + expect(ops).toHaveLength(1); + expect(ops[0].type).toBe("update_node"); + if (ops[0].type === "update_node") expect(ops[0].metadata?.storyboard?.rows[0]).toMatchObject({ id: "row-2", shotNumber: 1 }); +}); + +test("分镜编辑拒绝伪造结果、未知行和非法时长", () => { + const { snapshot, script } = fixture(); + for (const patch of [{ status: "success" }, { videoNodeId: "fake" }, { durationSeconds: -1 }]) { + expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch })).toThrow(); + } + expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "missing" })).toThrow(); +}); + +test("分析节点准备复用相同输入且不启动模型", () => { + const { snapshot, script } = fixture(); + snapshot.nodes.push({ ...script, id: "source", type: "image", metadata: { storageKey: "resource:image" } }); + const action = prepareAnalysisNodeAction("test-analysis", "分析", () => ({})); + const ops = action.buildOperations({ sourceNodeId: "source" }, snapshot); + expect(ops.some((op) => op.type === "run_generation")).toBe(false); + expect(ops.some((op) => op.type === "connect_nodes")).toBe(true); + snapshot.nodes.push({ ...script, id: "analysis", type: "test-analysis" }); + snapshot.connections.push({ id: "edge", fromNodeId: "source", toNodeId: "analysis" }); + expect(action.buildOperations({ sourceNodeId: "source" }, snapshot)).toEqual([{ type: "select_nodes", ids: ["analysis"] }]); +}); diff --git a/web/test/art-critique-execution.test.ts b/web/test/art-critique-execution.test.ts new file mode 100644 index 000000000..24f08e4ea --- /dev/null +++ b/web/test/art-critique-execution.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { executeArtCritique } from "../src/services/art-critique-execution"; +import { defaultConfig } from "../src/stores/use-config-store"; +import { createCanvasNode } from "../src/lib/canvas/canvas-project-domain"; +import { CanvasNodeType } from "../src/types/canvas"; +import type { ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline"; + +const source = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "resource:original" }); +const options = () => ({ nodeId: "analysis", runId: "run", source, config: defaultConfig, signal: new AbortController().signal, onTaskCreated: (_stage: string, _id: string) => {} }); + +test("分析阶段使用资源引用、记录后台任务并拒绝重复阶段提交", async () => { + let submitted = 0; + const created: string[] = []; + const input = options(); + input.onTaskCreated = (stage, id) => created.push(`${stage}:${id}`); + await executeArtCritique(input, { + inspectImage: async () => ({ storageKey: "resource:original", encodedBytes: 30 }), + runTask: async (request) => { + submitted++; + expect(JSON.stringify(request.messages)).toContain("resource:original"); + expect(request.metadata).toMatchObject({ source: "art-critique", runId: "run", nodeId: "analysis" }); + request.onTaskCreated?.({ id: "task-1" } as never); + return { content: "{}", toolCalls: [] }; + }, + runPipeline: async (config, image, opts: ArtCritiquePipelineOptions = {}) => { + expect(image.dataUrl).toBe("resource:original"); + const stage = { config, messages: [{ role: "user" as const, content: [{ type: "image_url" as const, image_url: { url: image.dataUrl } }] }], tool: { type: "function" as const, name: "review", description: "review", parameters: {} }, toolName: "review", signal: input.signal }; + await opts.requestStage!(stage); + await expect(opts.requestStage!(stage)).rejects.toThrow("重复"); + return {} as never; + }, + }); + expect(submitted).toBe(1); + expect(created).toEqual(["review:task-1"]); +}); + +test("准备素材期间取消不会提交后台模型任务", async () => { + const controller = new AbortController(); + let submitted = false; + await expect(executeArtCritique({ ...options(), signal: controller.signal }, { + inspectImage: async () => { controller.abort(); return { storageKey: "resource:image", encodedBytes: 30 }; }, + runTask: async () => { submitted = true; return { content: "", toolCalls: [] }; }, + runPipeline: async () => { throw new Error("不应开始分析"); }, + })).rejects.toThrow(); + expect(submitted).toBe(false); +}); diff --git a/web/test/skill-runtime.test.ts b/web/test/skill-runtime.test.ts index e5c112943..b9ff80239 100644 --- a/web/test/skill-runtime.test.ts +++ b/web/test/skill-runtime.test.ts @@ -54,6 +54,15 @@ function file(path: string, content: string, kind: SkillPackageFile["kind"] = "m } describe("skill runtime", () => { + test("可检索首屏之外的技能并分页,不暴露未加入技能", async () => { + const runtime = createSkillRuntime(); + const skills = Array.from({ length: 45 }, (_, index) => skill({ skill_id: `skill-${index}`, skill_name: `技能 ${index}`, description: index === 44 ? "商品构图" : "通用" })); + skills.push(skill({ skill_id: "hidden", description: "商品构图", is_added: false })); + const first = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", {}, skills); + expect(first?.ok && first.data).toMatchObject({ total: 45, nextOffset: 40 }); + const found = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", { query: "商品" }, skills); + expect(found?.ok && found.data).toMatchObject({ total: 1, nextOffset: null, items: [{ skillId: "skill-44" }] }); + }); test("技能引用解析由统一规则同时支持稳定 token 和自然提及", () => { const director = skill(); const storyboard = skill({ skill_id: "storyboard", skill_name: "小说转分镜" });