feat(agent): 统一专业拆镜入口与真实镜头生成

This commit is contained in:
王志伟
2026-09-08 23:28:56 +08:00
parent 45de256deb
commit b33de36976
12 changed files with 215 additions and 22 deletions

View File

@@ -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<CanvasAgentSnapshot>;
onApplyStyle: (preset: CanvasStylePreset) => Promise<void>;
onStartArtCritique: (nodeId: string, restart: boolean) => void;
onGenerateStoryboard: (nodeId: string, prompt: string, signal?: AbortSignal) => Promise<boolean | undefined>;
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<string, unknown>, 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 "查找项目画风";

View File

@@ -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}

View File

@@ -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;

View File

@@ -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秒的模型;不存在则解释缺少能力并询问可接受替代,绝不能偷偷缩短。图像引用数量、视频输入角色、尺寸均须符合上下文能力。边表达真实依赖,不为了好看造线。

View File

@@ -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 });

View File

@@ -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<string>());
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<boolean>((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<typeof resolveStoryboardGenerationContext>;
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<boolean>((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);

View File

@@ -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<boolean>;
}, 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. */ });
}
}