feat(agent): 创作能力 - 接通专业节点发现与插件执行基础
This commit is contained in:
1 parent
a594c8266f
commit
757d1dd2c7
29 files changed
+617
-88
No files matched your search
@@ -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<string, unknown>);
|
||||
if (op.id && liveNodeIds.has(op.id)) issues.push({ index, severity: "error", message: `新增节点 id「${op.id}」重复` });
|
||||
if (op.id) liveNodeIds.add(op.id);
|
||||
|
||||
@@ -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<CanvasNodeData>; 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") {
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>;
|
||||
const patch: Partial<StoryboardRow> = {};
|
||||
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 || [],
|
||||
} } }];
|
||||
}
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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<string>();
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user