feat(agent): 创作能力 - 接通专业节点发现与插件执行基础
This commit is contained in:
@@ -125,6 +125,7 @@ export type ArtCritiqueReport = {
|
||||
export type ArtCritiqueNodeStatus = "idle" | "running" | "completed" | "failed" | "stale";
|
||||
|
||||
export type ArtCritiqueNodeState = {
|
||||
stageTaskIds?: Record<string, string>;
|
||||
schemaVersion: typeof ART_CRITIQUE_SCHEMA_VERSION;
|
||||
status: ArtCritiqueNodeStatus;
|
||||
sourceNodeId?: string;
|
||||
|
||||
@@ -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<ToolResponseResult>;
|
||||
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<T>(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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
52
web/src/lib/canvas/canvas-agent-storyboard.ts
Normal file
52
web/src/lib/canvas/canvas-agent-storyboard.ts
Normal file
@@ -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);
|
||||
|
||||
@@ -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<string>(), visiting = new Set<string>();
|
||||
|
||||
@@ -5,7 +5,7 @@ const strings = { type: "array", items: string };
|
||||
const object = (properties: Record<string, unknown>, 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秒的模型;不存在则解释缺少能力并询问可接受替代,绝不能偷偷缩短。图像引用数量、视频输入角色、尺寸均须符合上下文能力。边表达真实依赖,不为了好看造线。
|
||||
|
||||
24
web/src/lib/plugins/analysis-node-action.ts
Normal file
24
web/src/lib/plugins/analysis-node-action.ts
Normal file
@@ -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] },
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -51,6 +51,11 @@ function assertManifestV2(manifest: PluginManifestV2) {
|
||||
|
||||
export function registerPlugin(plugin: RegisteredPlugin) {
|
||||
assertManifest(plugin.manifest);
|
||||
const actionIds = new Set<string>();
|
||||
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} 已注册其他版本`);
|
||||
|
||||
@@ -317,7 +317,18 @@ export type AssetSourceProvider = {
|
||||
openAsset?: (item: ExternalAssetItem) => Promise<void>;
|
||||
};
|
||||
|
||||
export type PluginAgentAction = {
|
||||
id: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
/** Pure planner: validate input and return operations, never perform side effects here. */
|
||||
buildOperations: (input: Record<string, unknown>, 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<string, unknown>;
|
||||
/** v1 或 v2 插件清单;v2 清单结构为 v1 超集(含 editorSlots 声明)。 */
|
||||
manifest: PluginManifest | PluginManifestV2;
|
||||
source?: "bundled" | "uploaded" | string;
|
||||
|
||||
Reference in New Issue
Block a user