From b33de36976c58eb5bf7eb4441d89fbfa540c6bec 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 23:28:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E7=BB=9F=E4=B8=80=E4=B8=93?= =?UTF-8?q?=E4=B8=9A=E6=8B=86=E9=95=9C=E5=85=A5=E5=8F=A3=E4=B8=8E=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E9=95=9C=E5=A4=B4=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/creation.go | 6 +- backend/internal/service/creation_test.go | 24 ++++++ docs/design/agent-capabilities.mdx | 4 + .../canvas/canvas-assistant-panel.tsx | 17 +++- .../components/canvas/canvas-script-node.tsx | 12 +-- web/src/lib/canvas/canvas-agent-storyboard.ts | 8 ++ web/src/lib/creation/creative-agent-tools.ts | 2 +- web/src/pages/canvas/project.tsx | 1 + web/src/pages/canvas/use-canvas-storyboard.ts | 79 ++++++++++++++++--- web/src/services/storyboard-submission.ts | 35 ++++++++ web/test/agent-storyboard.test.ts | 12 ++- web/test/storyboard-submission.test.ts | 37 +++++++++ 12 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 web/src/services/storyboard-submission.ts create mode 100644 web/test/storyboard-submission.test.ts diff --git a/backend/internal/service/creation.go b/backend/internal/service/creation.go index 657a2ed24..e0e3b5252 100644 --- a/backend/internal/service/creation.go +++ b/backend/internal/service/creation.go @@ -343,10 +343,10 @@ func (s *Service) prepareCreationTask(userID string, req CreateTaskRequest) (*mo if err := validateCreationJSON(req); err != nil { return nil, nil, "", err } - if req.Type != "canvas_text" && req.Type != "text" && req.Type != "canvas_image" && req.Type != "canvas_video" { + if req.Type != "canvas_text" && req.Type != "text" && req.Type != "agent_storyboard_rows" && req.Type != "canvas_image" && req.Type != "canvas_video" { return nil, nil, "", BadAuthRequest("智能创作任务类型不受支持") } - expectedMode := map[string]string{"text": "text", "canvas_text": "text", "canvas_image": "image", "canvas_video": "video"}[req.Type] + expectedMode := map[string]string{"text": "text", "canvas_text": "text", "agent_storyboard_rows": "text", "canvas_image": "image", "canvas_video": "video"}[req.Type] if stringValue(req.Input["mode"]) != expectedMode || strings.TrimSpace(stringValue(req.Input["prompt"])) != strings.TrimSpace(req.Prompt) { return nil, nil, "", BadAuthRequest("任务类型、模式和实际提示词必须一致") } @@ -478,7 +478,7 @@ func validateCreationSubmissionScope(run *model.CreationRun, version int64, req if run.Status == "paused" || run.Status == "cancelled" || run.Status == "completed" { return creationConflict("请先恢复创作任务") } - if req.Type == "canvas_text" || req.Type == "text" { + if req.Type == "canvas_text" || req.Type == "text" || req.Type == "agent_storyboard_rows" { if req.ProjectID != "" && req.ProjectID != run.CanvasID { return creationConflict("规划任务的画布关联不匹配") } diff --git a/backend/internal/service/creation_test.go b/backend/internal/service/creation_test.go index 4ab78b240..b3f6058fe 100644 --- a/backend/internal/service/creation_test.go +++ b/backend/internal/service/creation_test.go @@ -43,6 +43,30 @@ func creationTestService(t *testing.T) (*Service, *gorm.DB, string, CreationGuar func creationTextRequest() CreateTaskRequest { return CreateTaskRequest{Type: "canvas_text", Prompt: "只输出测试", Model: "text-test", Input: map[string]any{"mode": "text", "prompt": "只输出测试", "config": map[string]any{"channelId": "channel", "model": "text-test"}}} } + +func TestCreationStoryboardRequiresApprovalAndCanvasScope(t *testing.T) { + s, _, id, guard := creationTestService(t) + req := creationTextRequest() + req.Type = "agent_storyboard_rows" + req.Operation = "storyboard_rows" + item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "storyboard", Request: req}) + if err != nil { + t.Fatal(err) + } + if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err == nil { + t.Fatal("unapproved storyboard executed") + } + if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err != nil { + t.Fatal(err) + } + task, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}) + if err != nil || task.Type != "agent_storyboard_rows" { + t.Fatalf("professional storyboard not submitted: task=%v error=%v", task, err) + } + if err = validateCreationSubmissionScope(&model.CreationRun{CanvasID: "canvas", Status: "running"}, 0, CreateTaskRequest{Type: "agent_storyboard_rows", ProjectID: "other"}); err == nil { + t.Fatal("cross-canvas storyboard accepted") + } +} func prepareApprovedCreation(t *testing.T, s *Service, id string, guard CreationGuard) *CreationSubmissionOutput { t.Helper() item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-1", Request: creationTextRequest()}) diff --git a/docs/design/agent-capabilities.mdx b/docs/design/agent-capabilities.mdx index 72c778f08..3ac0d1b6d 100644 --- a/docs/design/agent-capabilities.mdx +++ b/docs/design/agent-capabilities.mdx @@ -15,6 +15,10 @@ description: 在线画布助手的节点发现、技能渐进读取和插件动 ## 专业短剧节点 +Agent 拆镜与分镜节点底部输入共用 `generateScriptRows`:调用 `canvas_generate_storyboard` 后,由现有 `agent_storyboard_rows` 任务结合画风、角色、画布资产和短剧技能生成真实行。原始剧本保存在 `composerContent`;没有节点时可用 `canvas_create_storyboard` 创建明确标为待拆镜的空表,此时不得宣称镜头已完成。此入口用于已获准的搭建任务,不取消创作方案确认;通用方案直接交付镜头时仍要求完整 `script.shots`。 + +受管模型先用 `creation-runs` 准备真实报价,用户确认后批准并提交;外部自定义渠道沿用明确说明外部费用的提交确认,不伪造价格。整表重拆确认会说明替换镜头行、保留原媒体;取消或报价过期不提交。局部修改继续使用 `canvas_edit_storyboard`。专业拆镜完成后 Agent 继续读取真实表格检查,再进行后续操作;取消时停止本轮。单镜时长选项不表示总片长,总片长应在剧本要求中说明。 + 创作方案和通用工作流支持 `styleboard`、`story_input`、`script`,复用手动短剧入口的数据结构。画风节点初始为空,显示系统已有的选择入口;故事节点使用专用编辑卡片;分镜节点使用真实表格。 `script.shots` 必须逐镜填写正数 `durationSeconds` 和非空 `videoMotionPrompt`,`dialogue` 按需填写,最多 100 行,写入稳定行 ID。仅有正文或提示词的分镜方案会返回校验错误供模型修正;通用节点创建工具也校验真实分镜行,不能以空表代替交付。方案连到分镜节点时使用 `storyboard:context` 输入。仅创建这些节点不产生媒体生成项;图像、视频仍沿用既有方案和费用确认。 diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 1e7620e73..11f2ebcbf 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -15,7 +15,7 @@ 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, validateAgentStoryboardCreation } from "@/lib/canvas/canvas-agent-storyboard"; +import { buildAgentStoryboardDraft, buildAgentStoryboardOperations, readAgentStoryboard, validateAgentStoryboardCreation } 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"; @@ -137,6 +137,8 @@ function generationToolDefinition(name: string, description: string, mode?: "tex const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [ toolDefinition("canvas_start_art_critique", "打开真实审美节点并准备分析报价,费用卡确认后才提交模型;不会由本工具批准费用。已有当前图片报告直接复用,只有用户明确要求重新分析才传 retry=true。", { nodeId: { type: "string" }, retry: { type: "boolean" } }, ["nodeId"]), + toolDefinition("canvas_create_storyboard", "创建待拆镜的专业分镜节点,将原始剧本放入底部输入框。此时表格为空,不能声称镜头已完成;随后调用 canvas_generate_storyboard。已有分镜节点优先复用。", { title: { type: "string" }, prompt: { type: "string" }, x: { type: "number" }, y: { type: "number" } }, ["title", "prompt"]), + toolDefinition("canvas_generate_storyboard", "使用分镜节点原有专业拆镜任务,结合项目画风、角色、素材及技能将剧本拆成真实镜头。程序展示费用与整表替换确认,确认后执行并等待结果;取消后不重复发起。局部修改使用 canvas_edit_storyboard。", { nodeId: { type: "string" }, prompt: { type: "string", description: "原始剧本与拆镜要求;省略时使用节点底部剧本" } }, ["nodeId"]), 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"]), @@ -288,6 +290,7 @@ type CanvasAssistantPanelProps = { onApplyOps: (ops?: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise; onApplyStyle: (preset: CanvasStylePreset) => Promise; onStartArtCritique: (nodeId: string, restart: boolean) => void; + onGenerateStoryboard: (nodeId: string, prompt: string, signal?: AbortSignal) => Promise; canUndoOps: boolean; undoOpsCount: number; onUndoOps: () => CanvasAgentSnapshot | null; @@ -381,6 +384,7 @@ export function CanvasAssistantPanel({ onApplyOps, onApplyStyle, onStartArtCritique, + onGenerateStoryboard, canUndoOps, undoOpsCount, onUndoOps, @@ -866,6 +870,14 @@ export function CanvasAssistantPanel({ 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_generate_storyboard") { + const { node, readiness } = readAgentStoryboard(current, requireString(args.nodeId, "nodeId")); + if (!readiness.canGenerateStoryboard) throw new Error(readiness.blockingReason || "请先配置项目画风"); + const prompt = requireString(args.prompt ?? node.metadata?.composerContent, "剧本与拆镜要求"); + const completed = await onGenerateStoryboard(node.id, prompt, generationConsumerControllerRef.current.signal); + if (!completed) return { ok: true, waitForUser: true, message: "分镜生成未完成或已取消,请查看节点提示;未完成不代表已生成,不要自动重复发起。", data: { nodeId: node.id, completed: false } }; + return { ok: true, message: "专业拆镜任务已完成并写入真实分镜表。请读取分镜表检查时长与镜头内容,再继续后续操作。", data: { nodeId: node.id, completed: true } }; + } if (name === "canvas_start_art_critique") { const nodeId = requireString(args.nodeId, "nodeId"); const node = current.nodes.find((item) => item.id === nodeId && item.type === "ai-art-critique"); @@ -1562,6 +1574,7 @@ function parseToolArguments(value: string) { } export function onlineToolToOps(name: string, input: Record, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] { + if (name === "canvas_create_storyboard") return buildAgentStoryboardDraft({ title: requireString(input.title, "title"), prompt: requireString(input.prompt, "prompt"), x: numberOr(input.x, nextCanvasX(snapshot)), y: numberOr(input.y, 0) }); 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 validateAgentStoryboardCreation(requireOps(input.ops)); @@ -1781,6 +1794,8 @@ function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgent } function toolCallLabel(name: string) { + if (name === "canvas_create_storyboard") return "创建待拆镜分镜表"; + if (name === "canvas_generate_storyboard") return "专业拆镜"; if (name === "canvas_start_art_critique") return "准备审美分析"; if (name === "canvas_read_plugin_node") return "读取插件结果"; if (name === "canvas_list_styles") return "查找项目画风"; diff --git a/web/src/components/canvas/canvas-script-node.tsx b/web/src/components/canvas/canvas-script-node.tsx index 3faa330c4..9688945e3 100644 --- a/web/src/components/canvas/canvas-script-node.tsx +++ b/web/src/components/canvas/canvas-script-node.tsx @@ -393,7 +393,7 @@ export function CanvasScriptNodeContent({ className="thin-scrollbar h-full min-h-0 w-full touch-pan-y resize-none overflow-y-auto overflow-x-hidden overscroll-contain rounded-md border bg-transparent px-3 py-2 text-sm leading-5 outline-none transition placeholder:opacity-45 focus:ring-1" style={{ borderColor: theme.node.stroke, color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties} value={prompt} - placeholder="描述想生成的脚本或视频内容" + placeholder="输入剧本或剧情,以及总时长等要求,自动拆分为镜头" onContentSizeChange={resizePrompt} onChange={(value) => { setPrompt(value); @@ -447,11 +447,11 @@ export function CanvasScriptNodeContent({ value={shotDuration} disabled={node.metadata?.status === "loading"} options={[ - { value: "auto", label: "时长自动" }, - { value: "5", label: "5 秒" }, - { value: "10", label: "10 秒" }, - { value: "15", label: "15 秒" }, - { value: "30", label: "30 秒" }, + { value: "auto", label: "单镜时长自动" }, + { value: "5", label: "每镜 5 秒" }, + { value: "10", label: "每镜 10 秒" }, + { value: "15", label: "每镜 15 秒" }, + { value: "30", label: "每镜 30 秒" }, ]} popupMatchSelectWidth={false} onChange={onShotDurationChange} diff --git a/web/src/lib/canvas/canvas-agent-storyboard.ts b/web/src/lib/canvas/canvas-agent-storyboard.ts index cbee247ed..6d2ee6a94 100644 --- a/web/src/lib/canvas/canvas-agent-storyboard.ts +++ b/web/src/lib/canvas/canvas-agent-storyboard.ts @@ -2,6 +2,14 @@ import type { CanvasAgentOp, CanvasAgentSnapshot } from "./canvas-agent-ops"; import { createStoryboardRow } from "./canvas-project-domain"; import type { StoryboardRow } from "@/types/canvas"; import { inspectStoryboardReadiness } from "./canvas-storyboard-context"; +import { createShortDramaPipeline } from "./canvas-short-drama"; + +export function buildAgentStoryboardDraft(input: { title: string; prompt: string; x: number; y: number }): CanvasAgentOp[] { + if (!input.title.trim() || !input.prompt.trim()) throw new Error("待拆镜节点需要标题和原始剧本"); + const node = createShortDramaPipeline({ x: input.x - 180, y: input.y }).nodes[2]; + return [{ type: "add_node", id: node.id, nodeType: node.type, title: input.title, position: node.position, width: node.width, height: node.height, + metadata: { ...node.metadata, composerContent: input.prompt, workflowDescription: "待专业拆镜,尚未生成镜头" } }]; +} const textFields = ["plotDescription", "dialogue", "videoMotionPrompt", "imageGenerationPrompt", "camera", "motion", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "narrativeIntent", "viewerPOV", "performanceBlocking", "timeBeats", "continuityOut", "negativePrompt"] as const; diff --git a/web/src/lib/creation/creative-agent-tools.ts b/web/src/lib/creation/creative-agent-tools.ts index 202c5b4bf..33c10ef63 100644 --- a/web/src/lib/creation/creative-agent-tools.ts +++ b/web/src/lib/creation/creative-agent-tools.ts @@ -32,7 +32,7 @@ export const CREATIVE_AGENT_SYSTEM_PROMPT = `你是影策的通用创作助手 多步骤任务可携带 plan,按本次实际工作命名步骤,例如“确定结尾情绪”“设计商品构图”“制作首张主图”;不套固定步骤数。每步 ref 在同一工作持续时保持稳定,新工作使用新ref。phase只用于程序关联真实状态:questions=需求选择,proposal=内容方案,canvas=节点落地,media=产物生成(mediaRefs关联方案生成项ref)。可以调整未完成步骤并在reason说明原因;不编造完成状态。普通咨询不创建计划。已完成工作不可抹掉,新增任务不能复用已完成步骤ref。 问题与方案不能在同一次响应同时推进。已经确认的题材/时长/数量/比例/风格不再问。覆盖已确认字段必须在 evidence 引用最新用户消息中明确修改该字段的原文;不能将建议或旧消息作为修改授权。素材题的 option.id 只能使用上下文提供的真实素材ID。 方案应是可阅读的完整 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 引用。 -分镜任务先检查 canvas_get_context.storyboardReadiness 或 canvas_read_storyboard.readiness。缺少画风时先 canvas_list_styles,再按用户已确认的风格或自主决策授权调用 canvas_apply_style;只有确实需要用户取舍时才提问,不重复询问已有风格。应用后重新读取上下文确认 canGenerateStoryboard,不能只在正文声称画风已设置。方案批准后创建 script 时必须提供非空 shots,不能只写 content/prompt 后把默认空行当成完成。已有分镜用 canvas_read_storyboard 取得真实行 ID,再 canvas_edit_storyboard 更新空行、按需追加镜头,保留已有行 ID 与素材;已有剧本足够时由你直接编写镜头,不要求用户再次点击生成分镜。0–2秒对应 durationSeconds=2,2–4秒为2秒,4–7秒为3秒,7–9秒为2秒,9–10秒为1秒;写入后核对总时长与约定一致。不用改标题代替修改时长,不声称有分镜行就已经生成图片视频。 +分镜任务先检查 canvas_get_context.storyboardReadiness 或 canvas_read_storyboard.readiness。缺少画风时先 canvas_list_styles,再按用户已确认的风格或自主决策授权调用 canvas_apply_style;只有确实需要用户取舍时才提问,不重复询问已有风格。应用后重新读取上下文确认 canGenerateStoryboard,不能只在正文声称画风已设置。用户给的是剧本、剧情或要求自动拆镜时,优先 canvas_generate_storyboard 复用项目专业拆镜能力,不自己编一组 shots 替代专业任务。没有分镜节点时,在方案获批或用户已明确要求搭建的范围内调用 canvas_create_storyboard,将原始剧本保存在底部输入框,再调用 canvas_generate_storyboard;待拆镜空表不代表完成。专业任务会展示费用与整表替换确认,用户取消后停止,不重复请求。已有明确镜头清单需要录入,或局部修改时,用 canvas_read_storyboard 获取真实行 ID,再 canvas_edit_storyboard 更新或追加,保留其余行与素材。方案直接交付已拆好的 script 时仍必须提供非空 shots。拆镜后重新读取真实表格,核对总时长和镜头数;5秒、10秒等节点时长选项是单镜头时长,总片长必须写入拆镜要求。不要把项目画风全文重复灌进每行作为镜头描述,不声称有镜头行就已经生成图片视频。 generationItems 是方案的声明式生成配置,用于校验和之后的报价,不会立即调用媒体模型。因此“确认后再生成”仍必须填写它,不能省略。每项 ref 必须对应节点 ref,mode 必须等于节点 kind,model 必须来自 availableModels 中相同 mode 的条目;图片模型不能用于视频。上下文若有 rejectedProposal,先根据其中真实校验错误修正,保留已知需求并返回完整方案。结构化方案和费用的批准以程序记录为准;若仍缺按钮确认,直接显示对应卡片,不再先问一遍是否同意、再让用户点卡片。 引用已有画布图片时,将真实 references.id 放到 workflow.nodes[].referenceNodeIds;referenceRefs 优先用于本次方案内其他图片节点的 ref。不要为引用现有图片再生成一张副本,不要在两个字段重复放同一张图。程序会核实真实资源并整理引用,未知ID不能通过。 单段视频可以在正文描述多个叙事节奏,不把它们拆成未合成的几段视频。用户要求15秒时只选实际支持15秒的模型;不存在则解释缺少能力并询问可接受替代,绝不能偷偷缩短。图像引用数量、视频输入角色、尺寸均须符合上下文能力。边表达真实依赖,不为了好看造线。 diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 82089126b..64ba6295f 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2488,6 +2488,7 @@ function InfiniteCanvasPage() { onSessionsChange={handleAssistantSessionsChange} onApplyOps={applyAgentOps} onApplyStyle={applyCanvasStyleAsync} + onGenerateStoryboard={generateScriptRows} onStartArtCritique={(nodeId, restart) => { if (artCritiqueRunningRef.current) throw new Error("已有审美分析正在进行,请先完成或停止当前分析"); setArtCritiqueStartRequest({ nodeId, id: nanoid(), restart }); diff --git a/web/src/pages/canvas/use-canvas-storyboard.ts b/web/src/pages/canvas/use-canvas-storyboard.ts index 0a6db47c0..9a6472d0b 100644 --- a/web/src/pages/canvas/use-canvas-storyboard.ts +++ b/web/src/pages/canvas/use-canvas-storyboard.ts @@ -1,4 +1,4 @@ -import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react"; import { App } from "antd"; import { nanoid } from "nanoid"; @@ -27,7 +27,9 @@ import { navigateToSettings } from "@/lib/settings-navigation"; import type { Skill } from "@/services/api/skills"; import { createGenerationTask, waitForGenerationTask } from "@/services/api/task-center"; import { skillRuntime } from "@/services/skill-runtime"; -import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { submitStoryboardTask } from "@/services/storyboard-submission"; +import { getActiveUserScope } from "@/lib/user-scope"; +import { modelDisplayName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasConnection, @@ -65,6 +67,13 @@ export function useCanvasStoryboard({ const { message, modal } = App.useApp(); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const storyboardRequests = useRef(new Set()); + const storyboardLifetime = useRef(new AbortController()); + useEffect(() => { + const controller = new AbortController(); + storyboardLifetime.current = controller; + return () => controller.abort(); + }, [projectId]); const confirmGenerationSubmission = useCallback((count: number, model: string, taskLabel: string) => new Promise((resolve) => { if (!count) return resolve(false); @@ -99,9 +108,9 @@ export function useCanvasStoryboard({ const previousRows = new Map((nodesRef.current.find((node) => node.id === nodeId)?.metadata?.storyboard?.rows || []).map((row) => [row.id, row])); const nextRows = rows.map((row) => invalidateEditedPromptVariables(previousRows.get(row.id), row)); setConnections((current) => current - .filter((connection) => !connection.storyboardRowId || storyboardRowIds.has(connection.storyboardRowId)) - .filter((connection) => connection.fromNodeId !== nodeId || !connection.fromHandleId || rowIds.has(connection.fromHandleId)) - .filter((connection) => connection.toNodeId !== nodeId || !connection.toHandleId || rowIds.has(connection.toHandleId))); + .filter((connection) => connection.fromNodeId !== nodeId && connection.toNodeId !== nodeId || !connection.storyboardRowId || storyboardRowIds.has(connection.storyboardRowId)) + .filter((connection) => connection.fromNodeId !== nodeId || !connection.fromHandleId?.startsWith("row:") || rowIds.has(connection.fromHandleId)) + .filter((connection) => connection.toNodeId !== nodeId || !connection.toHandleId?.startsWith("row:") || rowIds.has(connection.toHandleId))); updateScriptRows(nodeId, () => nextRows); }, [nodesRef, setConnections, updateScriptRows]); @@ -119,9 +128,19 @@ export function useCanvasStoryboard({ replaceScriptRows(nodeId, rows); }, [nodesRef, replaceScriptRows]); - const generateScriptRows = useCallback(async (nodeId: string, prompt: string) => { + const generateScriptRows = useCallback(async (nodeId: string, prompt: string, callerSignal?: AbortSignal) => { const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script); if (!scriptNode || !prompt.trim()) return; + if (storyboardRequests.current.has(nodeId) || scriptNode.metadata?.status === NODE_STATUS_LOADING) { message.warning("当前分镜正在处理,请等待原任务完成"); return false; } + const lifetimeSignal = storyboardLifetime.current.signal; + const signal = callerSignal ? AbortSignal.any([lifetimeSignal, callerSignal]) : lifetimeSignal; + const scope = getActiveUserScope(); + const originalRows = JSON.stringify(scriptNode.metadata?.storyboard?.rows || []); + const assertCurrent = () => { + signal.throwIfAborted(); + const current = nodesRef.current.find((node) => node.id === nodeId); + if (getActiveUserScope() !== scope || !current || JSON.stringify(current.metadata?.storyboard?.rows || []) !== originalRows) throw new Error("画布、账号或分镜内容已变化,未覆盖现有镜头,请重新读取后处理"); + }; let storyboardContext: ReturnType; try { storyboardContext = resolveStoryboardGenerationContext(nodesRef.current); @@ -139,14 +158,17 @@ export function useCanvasStoryboard({ navigateToSettings({ continueCreation: true }); return; } + storyboardRequests.current.add(nodeId); try { const skillExecution = await skillRuntime.prepare({ profile: "shortDrama", prompt: expandedPrompt, skills: addedSkills, }); + assertCurrent(); setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node)); - const task = await createGenerationTask({ + assertCurrent(); + const request = { projectId, type: "agent_storyboard_rows", operation: "storyboard_rows", @@ -154,6 +176,8 @@ export function useCanvasStoryboard({ model: generationConfig.model, ...(logicalModelIDForConfig(generationConfig) ? { logicalModelId: logicalModelIDForConfig(generationConfig) } : {}), input: { + mode: "text", + prompt: skillExecution.prompt, canvasAssets: buildStoryboardAssetCatalog(nodesRef.current), requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。", projectStyle: storyboardContext.projectStyle, @@ -163,14 +187,41 @@ export function useCanvasStoryboard({ config: backendProviderConfig(generationConfig, "text"), metadata: { nodeId, ...skillExecution.metadata }, }, - }); + }; + const managed = Boolean(logicalModelIDForConfig(generationConfig) || resolveModelRequestConfig(generationConfig, generationConfig.model).channelId); + const task = managed ? await submitStoryboardTask(request, { signal, assertCurrent, confirm: (submission) => new Promise((resolve) => { + const quote = submission.quote; + const dialog = modal.confirm({ + title: "确认生成分镜", + content: `使用 ${modelDisplayName(effectiveConfig, quote.model)} 拆分镜头。${quote.estimated ? "预计" : ""}平台费用 ${quote.amountMicrocredits / 1_000_000} 积分;自定义渠道费用由渠道另计,按用量计费以实际结算为准。${(scriptNode.metadata?.storyboard?.rows || []).length ? "本次将替换现有分镜行,原图片视频保留,镜头关联需要重新核对。" : "确认后生成可编辑的分镜表。"}`, + okText: "确认生成", cancelText: "取消", centered: true, + onOk: () => resolve(true), onCancel: () => resolve(false), + afterClose: () => signal.removeEventListener("abort", cancel), + }); + const cancel = () => { dialog.destroy(); resolve(false); }; + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + }) }) : await (async () => { + const confirmed = await confirmGenerationSubmission(1, generationConfig.model, `专业拆镜${(scriptNode.metadata?.storyboard?.rows || []).length ? "(替换现有镜头,保留原媒体)" : ""}`); + if (!confirmed) return undefined; + assertCurrent(); + return createGenerationTask(request); + })(); + if (!task) { + if (!signal.aborted && scope === getActiveUserScope()) setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: scriptNode.metadata?.status || NODE_STATUS_IDLE, taskStage: undefined } } : node)); + return false; + } + assertCurrent(); setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(task), status: NODE_STATUS_LOADING } } : node)); const completed = await waitForGenerationTask(task.id, { initialTask: task, + signal, useTextEvents: true, - onTaskUpdate: (next) => setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(next), status: NODE_STATUS_LOADING } } : node)), + onTaskUpdate: (next) => { if (!signal.aborted && scope === getActiveUserScope()) setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(next), status: NODE_STATUS_LOADING } } : node)); }, }); const result = storyboardRowsFromTask(completed); + assertCurrent(); + replaceScriptRows(nodeId, result.rows); setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, title: result.title || node.title, @@ -189,12 +240,20 @@ export function useCanvasStoryboard({ message.success(`已生成 ${result.rows.length} 个镜头`); return true; } catch (error) { + if (signal.aborted || scope !== getActiveUserScope()) return false; const details = generationErrorMessage(error); setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : node)); message.error(details); return false; + } finally { + if (signal.aborted && !lifetimeSignal.aborted && scope === getActiveUserScope()) setNodes((current) => current.map((node) => { + if (node.id !== nodeId) return node; + const active = node.metadata?.taskStatus === "queued" || node.metadata?.taskStatus === "running"; + return { ...node, metadata: { ...node.metadata, status: active ? NODE_STATUS_LOADING : NODE_STATUS_IDLE, taskStage: active ? "已提交后台任务,等待结果" : undefined } }; + })); + storyboardRequests.current.delete(nodeId); } - }, [addedSkills, connectionsRef, effectiveConfig, isAiConfigReady, message, nodesRef, projectId, setNodes]); + }, [addedSkills, confirmGenerationSubmission, connectionsRef, effectiveConfig, isAiConfigReady, message, modal, nodesRef, projectId, replaceScriptRows, setNodes]); const ensureScriptImageNodes = useCallback((nodeId: string, rowIds: string[]) => { const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script); diff --git a/web/src/services/storyboard-submission.ts b/web/src/services/storyboard-submission.ts new file mode 100644 index 000000000..36562f2b1 --- /dev/null +++ b/web/src/services/storyboard-submission.ts @@ -0,0 +1,35 @@ +import { nanoid } from "nanoid"; +import { creationRuns, type CreationSubmission } from "./api/creation-runs"; +import type { CreateTaskInput } from "./api/task-center"; + +// Both the node composer and Agent submit the same quoted professional task. +export async function submitStoryboardTask(request: CreateTaskInput, options: { + signal: AbortSignal; + assertCurrent: () => void; + confirm: (submission: CreationSubmission) => Promise; +}, api = creationRuns) { + const { signal, assertCurrent } = options; + const detail = await api.create({ clientKey: `storyboard:${nanoid()}`, canvasId: request.projectId, state: { kind: "storyboard" } }, signal); + const run = await api.claim(detail.run.id, { expectedEpoch: detail.run.executionEpoch, owner: `storyboard:${nanoid()}` }, signal); + const guard = { executionEpoch: run.executionEpoch, owner: run.executionOwner }; + let connectionError: unknown; + const heartbeat = setInterval(() => { + void api.heartbeat(run.id, guard, signal).catch((error) => { connectionError = error; }); + }, 15_000); + try { + const submission = await api.prepare(run.id, { ...guard, itemKey: "storyboard", request }, signal); + if (!await options.confirm(submission)) return undefined; + signal.throwIfAborted(); + if (connectionError) throw connectionError; + assertCurrent(); + if (Date.parse(submission.quote.expiresAt) <= Date.now()) throw new Error("分镜报价已过期,请重新发起拆镜并确认最新费用"); + const approved = await api.approve(run.id, { ...guard, submissionIds: [submission.id] }, signal); + if (!approved.submissions.some((item) => item.id === submission.id && item.approvedAt)) throw new Error("费用确认回执不完整,尚未提交拆镜任务"); + signal.throwIfAborted(); + assertCurrent(); + return await api.execute(run.id, { ...guard, submissionId: submission.id }, signal); + } finally { + clearInterval(heartbeat); + void api.release(run.id, guard).catch(() => { /* Offline leases expire on the server. */ }); + } +} diff --git a/web/test/agent-storyboard.test.ts b/web/test/agent-storyboard.test.ts index 273cb6721..09a410b09 100644 --- a/web/test/agent-storyboard.test.ts +++ b/web/test/agent-storyboard.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { buildAgentStoryboardOperations, validateAgentStoryboardCreation } from "../src/lib/canvas/canvas-agent-storyboard"; +import { buildAgentStoryboardDraft, buildAgentStoryboardOperations, validateAgentStoryboardCreation } from "../src/lib/canvas/canvas-agent-storyboard"; import { inspectStoryboardReadiness } from "../src/lib/canvas/canvas-storyboard-context"; import { buildCanvasAgentContext } from "../src/lib/canvas/canvas-agent-context"; import { createShortDramaPipeline } from "../src/lib/canvas/canvas-short-drama"; @@ -15,6 +15,16 @@ function fixture() { return { snapshot, script }; } +test("待拆镜入口保存原始剧本但不伪造已完成的镜头", () => { + const { snapshot } = fixture(); + const result = applyCanvasAgentOps(snapshot, buildAgentStoryboardDraft({ title: "旧机来电", prompt: "手机突然响起", x: 500, y: 100 })); + const node = result.nodes.find((item) => item.title === "旧机来电")!; + expect(node.type).toBe("script"); + expect(node.metadata?.composerContent).toBe("手机突然响起"); + expect(node.metadata?.storyboard?.rows).toEqual([]); + expect(node.metadata?.workflowDescription).toContain("尚未生成"); +}); + test("Agent 上下文提前指出未选择画风及分镜空行", () => { const { snapshot, script } = fixture(); const readiness = buildCanvasAgentContext(snapshot).storyboardReadiness; diff --git a/web/test/storyboard-submission.test.ts b/web/test/storyboard-submission.test.ts new file mode 100644 index 000000000..8aa74db93 --- /dev/null +++ b/web/test/storyboard-submission.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { submitStoryboardTask } from "../src/services/storyboard-submission"; +import { creationRuns, type CreationSubmission } from "../src/services/api/creation-runs"; +import type { CreateTaskInput } from "../src/services/api/task-center"; + +const request = { type: "agent_storyboard_rows", projectId: "canvas", prompt: "剧本" } as CreateTaskInput; +function harness(expiresAt = "2099-01-01") { + const calls: string[] = []; + const run = { id: "run", executionEpoch: 1, executionOwner: "owner" }; + const submission = { id: "quote", quote: { expiresAt } } as CreationSubmission; + const api = { + ...creationRuns, + create: async () => ({ run, submissions: [] }), claim: async () => run, + prepare: async () => { calls.push("prepare"); return submission; }, + approve: async () => { calls.push("approve"); return { submissions: [{ ...submission, approvedAt: "now" }] }; }, + execute: async () => { calls.push("execute"); return { id: "task" }; }, + release: async () => { calls.push("release"); return { released: true }; }, + } as typeof creationRuns; + return { api, calls }; +} +test("取消专业拆镜费用确认不会批准或提交任务", async () => { + const h = harness(); + expect(await submitStoryboardTask(request, { signal: new AbortController().signal, assertCurrent() {}, confirm: async () => false }, h.api)).toBeUndefined(); + expect(h.calls).toEqual(["prepare", "release"]); +}); +test("确认后提交原报价对应的专业任务", async () => { + const h = harness(); + expect((await submitStoryboardTask(request, { signal: new AbortController().signal, assertCurrent() {}, confirm: async () => true }, h.api))?.id).toBe("task"); + expect(h.calls).toEqual(["prepare", "approve", "execute", "release"]); +}); +test("过期报价或确认期间编辑分镜均阻止提交", async () => { + for (const changed of [false, true]) { + const h = harness(changed ? "2099-01-01" : "2000-01-01"); + await expect(submitStoryboardTask(request, { signal: new AbortController().signal, assertCurrent() { if (changed) throw new Error("分镜已编辑"); }, confirm: async () => true }, h.api)).rejects.toThrow(); + expect(h.calls).toEqual(["prepare", "release"]); + } +});