feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续

This commit is contained in:
ddCat
2026-09-09 22:46:45 +08:00
97 changed files with 8434 additions and 326 deletions

View File

@@ -5,10 +5,14 @@ import { workflowStarterPrompt } from "@/lib/prompts";
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"
@@ -29,6 +33,7 @@ export type CanvasWorkflowNodeInput = {
runGeneration?: boolean;
width?: number;
height?: number;
shots?: Array<{ durationSeconds: number; videoMotionPrompt: string; dialogue?: string }>;
};
export type CanvasWorkflowInput = {
@@ -51,7 +56,7 @@ const WORKFLOW_KINDS = new Set<CanvasWorkflowNodeKind>([
const DEFAULT_GAP = 120;
const WORKFLOW_PREFIX = "agent-workflow";
export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: CanvasAgentSnapshot, config: AiConfig, identityPrefix?: string): CanvasAgentOp[] {
if (!Array.isArray(input.nodes) || input.nodes.length === 0) throw new Error("工作流至少需要一个节点");
const refs = new Set<string>();
input.nodes.forEach((node) => {
@@ -76,7 +81,7 @@ export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: Can
const direction = input.direction || "horizontal";
const gap = Math.max(48, input.gap ?? DEFAULT_GAP);
const positions = layoutWorkflowNodes(input.nodes, snapshot, direction, gap, input.start);
const ids = new Map(input.nodes.map((node) => [node.ref, `${WORKFLOW_PREFIX}-${slug(node.ref)}-${nanoid(8)}`]));
const ids = new Map(input.nodes.map((node) => [node.ref, identityPrefix ? `${identityPrefix}:node:${encodeURIComponent(node.ref)}` : `${WORKFLOW_PREFIX}-${slug(node.ref)}-${nanoid(8)}`]));
const ops: CanvasAgentOp[] = [];
input.nodes.forEach((node, index) => {
@@ -103,10 +108,23 @@ 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?.length) throw new Error("创建分镜工作流必须提供非空 shots,请把镜头逐行填写 durationSeconds、videoMotionPrompt、dialogue,不能仅写正文");
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 });
});
const edges = input.edges?.length ? input.edges : input.nodes.slice(0, -1).map((node, index) => ({ from: node.ref, to: input.nodes[index + 1].ref }));
const edges = input.edges !== undefined ? input.edges : input.nodes.slice(0, -1).map((node, index) => ({ from: node.ref, to: input.nodes[index + 1].ref }));
const edgeKeys = new Set<string>();
for (const edge of edges) {
if (!ids.has(edge.from) || !ids.has(edge.to)) throw new Error(`工作流连线引用不存在的节点:${edge.from} → ${edge.to}`);
@@ -114,20 +132,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)!);
@@ -139,7 +157,7 @@ export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: Can
if (shouldRun && generationModeForNode(type)) ops.push({ type: "run_generation", nodeId: ids.get(node.ref)!, mode: generationModeForNode(type)!, prompt: node.prompt || node.content || workflowPrompt(node.kind, node.title, input) || undefined });
});
}
return ops;
return identityPrefix ? ops.map((op) => op.type === "connect_nodes" ? { ...op, id: `${identityPrefix}:edge:${encodeURIComponent(op.fromNodeId)}:${encodeURIComponent(op.toNodeId)}` } : op) : ops;
}
function workflowPrompt(kind: CanvasWorkflowNodeKind, title: string, input: CanvasWorkflowInput) {
@@ -178,7 +196,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) {
@@ -188,7 +207,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"] {