refactor(canvas): 节点注册表 - 合并 PR #284 并收敛类型判据
This commit is contained in:
@@ -5,8 +5,8 @@ export const canvasThemes = {
|
||||
light: {
|
||||
canvas: {
|
||||
background: "#ffffff",
|
||||
dot: "rgba(15,23,42,.14)",
|
||||
line: "rgba(15,23,42,.065)",
|
||||
dot: "rgba(15,23,42,.30)",
|
||||
line: "rgba(15,23,42,.15)",
|
||||
selectionFill: "rgba(17,17,17,.10)",
|
||||
},
|
||||
node: {
|
||||
@@ -68,8 +68,8 @@ export const canvasThemes = {
|
||||
dark: {
|
||||
canvas: {
|
||||
background: "#090a0c",
|
||||
dot: "rgba(174,184,199,.18)",
|
||||
line: "rgba(174,184,199,.06)",
|
||||
dot: "rgba(174,184,199,.32)",
|
||||
line: "rgba(174,184,199,.16)",
|
||||
selectionFill: "rgba(255,255,255,.12)",
|
||||
},
|
||||
node: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection";
|
||||
import { getNodeGenerationMode, getNodeInputKind } from "@/lib/canvas/node-registry";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
|
||||
import { type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type ConnectionCandidate = Pick<CanvasConnection, "fromNodeId" | "toNodeId">;
|
||||
type CanvasConnectionPolicyOptions = {
|
||||
@@ -11,7 +12,7 @@ type CanvasConnectionPolicyOptions = {
|
||||
export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate, options: CanvasConnectionPolicyOptions = {}) {
|
||||
const target = nodes.find((node) => node.id === candidate.toNodeId);
|
||||
if (!target) return "找不到连线目标节点";
|
||||
const mode = nodeGenerationMode(target);
|
||||
const mode = getNodeGenerationMode(target);
|
||||
if (!mode) return "";
|
||||
const input = connectionInputSummary(target.id, nodes, connections, candidate);
|
||||
const visualInputCount = input.imageCount + input.characterCount;
|
||||
@@ -35,25 +36,18 @@ export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeDa
|
||||
const input: ModelInputSummary = { textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 };
|
||||
sourceIds.forEach((sourceId) => {
|
||||
const source = nodes.find((node) => node.id === sourceId);
|
||||
if (!source || source.type === CanvasNodeType.Config || source.type === CanvasNodeType.Frame) return;
|
||||
if (!source) return;
|
||||
// 生成配置与背板不是参考素材,不参与容量计数——这一步必须早于角色卡判定,
|
||||
// 否则一个带角色元数据的配置/背板节点会被多算成角色。
|
||||
const inputKind = getNodeInputKind(source.type);
|
||||
if (!inputKind) return;
|
||||
// 角色卡是跨类型覆盖:落在可计数类型上时改记为角色。
|
||||
if (source.metadata?.workflowKind === "character") input.characterCount += 1;
|
||||
else if (source.type === CanvasNodeType.Image || source.type === CanvasNodeType.Drawing) input.imageCount += 1;
|
||||
else if (source.type === CanvasNodeType.Video) input.videoCount += 1;
|
||||
else if (source.type === CanvasNodeType.Audio) input.audioCount += 1;
|
||||
else input.textCount += 1;
|
||||
else input[`${inputKind}Count`] += 1;
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
function nodeGenerationMode(node: CanvasNodeData): CanvasGenerationMode | null {
|
||||
if (node.type === CanvasNodeType.Config) return node.metadata?.generationMode || "image";
|
||||
if (node.type === CanvasNodeType.Image) return "image";
|
||||
if (node.type === CanvasNodeType.Video) return "video";
|
||||
if (node.type === CanvasNodeType.Audio) return "audio";
|
||||
if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Script) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
function capacityError(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio", count: number, label: string) {
|
||||
const maximum = maxModelInputCapacity(config, capability, kind);
|
||||
if (maximum === null || count <= maximum) return "";
|
||||
|
||||
@@ -4,6 +4,12 @@ export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview";
|
||||
export const CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT = "canvas:graphics-viewport-preview";
|
||||
export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview";
|
||||
|
||||
// 空间网格点模式的点半径(像素单位)。远距(缩放 < 0.12)时用更小半径避免糊成一团。
|
||||
// 同时被 <CanvasGrid>(infinite-canvas)与滚动同步(applyCanvasLiveViewport)共享,避免两处漂移。
|
||||
export function canvasDotPx(scale: number): string {
|
||||
return scale < 0.12 ? "1.2px" : "2px";
|
||||
}
|
||||
|
||||
export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) {
|
||||
if (!container) return;
|
||||
const gridSize = 48 * viewport.k;
|
||||
@@ -17,7 +23,7 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
|
||||
container.style.setProperty("--canvas-grid-size", `${gridSize}px`);
|
||||
container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
|
||||
container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
|
||||
container.style.setProperty("--canvas-dot-size", viewport.k < 0.12 ? "0.8px" : "1.15px");
|
||||
container.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k));
|
||||
// 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。
|
||||
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
|
||||
if (notify) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset, AssetCategory } from "@/stores/use-asset-store";
|
||||
@@ -146,14 +147,9 @@ function isResourceNode(node: CanvasNodeData) {
|
||||
}
|
||||
|
||||
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
||||
// 角色卡是跨类型覆盖:任何节点带上角色元数据都按角色处理,故先于按类型判定。
|
||||
if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) return "character";
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
||||
if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) return "image";
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
||||
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
||||
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
||||
if (node.type === CanvasNodeType.Skill && (node.metadata?.skillSnapshot || node.metadata?.content)) return "text";
|
||||
return null;
|
||||
return getNodeResourceKind(node);
|
||||
}
|
||||
|
||||
function skillResourceText(node: CanvasNodeData) {
|
||||
|
||||
121
web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx
Normal file
121
web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Clapperboard, Image as ImageIcon, Music2, PanelTop, Pencil, Settings2, Sparkles, Type, Video } from "lucide-react";
|
||||
|
||||
import { NODE_SPECS } from "@/constant/canvas";
|
||||
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
import type { CanvasNodeDefinition } from "../node-definition";
|
||||
import { registerNodeDefinitions } from "../node-registry";
|
||||
|
||||
/** 非媒体节点的拉伸下限 */
|
||||
const DEFAULT_MIN_SIZE = { width: 220, height: 160 } as const;
|
||||
|
||||
/**
|
||||
* 每种内置节点的自有特征。
|
||||
*
|
||||
* 尺寸、默认标题与初始 metadata 不在这里重复——它们的数据源是 constant/canvas.ts 的
|
||||
* NODE_SPECS,下方装配时统一读取。
|
||||
*
|
||||
* `satisfies Record<CanvasNodeType, …>` 是刻意的:往 CanvasNodeType 加成员后若不补定义,
|
||||
* 这里会编译失败,而不是在运行期悄悄回落成兜底值。
|
||||
*/
|
||||
const BUILTIN_NODE_TRAITS = {
|
||||
[CanvasNodeType.Image]: {
|
||||
label: "图片",
|
||||
icon: <ImageIcon />,
|
||||
minSize: MEDIA_NODE_MIN_SIZE,
|
||||
keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize,
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "image" : null),
|
||||
generationMode: () => "image",
|
||||
inputKind: "image",
|
||||
},
|
||||
[CanvasNodeType.Text]: {
|
||||
label: "文本",
|
||||
icon: <Type />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content || node.metadata?.prompt ? "text" : null),
|
||||
generationMode: () => "text",
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.Drawing]: {
|
||||
label: "绘图",
|
||||
icon: <Pencil />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
// 绘图产出的是图像,所以作为素材与输入都按图片计。
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.drawingId ? "image" : null),
|
||||
inputKind: "image",
|
||||
},
|
||||
[CanvasNodeType.Script]: {
|
||||
label: "分镜脚本",
|
||||
icon: <Clapperboard />,
|
||||
// 分镜脚本的表格布局需要更宽的下限;高度仍由内容动态撑开(见 canvas-node.tsx)。
|
||||
minSize: { width: 800, height: DEFAULT_MIN_SIZE.height },
|
||||
showInCreateMenu: true,
|
||||
generationMode: () => "text",
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.Skill]: {
|
||||
label: "技能",
|
||||
icon: <Sparkles />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
// 技能节点由技能库插入,不占创建菜单格位。
|
||||
showInCreateMenu: false,
|
||||
// 技能注入的是提示词文本,故按文本素材计。
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.skillSnapshot || node.metadata?.content ? "text" : null),
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.Config]: {
|
||||
label: "生成配置",
|
||||
icon: <Settings2 />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
// 生成配置由连线时自动创建。
|
||||
showInCreateMenu: false,
|
||||
// 生成模式由用户在配置节点上选择,缺省按图片。
|
||||
generationMode: (node: CanvasNodeData) => node.metadata?.generationMode || "image",
|
||||
// 不设 inputKind:配置节点本身不是参考素材,不参与容量计数。
|
||||
},
|
||||
[CanvasNodeType.Video]: {
|
||||
label: "视频",
|
||||
icon: <Video />,
|
||||
minSize: MEDIA_NODE_MIN_SIZE,
|
||||
keepAspectRatio: () => true,
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "video" : null),
|
||||
generationMode: () => "video",
|
||||
inputKind: "video",
|
||||
},
|
||||
[CanvasNodeType.Audio]: {
|
||||
label: "音频",
|
||||
icon: <Music2 />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "audio" : null),
|
||||
generationMode: () => "audio",
|
||||
inputKind: "audio",
|
||||
},
|
||||
[CanvasNodeType.Frame]: {
|
||||
label: "背板",
|
||||
// 背板在列表里就叫「背板」,不是「背板节点」——显式钉住,避免被 label 派生改写。
|
||||
listLabel: "背板",
|
||||
icon: <PanelTop />,
|
||||
minSize: DEFAULT_MIN_SIZE,
|
||||
showInCreateMenu: true,
|
||||
// 背板只是视觉容器,既不是素材也不参与计数。
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
|
||||
|
||||
export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => {
|
||||
const spec = NODE_SPECS[type];
|
||||
return {
|
||||
type,
|
||||
...BUILTIN_NODE_TRAITS[type],
|
||||
defaultTitle: spec.title,
|
||||
defaultSize: { width: spec.width, height: spec.height },
|
||||
defaultMetadata: spec.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
registerNodeDefinitions(BUILTIN_NODE_DEFINITIONS);
|
||||
4
web/src/lib/canvas/node-registry/definitions/index.ts
Normal file
4
web/src/lib/canvas/node-registry/definitions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 导入此模块会触发所有内置节点定义的注册。
|
||||
*/
|
||||
import "./builtin-nodes";
|
||||
3
web/src/lib/canvas/node-registry/index.ts
Normal file
3
web/src/lib/canvas/node-registry/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { CanvasNodeDefinition, CanvasNodeInputKind } from "./node-definition";
|
||||
export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
|
||||
import "./definitions";
|
||||
48
web/src/lib/canvas/node-registry/node-definition.ts
Normal file
48
web/src/lib/canvas/node-registry/node-definition.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
/** 作为上游输入被容量校验计数时归入的类别 */
|
||||
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
|
||||
|
||||
/**
|
||||
* 节点定义——注册表的基本单元。
|
||||
*
|
||||
* 一种画布节点类型的静态知识(叫什么、长什么样、能拉多小、是否锁比例)集中在这里,
|
||||
* 避免同一份知识散落到创建菜单、搜索弹窗、拉伸逻辑等多处后各自漂移。
|
||||
*/
|
||||
export type CanvasNodeDefinition = {
|
||||
type: CanvasNodeType;
|
||||
/** UI 短标签——创建菜单等处显示,如「文本」 */
|
||||
label: string;
|
||||
/** 列表/搜索标签,缺省派生为 `${label}节点` */
|
||||
listLabel?: string;
|
||||
/** 不带 className——由渲染处用 [&_svg]:size-* 统一控制尺寸 */
|
||||
icon: ReactNode;
|
||||
/** 新建节点的默认标题(与 label 分开:菜单叫「文本」,新建出来的节点名是「Note」) */
|
||||
defaultTitle: string;
|
||||
defaultSize: { width: number; height: number };
|
||||
defaultMetadata?: CanvasNodeMetadata;
|
||||
/** 手动拉伸的最小尺寸 */
|
||||
minSize: { width: number; height: number };
|
||||
/** 拉伸时是否锁定宽高比,缺省不锁 */
|
||||
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||
/** 是否出现在添加节点菜单(技能、生成配置由其他入口创建) */
|
||||
showInCreateMenu: boolean;
|
||||
/**
|
||||
* 作为 @ 引用素材时归入的类型;不设或返回 null 表示该节点不是可引用素材。
|
||||
* 判定依赖内容——空节点不构成素材。
|
||||
*
|
||||
* 注意:角色卡(workflowKind === "character")是**跨类型覆盖**,不在这里表达,
|
||||
* 由 canvas-resource-references 在查注册表之前先行判定。
|
||||
*/
|
||||
resourceKind?: (node: CanvasNodeData) => CanvasResourceKind | null;
|
||||
/** 作为生成节点时的生成模式;不设表示该类型不产生生成行为 */
|
||||
generationMode?: (node: CanvasNodeData) => CanvasGenerationMode | null;
|
||||
/**
|
||||
* 作为上游输入被参考素材容量校验计数时的类别;
|
||||
* 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。
|
||||
*/
|
||||
inputKind?: CanvasNodeInputKind;
|
||||
};
|
||||
92
web/src/lib/canvas/node-registry/node-registry.ts
Normal file
92
web/src/lib/canvas/node-registry/node-registry.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { CanvasNodeData, CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
import type { CanvasNodeDefinition } from "./node-definition";
|
||||
|
||||
/** 模块级注册表 */
|
||||
const definitions = new Map<CanvasNodeType, CanvasNodeDefinition>();
|
||||
/** 节点类型 → 归属方。内置节点归属 "builtin",为后续扩展留出隔离位。 */
|
||||
const ownerByType = new Map<CanvasNodeType, string>();
|
||||
|
||||
/** 未注册类型的兜底最小尺寸——与非媒体节点的历史下限一致 */
|
||||
const FALLBACK_MIN_SIZE = { width: 220, height: 160 } as const;
|
||||
|
||||
/** 批量注册节点定义 */
|
||||
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], ownerId = "builtin") {
|
||||
for (const def of defs) {
|
||||
definitions.set(def.type, def);
|
||||
ownerByType.set(def.type, ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销某归属方的全部节点定义。
|
||||
* 只删归属该 ownerId 的条目——内置节点不会被其他归属方的增删波及。
|
||||
*/
|
||||
export function unregisterNodeDefinitions(ownerId: string) {
|
||||
for (const [type, owner] of ownerByType) {
|
||||
if (owner !== ownerId) continue;
|
||||
definitions.delete(type);
|
||||
ownerByType.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeDefinition(type: CanvasNodeType) {
|
||||
return definitions.get(type);
|
||||
}
|
||||
|
||||
export function getNodeOwnerId(type: CanvasNodeType) {
|
||||
return ownerByType.get(type) || "builtin";
|
||||
}
|
||||
|
||||
export function listNodeDefinitions() {
|
||||
return [...definitions.values()];
|
||||
}
|
||||
|
||||
/** 添加节点菜单可见的节点定义 */
|
||||
export function listCreatableNodeDefinitions() {
|
||||
return listNodeDefinitions().filter((def) => def.showInCreateMenu);
|
||||
}
|
||||
|
||||
/** UI 短标签 */
|
||||
export function getNodeLabel(type: CanvasNodeType) {
|
||||
return definitions.get(type)?.label || "未知节点";
|
||||
}
|
||||
|
||||
/** 列表/搜索标签,缺省派生自 label */
|
||||
export function getNodeListLabel(type: CanvasNodeType) {
|
||||
const def = definitions.get(type);
|
||||
if (!def) return "未知节点";
|
||||
return def.listLabel || `${def.label}节点`;
|
||||
}
|
||||
|
||||
export function getNodeIcon(type: CanvasNodeType) {
|
||||
return definitions.get(type)?.icon ?? null;
|
||||
}
|
||||
|
||||
/** 手动拉伸的最小尺寸 */
|
||||
export function getNodeMinSize(type: CanvasNodeType) {
|
||||
return definitions.get(type)?.minSize ?? FALLBACK_MIN_SIZE;
|
||||
}
|
||||
|
||||
/** 拉伸时是否锁定宽高比 */
|
||||
export function shouldKeepAspectRatio(node: CanvasNodeData) {
|
||||
return definitions.get(node.type)?.keepAspectRatio?.(node) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该节点作为 @ 引用素材时的类型,非素材返回 null。
|
||||
* 只按类型判定——角色卡那类跨类型覆盖由调用方在此之前处理。
|
||||
*/
|
||||
export function getNodeResourceKind(node: CanvasNodeData) {
|
||||
return definitions.get(node.type)?.resourceKind?.(node) ?? null;
|
||||
}
|
||||
|
||||
/** 该节点作为生成节点时的生成模式,不产生生成行为返回 null */
|
||||
export function getNodeGenerationMode(node: CanvasNodeData) {
|
||||
return definitions.get(node.type)?.generationMode?.(node) ?? null;
|
||||
}
|
||||
|
||||
/** 该节点作为上游输入被计数时的类别,不参与计数返回 undefined */
|
||||
export function getNodeInputKind(type: CanvasNodeType) {
|
||||
return definitions.get(type)?.inputKind;
|
||||
}
|
||||
67
web/src/lib/canvas/skill-drafting.ts
Normal file
67
web/src/lib/canvas/skill-drafting.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
|
||||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type SkillDraft = {
|
||||
skill_name: string;
|
||||
description: string;
|
||||
instruction: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
const SKILL_DRAFT_PROMPT = `你是一位技能编写助手。根据用户的想法,为一个「可复用的创作技能」生成一份草稿。
|
||||
只输出一个 JSON 对象,不要输出任何其他文字、解释或 Markdown 代码块。JSON 字段:
|
||||
- "skill_name": 技能名称,简短(不超过 20 个字)
|
||||
- "tag": 分类,从以下值中选一个字符串:drama(短剧影视)、ecommerce(电商营销)、creative(创意设计)、social(社媒内容)、others(其他)
|
||||
- "description": 技能简介(不超过 120 字),说明适用场景、输入条件和最终产出
|
||||
- "instruction": 技能指令(Markdown 格式,至少 300 字),写给后续在画布中使用该技能的 AI 模型阅读,必须包含:角色设定、输入与约束、分步执行流程、检查清单、输出格式
|
||||
|
||||
用户的想法:
|
||||
{{{IDEA}}}`;
|
||||
|
||||
export function buildSkillDraftPrompt(idea: string): string {
|
||||
return SKILL_DRAFT_PROMPT.replace("{{{IDEA}}}", idea.trim());
|
||||
}
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
|
||||
export function parseSkillDraft(text: string): Partial<SkillDraft> {
|
||||
const trimmed = (text || "").trim();
|
||||
if (!trimmed) return {};
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
const raw = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>;
|
||||
if (raw && typeof raw === "object") {
|
||||
return {
|
||||
skill_name: String(raw.skill_name ?? "").trim().slice(0, MAX_NAME_LENGTH),
|
||||
description: String(raw.description ?? "").trim().slice(0, MAX_DESCRIPTION_LENGTH),
|
||||
instruction: String(raw.instruction ?? raw.instructions ?? "").trim(),
|
||||
tag: typeof raw.tag === "string" && raw.tag.trim() ? raw.tag.trim() : undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败时回退到文本整体方案
|
||||
}
|
||||
}
|
||||
// 回退:首行作为名称,正文作为简介与指令,用户随后可自由编辑
|
||||
const firstLine = trimmed.split(/\r?\n/)[0]?.trim().slice(0, 30) || "未命名技能";
|
||||
return {
|
||||
skill_name: firstLine,
|
||||
description: trimmed.slice(0, MAX_DESCRIPTION_LENGTH),
|
||||
instruction: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSkillDraft(idea: string, config: AiConfig, signal?: AbortSignal): Promise<Partial<SkillDraft>> {
|
||||
const generationConfig = buildGenerationConfig(config, undefined, "text");
|
||||
const result = await runBackendGenerationTask({
|
||||
mode: "text",
|
||||
prompt: buildSkillDraftPrompt(idea),
|
||||
config: generationConfig,
|
||||
signal,
|
||||
});
|
||||
return parseSkillDraft(result.text || "");
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
import { Clapperboard, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video } from "lucide-react";
|
||||
import { FolderOpen, Layers3, Palette, UploadCloud, UserRound } from "lucide-react";
|
||||
|
||||
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
|
||||
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
/** 真正创建节点的命令,文案与图标统一取自节点注册表。 */
|
||||
function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" | "label" | "icon" | "section">): AddNodeMenuCommand {
|
||||
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "node", ...rest };
|
||||
}
|
||||
|
||||
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
// 项目级动作不占用节点网格,保证 8 个节点稳定保持四列两行。
|
||||
{ id: "style", label: "项目画风", icon: <Palette />, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
|
||||
// 创作节点
|
||||
{ id: "text", label: "文本", icon: <Type />, section: "node", defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() },
|
||||
{ id: "drawing", label: "绘图", icon: <Pencil />, section: "node", defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() },
|
||||
{ id: "script", label: "分镜脚本", icon: <Clapperboard />, badge: "核心", section: "node", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() },
|
||||
{ id: "frame", label: "背板", icon: <PanelTop />, section: "node", defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() },
|
||||
{ id: "image", label: "图片", icon: <ImageIcon />, section: "node", defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() },
|
||||
{ id: "video", label: "视频", icon: <Video />, section: "node", defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() },
|
||||
nodeCommand(CanvasNodeType.Text, { defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() }),
|
||||
nodeCommand(CanvasNodeType.Drawing, { defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() }),
|
||||
nodeCommand(CanvasNodeType.Script, { badge: "核心", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() }),
|
||||
nodeCommand(CanvasNodeType.Frame, { defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() }),
|
||||
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
|
||||
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
{ id: "audio", label: "音频", icon: <Music2 />, section: "node", defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() },
|
||||
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
|
||||
// 导入资源
|
||||
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
|
||||
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
|
||||
|
||||
Reference in New Issue
Block a user