feat(platform): 插件协议系统 - 支持声明式渠道、工作流与参考媒体映射

This commit is contained in:
ddCat
2026-08-26 13:11:10 +08:00
parent 054e3bbbff
commit 2f3a813ccb
58 changed files with 2074 additions and 706 deletions

View File

@@ -1,6 +1,6 @@
import { getDataUrlByteSize } from "@/lib/image-utils";
import type { Asset, AssetCategory, NewAsset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
export type CanvasAssetSource = "canvas-generation" | "canvas-upload" | "canvas-manual";
@@ -124,7 +124,7 @@ export function canvasNodeAssetCategory(node: CanvasNodeData): AssetCategory {
return "other";
}
function canvasAssetFallbackTitle(type: CanvasNodeType) {
function canvasAssetFallbackTitle(type: CanvasNodeTypeId) {
if (type === CanvasNodeType.Image) return "画布图片";
if (type === CanvasNodeType.Video) return "画布视频";
if (type === CanvasNodeType.Audio) return "画布音频";

View File

@@ -1,5 +1,5 @@
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType, isBuiltinCanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export const MEDIA_NODE_MIN_SIZE = { width: 420, height: 236 } as const;
export const VIDEO_NODE_MAX_SIZE = { width: 720, height: 520 } as const;
@@ -34,10 +34,11 @@ export function ensureMediaNodeMinimumSize(node: CanvasNodeData) {
const title = node.title === "New Generation" ? "图片" : node.title === "Video" ? "视频" : node.title;
let width = node.width;
let height = node.height;
const emptyStage = NODE_DEFAULT_SIZE[node.type];
const emptyStage = isBuiltinCanvasNodeType(node.type) ? NODE_DEFAULT_SIZE[node.type] : undefined;
const shouldPromoteEmptyStage = !node.metadata?.content
&& !node.metadata?.freeResize
&& !node.metadata?.locked
&& emptyStage !== undefined
&& width * height < emptyStage.width * emptyStage.height;
if (shouldPromoteEmptyStage) {
width = emptyStage.width;

View File

@@ -7,9 +7,10 @@ import type { NodeGenerationInput } from "@/components/canvas/canvas-node-genera
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
const CANVAS_WORKSPACE_MODE_STORAGE_KEY = "canvas-workspace-mode-v1";
@@ -32,8 +33,11 @@ export function persistCanvasWorkspaceMode(mode: CanvasWorkspaceMode) {
}
export function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(type);
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const builtinSpec = type in NODE_DEFAULT_SIZE ? getNodeSpec(type as CanvasNodeType) : undefined;
const pluginDefinition = getNodeDefinition(type);
const spec = builtinSpec || (pluginDefinition ? { width: pluginDefinition.defaultSize.width, height: pluginDefinition.defaultSize.height, title: pluginDefinition.defaultTitle, metadata: pluginDefinition.defaultMetadata } : undefined);
if (!spec) throw new Error(`未注册的画布节点类型:${type}`);
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return {

View File

@@ -3,7 +3,7 @@ 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";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character";
@@ -17,7 +17,7 @@ export type CanvasResourceReference = {
storageKey?: string;
text?: string;
active: boolean;
sourceType?: CanvasNodeType;
sourceType?: CanvasNodeTypeId;
skill?: Skill;
assetId?: string;
category?: AssetCategory;

View File

@@ -16,8 +16,7 @@ const DEFAULT_MIN_SIZE = { width: 220, height: 160 } as const;
* 尺寸、默认标题与初始 metadata 不在这里重复——它们的数据源是 constant/canvas.ts 的
* NODE_SPECS,下方装配时统一读取。
*
* `satisfies Record<CanvasNodeType, …>` 是刻意的:往 CanvasNodeType 加成员后若不补定义,
* 这里会编译失败,而不是在运行期悄悄回落成兜底值。
* 内置节点保持集中定义;第三方节点通过同一 registry 以命名空间 ID 动态注册。
*/
const BUILTIN_NODE_TRAITS = {
[CanvasNodeType.Image]: {
@@ -166,7 +165,7 @@ const BUILTIN_NODE_TRAITS = {
resourceKind: () => "image",
inputKind: "image",
},
} satisfies Record<CanvasNodeType, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
} satisfies Record<string, 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];

View File

@@ -1,3 +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";
export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, registerPluginCanvasNodes, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
import "./definitions";

View File

@@ -1,7 +1,8 @@
import type { ReactNode } from "react";
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId } from "@/types/canvas";
import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types";
/** 作为上游输入被容量校验计数时归入的类别 */
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
@@ -13,7 +14,7 @@ export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
* 避免同一份知识散落到创建菜单、搜索弹窗、拉伸逻辑等多处后各自漂移。
*/
export type CanvasNodeDefinition = {
type: CanvasNodeType;
type: CanvasNodeTypeId;
/** UI 短标签——创建菜单等处显示,如「文本」 */
label: string;
/** 列表/搜索标签,缺省派生为 `${label}节点` */
@@ -45,4 +46,23 @@ export type CanvasNodeDefinition = {
* 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。
*/
inputKind?: CanvasNodeInputKind;
plugin?: {
pluginId: string;
renderer: PluginCanvasNodeContribution["renderer"];
schema: Record<string, unknown>;
};
};
export function canvasNodeDefinitionFromPlugin(pluginId: string, contribution: PluginCanvasNodeContribution): CanvasNodeDefinition {
return {
type: contribution.id,
label: contribution.label,
icon: null,
defaultTitle: contribution.defaultTitle,
defaultSize: contribution.defaultSize,
defaultMetadata: { pluginId, pluginNodeId: contribution.id, pluginData: {}, content: "" },
minSize: { width: Math.min(contribution.defaultSize.width, 220), height: Math.min(contribution.defaultSize.height, 160) },
showInCreateMenu: true,
plugin: { pluginId, renderer: contribution.renderer, schema: contribution.schema },
};
}

View File

@@ -1,11 +1,12 @@
import type { CanvasNodeData, CanvasNodeType } from "@/types/canvas";
import type { CanvasNodeData, CanvasNodeTypeId } from "@/types/canvas";
import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types";
import type { CanvasNodeDefinition } from "./node-definition";
import { canvasNodeDefinitionFromPlugin, type CanvasNodeDefinition } from "./node-definition";
/** 模块级注册表 */
const definitions = new Map<CanvasNodeType, CanvasNodeDefinition>();
const definitions = new Map<CanvasNodeTypeId, CanvasNodeDefinition>();
/** 节点类型 → 归属方。内置节点归属 "builtin",为后续扩展留出隔离位。 */
const ownerByType = new Map<CanvasNodeType, string>();
const ownerByType = new Map<CanvasNodeTypeId, string>();
/** 未注册类型的兜底最小尺寸——与非媒体节点的历史下限一致 */
const FALLBACK_MIN_SIZE = { width: 220, height: 160 } as const;
@@ -18,6 +19,11 @@ export function registerNodeDefinitions(defs: CanvasNodeDefinition[], ownerId =
}
}
/** Registers schema-driven canvas nodes from the unified plugin manifest. */
export function registerPluginCanvasNodes(pluginId: string, nodes: PluginCanvasNodeContribution[]) {
registerNodeDefinitions(nodes.map((node) => canvasNodeDefinitionFromPlugin(pluginId, node)), pluginId);
}
/**
* 注销某归属方的全部节点定义。
* 只删归属该 ownerId 的条目——内置节点不会被其他归属方的增删波及。
@@ -30,11 +36,11 @@ export function unregisterNodeDefinitions(ownerId: string) {
}
}
export function getNodeDefinition(type: CanvasNodeType) {
export function getNodeDefinition(type: CanvasNodeTypeId) {
return definitions.get(type);
}
export function getNodeOwnerId(type: CanvasNodeType) {
export function getNodeOwnerId(type: CanvasNodeTypeId) {
return ownerByType.get(type) || "builtin";
}
@@ -48,23 +54,23 @@ export function listCreatableNodeDefinitions() {
}
/** UI 短标签 */
export function getNodeLabel(type: CanvasNodeType) {
export function getNodeLabel(type: CanvasNodeTypeId) {
return definitions.get(type)?.label || "未知节点";
}
/** 列表/搜索标签,缺省派生自 label */
export function getNodeListLabel(type: CanvasNodeType) {
export function getNodeListLabel(type: CanvasNodeTypeId) {
const def = definitions.get(type);
if (!def) return "未知节点";
return def.listLabel || `${def.label}节点`;
}
export function getNodeIcon(type: CanvasNodeType) {
export function getNodeIcon(type: CanvasNodeTypeId) {
return definitions.get(type)?.icon ?? null;
}
/** 手动拉伸的最小尺寸 */
export function getNodeMinSize(type: CanvasNodeType) {
export function getNodeMinSize(type: CanvasNodeTypeId) {
return definitions.get(type)?.minSize ?? FALLBACK_MIN_SIZE;
}
@@ -87,6 +93,6 @@ export function getNodeGenerationMode(node: CanvasNodeData) {
}
/** 该节点作为上游输入被计数时的类别,不参与计数返回 undefined */
export function getNodeInputKind(type: CanvasNodeType) {
export function getNodeInputKind(type: CanvasNodeTypeId) {
return definitions.get(type)?.inputKind;
}

View File

@@ -2,7 +2,7 @@ import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2,
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
import { CanvasNodeType } from "@/types/canvas";
import { CanvasNodeType, type CanvasNodeTypeId } from "@/types/canvas";
/** 真正创建节点的命令,文案与图标统一取自节点注册表。 */
function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" | "label" | "icon" | "section">): AddNodeMenuCommand {
@@ -13,7 +13,7 @@ function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" |
* 扩展节点命令:落在独立的「展示与加工」分区,不挤占上方调好的四列创作网格。
* 创建逻辑一律是 createNode(type),所以统一走 onAddExtensionNode 而不是各开一个 handler。
*/
function extensionCommand(type: CanvasNodeType, defaultOrder: number): AddNodeMenuCommand {
function extensionCommand(type: CanvasNodeTypeId, defaultOrder: number): AddNodeMenuCommand {
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "extension", defaultOrder, run: (ctx) => ctx.handlers.onAddExtensionNode(type) };
}

View File

@@ -3,7 +3,7 @@ import type { MouseEvent } from "react";
import type { CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
@@ -43,7 +43,7 @@ export type ToolbarHandlers = {
* 不给每种扩展节点单开一个 onAddXxx —— 那会让 ToolbarHandlers 随节点数线性膨胀,
* 而它们的创建逻辑完全一致(都只是 createNode(type))。
*/
onAddExtensionNode: (type: CanvasNodeType) => void;
onAddExtensionNode: (type: CanvasNodeTypeId) => void;
onChooseStyle: () => void;
onOpenDirector: () => void;
// 主工具栏——资源
@@ -102,7 +102,7 @@ export type ToolbarHandlers = {
/** 工具运行时可见的上下文。工具定义通过纯函数读取状态 */
export type ToolContext = {
selectedCount: number;
selectedNodeTypes: Set<CanvasNodeType>;
selectedNodeTypes: Set<CanvasNodeTypeId>;
selectedVideoCount: number;
canvasTool: CanvasToolMode;
workspaceMode: CanvasWorkspaceMode;