feat(plugin): implement canvas node plugin system with dynamic registration and remote installation

This commit is contained in:
HouYunFei
2026-07-15 11:47:25 +08:00
parent f0db29d8e3
commit eef4d96787
51 changed files with 4933 additions and 72 deletions
@@ -1,8 +1,9 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
import { type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -113,7 +114,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Group ? "#94a3b8" : theme.node.muted;
const color = getNodeDefinition(node.type)?.minimapColor || theme.node.muted;
return (
<div
key={node.id}
@@ -7,6 +7,7 @@ import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
import type { CanvasNodeToolbarItem } from "@/types/canvas-plugin";
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
@@ -35,6 +36,7 @@ type CanvasNodeHoverToolbarProps = {
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
extraTools?: CanvasNodeToolbarItem[];
};
type ToolbarTool = {
@@ -72,6 +74,7 @@ export function CanvasNodeHoverToolbar({
onRetry,
onToggleFreeResize,
onDelete,
extraTools = [],
}: CanvasNodeHoverToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
@@ -150,7 +153,7 @@ export function CanvasNodeHoverToolbar({
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools];
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
const closeImageToolSettings = () => {
+33 -9
View File
@@ -1,12 +1,15 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -25,6 +28,8 @@ type CanvasNodeProps = {
showImageInfo: boolean;
resourceLabel?: CanvasResourceReference;
mentionReferences?: CanvasResourceReference[];
pluginHost?: CanvasPluginHost;
registryVersion?: number;
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
@@ -61,6 +66,7 @@ type NodeContentRendererProps = {
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
pluginContext?: CanvasNodeContext | null;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
@@ -84,6 +90,7 @@ export const CanvasNode = React.memo(function CanvasNode({
showImageInfo,
resourceLabel,
mentionReferences = [],
pluginHost,
renderPanel,
renderNodeContent,
batchCount = 0,
@@ -110,6 +117,8 @@ export const CanvasNode = React.memo(function CanvasNode({
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const definition = getNodeDefinition(data.type);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale) : null), [pluginHost, data, theme, scale]);
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title || "");
@@ -259,7 +268,7 @@ export const CanvasNode = React.memo(function CanvasNode({
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video || Boolean(definition?.keepAspectRatio?.(data)),
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
@@ -343,6 +352,10 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch?.(data.id);
return;
}
if (definition?.onDoubleClick && pluginContext) {
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
return;
}
if (data.type === CanvasNodeType.Image && hasImageContent) {
event.stopPropagation();
onViewImage?.(data);
@@ -377,6 +390,7 @@ export const CanvasNode = React.memo(function CanvasNode({
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
pluginContext={pluginContext}
mentionReferences={mentionReferences}
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
@@ -400,7 +414,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
@@ -413,8 +427,16 @@ function NodeContent(props: NodeContentRendererProps) {
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
if (Renderer) return <Renderer {...props} />;
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
const definition = getNodeDefinition(props.node.type);
if (definition?.Content && props.pluginContext) {
const PluginContent = definition.Content;
return <PluginContent ctx={props.pluginContext} />;
}
return <MissingPluginContent theme={props.theme} type={props.node.type} />;
}
const nodeContentRenderers = {
@@ -473,10 +495,12 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
);
}
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
return (
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
<Puzzle className="size-7 opacity-40" />
<span className="text-sm"></span>
<span className="text-[11px] opacity-70"> {type} </span>
</div>
);
}
@@ -0,0 +1,105 @@
import { useState } from "react";
import { App, Button, Input, Modal, Popconfirm, Switch } from "antd";
import { AlertTriangle, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { message } = App.useApp();
const plugins = usePluginStore((state) => state.plugins);
const [url, setUrl] = useState("");
const [installing, setInstalling] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const handleInstall = async () => {
const target = url.trim();
if (!target) return;
setInstalling(true);
try {
const plugin = await installPluginFromUrl(target);
message.success(`已安装插件 ${plugin.name}`);
setUrl("");
} catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setInstalling(false);
}
};
const runOnPlugin = async (record: InstalledPlugin, action: () => Promise<void>, successText: string) => {
setBusyId(record.id);
try {
await action();
message.success(successText);
} catch (error) {
message.error(`${error instanceof Error ? error.message : String(error)}`);
} finally {
setBusyId(null);
}
};
return (
<Modal title="节点插件" open={open} onCancel={onClose} footer={null} centered width={640}>
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
<span>访 AI API Key</span>
</div>
<div className="flex gap-2">
<Input
placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js"
value={url}
onChange={(event) => setUrl(event.target.value)}
onPressEnter={handleInstall}
allowClear
/>
<Button type="primary" loading={installing} onClick={handleInstall} icon={<Puzzle className="size-4" />}>
</Button>
</div>
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{plugins.length === 0 ? (
<div className="py-10 text-center text-sm" style={{ color: theme.node.muted }}>
</div>
) : (
plugins.map((record) => (
<div key={record.id} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
<span className="grid size-9 shrink-0 place-items-center rounded-lg" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
<Puzzle className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium" style={{ color: theme.node.text }}>
<span className="truncate">{record.name}</span>
<span className="rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{record.version}
</span>
</div>
<div className="truncate text-xs" style={{ color: theme.node.muted }}>
{record.description || record.url}
</div>
</div>
<Switch
size="small"
checked={record.enabled}
loading={busyId === record.id}
onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")}
/>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
</div>
))
)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,37 @@
import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react";
import { NODE_SPECS } from "@/constant/canvas";
import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeDefinition, CanvasNodeResource } from "@/types/canvas-plugin";
// 内置节点的可扩展元数据(尺寸/初始 metadata 复用 NODE_SPECS)。
// 渲染仍由 canvas-node 内部渲染器负责,故不提供 Content。
function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) return { kind: "image", url: node.metadata.content };
if (node.type === CanvasNodeType.Video && node.metadata?.content) return { kind: "video", url: node.metadata.content };
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return { kind: "audio", url: node.metadata.content };
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return { kind: "text", text: node.metadata.content || node.metadata.prompt };
return null;
}
const iconClass = "size-5";
const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [
{ type: CanvasNodeType.Text, title: "文本", icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource },
{ type: CanvasNodeType.Image, title: "图片", icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource },
{ type: CanvasNodeType.Video, title: "视频", icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
{ type: CanvasNodeType.Audio, title: "音频", icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
{ type: CanvasNodeType.Config, title: "生成配置", icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
{ type: CanvasNodeType.Group, title: "组", icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
].map((def) => {
const spec = NODE_SPECS[def.type];
return { ...def, title: spec.title, defaultSize: { width: spec.width, height: spec.height }, defaultMetadata: spec.metadata };
});
let registered = false;
export function registerBuiltinNodes() {
if (registered) return;
registered = true;
registerNodeDefinitions(BUILTIN_DEFINITIONS, "builtin");
}
+6 -2
View File
@@ -1,5 +1,6 @@
import { CanvasNodeType } from "@/types/canvas";
import type { CanvasNodeMetadata } from "@/types/canvas";
import { getNodeSpec as getRegistryNodeSpec } from "@/lib/canvas/node-registry";
type CanvasNodeSpec = {
width: number;
@@ -44,6 +45,9 @@ export const NODE_SPECS = {
},
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
export function getNodeSpec(type: CanvasNodeType) {
return NODE_SPECS[type];
// 内置类型返回内置 spec;插件类型从注册表解析
export function getNodeSpec(type: string) {
if ((Object.values(CanvasNodeType) as string[]).includes(type)) return NODE_SPECS[type as CanvasNodeType];
const spec = getRegistryNodeSpec(type);
return { width: spec.width, height: spec.height, title: spec.title, metadata: spec.metadata };
}
+5 -5
View File
@@ -1,12 +1,12 @@
import { nanoid } from "nanoid";
import { getNodeSpec } from "@/constant/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas";
import { getNodeSpec, isRegisteredNodeType } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, 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 }
| { type: "set_viewport"; viewport: ViewportTransform }
@@ -42,7 +42,7 @@ 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 nodeType = op.nodeType && isRegisteredNodeType(op.nodeType) ? op.nodeType : CanvasNodeType.Text;
const spec = getNodeSpec(nodeType);
const node: CanvasNodeData = {
id: op.id || `${nodeType}-${Date.now()}-${index}`,
+47
View File
@@ -0,0 +1,47 @@
import localforage from "localforage";
import type { PluginStorage } from "@/types/canvas-plugin";
// 画布内轻量事件总线,供节点/插件互相通信
type Handler = (payload: unknown) => void;
const handlers = new Map<string, Set<Handler>>();
export function emitCanvasEvent(event: string, payload?: unknown) {
handlers.get(event)?.forEach((handler) => {
try {
handler(payload);
} catch (error) {
console.error(`[canvas-event] handler for "${event}" failed`, error);
}
});
}
export function onCanvasEvent(event: string, handler: Handler) {
let set = handlers.get(event);
if (!set) {
set = new Set();
handlers.set(event, set);
}
set.add(handler);
return () => set!.delete(handler);
}
// 插件私有存储,按 pluginId 命名空间隔离
const stores = new Map<string, LocalForage>();
export function createPluginStorage(pluginId: string): PluginStorage {
let store = stores.get(pluginId);
if (!store) {
store = localforage.createInstance({ name: "infinite-canvas-plugins", storeName: pluginId });
stores.set(pluginId, store);
}
return {
get: (key) => store!.getItem(key),
set: async (key, value) => {
await store!.setItem(key, value);
},
remove: async (key) => {
await store!.removeItem(key);
},
};
}
@@ -1,5 +1,6 @@
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
@@ -71,7 +72,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
label,
title: node.title || label,
previewUrl: node.metadata?.content,
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
text: resourceText(node),
active,
},
];
@@ -89,10 +90,17 @@ function isResourceNode(node: CanvasNodeData) {
return Boolean(resourceKind(node));
}
function resourceText(node: CanvasNodeData): string | undefined {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt;
const resource = getNodeDefinition(node.type)?.resource?.(node);
return resource?.kind === "text" ? resource.text : undefined;
}
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) 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";
return null;
// 插件节点通过 definition.resource 声明可作为输入
return getNodeDefinition(node.type)?.resource?.(node)?.kind || null;
}
+59
View File
@@ -0,0 +1,59 @@
import { create } from "zustand";
import type { CanvasNodeDefinition } from "@/types/canvas-plugin";
import { CanvasNodeType } from "@/types/canvas";
const definitions = new Map<string, CanvasNodeDefinition>();
const ownerByType = new Map<string, string>(); // type -> pluginId(内置为 "builtin")
// 注册表版本号,注册/卸载时自增,驱动创建菜单等 UI 重渲染
export const useNodeRegistryVersion = create<{ version: number }>(() => ({ version: 0 }));
function bump() {
useNodeRegistryVersion.setState((state) => ({ version: state.version + 1 }));
}
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], pluginId = "builtin") {
defs.forEach((def) => {
definitions.set(def.type, def);
ownerByType.set(def.type, pluginId);
});
bump();
}
export function unregisterPluginNodes(pluginId: string) {
for (const [type, owner] of ownerByType) {
if (owner !== pluginId) continue;
definitions.delete(type);
ownerByType.delete(type);
}
bump();
}
export function getNodeDefinition(type: string) {
return definitions.get(type);
}
export function getNodePluginId(type: string) {
return ownerByType.get(type) || "builtin";
}
export function listNodeDefinitions() {
return Array.from(definitions.values());
}
export function isRegisteredNodeType(type: string) {
return definitions.has(type);
}
const FALLBACK_SPEC = { width: 340, height: 240, title: "节点", metadata: {} as CanvasNodeDefinition["defaultMetadata"] };
// 提供默认尺寸/标题/初始 metadata,createCanvasNode 与 agent-ops 复用
export function getNodeSpec(type: string) {
const def = definitions.get(type);
if (!def) return FALLBACK_SPEC;
return { width: def.defaultSize.width, height: def.defaultSize.height, title: def.title, metadata: def.defaultMetadata };
}
export function isBuiltinNodeType(type: string) {
return (Object.values(CanvasNodeType) as string[]).includes(type);
}
+121
View File
@@ -0,0 +1,121 @@
import { registerNodeDefinitions, unregisterPluginNodes } from "@/lib/canvas/node-registry";
import { getPluginRuntime } from "@/lib/canvas/plugin-runtime";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
import type { CanvasPlugin } from "@/types/canvas-plugin";
const cleanups = new Map<string, () => void>();
// 远程插件默认导出可以是 CanvasPlugin,或接收 runtime 返回 CanvasPlugin 的工厂
// (工厂形式用 runtime.React,无需 bundle 自带 React)
async function evaluatePluginSource(source: string): Promise<CanvasPlugin> {
const blob = new Blob([source], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
try {
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown; plugin?: unknown };
const exported = mod.default ?? mod.plugin;
const plugin = typeof exported === "function" ? (exported as (runtime: unknown) => unknown)(getPluginRuntime()) : exported;
assertPlugin(plugin);
return plugin;
} finally {
URL.revokeObjectURL(url);
}
}
function assertPlugin(plugin: unknown): asserts plugin is CanvasPlugin {
const value = plugin as Partial<CanvasPlugin> | null;
if (!value || typeof value !== "object") throw new Error("插件未导出有效对象");
if (!value.id || !Array.isArray(value.nodes) || !value.nodes.length) throw new Error("插件缺少 id 或 nodes");
}
export function activatePlugin(plugin: CanvasPlugin) {
registerNodeDefinitions(plugin.nodes, plugin.id);
const runtime = getPluginRuntime();
const disposers: Array<() => void> = [];
// 插件声明的样式:启用时注入,禁用/卸载时清理
if (plugin.css) disposers.push(runtime.injectCSS(plugin.css, plugin.id));
const cleanup = plugin.setup?.(runtime);
if (typeof cleanup === "function") disposers.push(cleanup);
if (disposers.length) cleanups.set(plugin.id, () => disposers.forEach((dispose) => dispose()));
}
export function deactivatePlugin(pluginId: string) {
cleanups.get(pluginId)?.();
cleanups.delete(pluginId);
unregisterPluginNodes(pluginId);
}
async function fetchPluginSource(url: string) {
const response = await fetch(url);
if (!response.ok) throw new Error(`下载失败 (HTTP ${response.status})`);
return response.text();
}
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
export async function installPluginFromUrl(url: string) {
const source = await fetchPluginSource(url);
const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id); // 覆盖旧版本
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true });
activatePlugin(plugin);
return plugin;
}
export async function updatePlugin(record: InstalledPlugin) {
return installPluginFromUrl(record.url);
}
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
usePluginStore.getState().setEnabled(record.id, enabled);
if (!enabled) {
deactivatePlugin(record.id);
return;
}
const plugin = await evaluatePluginSource(record.source);
activatePlugin(plugin);
}
export function uninstallPlugin(id: string) {
deactivatePlugin(id);
usePluginStore.getState().remove(id);
}
let loaded = false;
// 应用启动时加载已安装且启用的插件
export async function ensurePluginsLoaded() {
if (loaded) return;
loaded = true;
await usePluginStore.persist.rehydrate();
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
await Promise.all(
records.map(async (record) => {
try {
activatePlugin(await evaluatePluginSource(record.source));
} catch (error) {
console.error(`[plugin] 加载失败: ${record.id}`, error);
}
}),
);
await loadDevPlugins();
}
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
async function loadDevPlugins() {
const raw = import.meta.env.VITE_DEV_PLUGINS;
if (!raw) return;
const urls = raw.split(",").map((item) => item.trim()).filter(Boolean);
await Promise.all(
urls.map(async (url) => {
try {
const source = await fetchPluginSource(`${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`);
const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id);
activatePlugin(plugin);
console.info(`[plugin] dev 插件已加载: ${plugin.id} (${url})`);
} catch (error) {
console.error(`[plugin] dev 插件加载失败: ${url}`, error);
}
}),
);
}
+26
View File
@@ -0,0 +1,26 @@
import { createPluginStorage, emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
import { getNodePluginId } from "@/lib/canvas/node-registry";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
// 把宿主能力 + 节点 + 主题/缩放,组装成注入给插件节点的上下文
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number): CanvasNodeContext {
const storage = createPluginStorage(getNodePluginId(node.type));
return {
node,
theme,
scale,
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
updateNode: (patch) => host.updateNode(node.id, patch),
getNode: (id) => host.getNode(id),
getNodes: () => host.getNodes(),
getConnections: () => host.getConnections(),
getUpstream: () => host.getUpstream(node.id),
getDownstream: () => host.getDownstream(node.id),
applyOps: (ops) => host.applyOps(ops),
emit: (event, payload) => emitCanvasEvent(event, payload),
on: (event, handler) => onCanvasEvent(event, handler),
storage,
};
}
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
import { emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
import type { CanvasPluginApp } from "@/types/canvas-plugin";
// 插件运行时:远程插件通过它拿到宿主的 React 实例,避免多份 React 实例
export type PluginRuntime = CanvasPluginApp & {
React: typeof React;
jsx: typeof React.createElement;
Fragment: typeof React.Fragment;
injectCSS: (css: string, key?: string) => () => void;
};
let runtime: PluginRuntime | null = null;
// 注入插件样式:同 key 覆盖旧样式,返回移除函数
function injectCSS(css: string, key?: string) {
const id = key ? `canvas-plugin-style-${key}` : undefined;
if (id) document.getElementById(id)?.remove();
const style = document.createElement("style");
if (id) style.id = id;
style.dataset.canvasPluginStyle = "true";
style.textContent = css;
document.head.appendChild(style);
return () => style.remove();
}
export function getPluginRuntime(): PluginRuntime {
if (!runtime) {
runtime = {
React,
jsx: React.createElement,
Fragment: React.Fragment,
injectCSS,
version: typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev",
emit: emitCanvasEvent,
on: onCanvasEvent,
};
(window as unknown as { InfiniteCanvasRuntime?: PluginRuntime }).InfiniteCanvasRuntime = runtime;
}
return runtime;
}
+67 -14
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Puzzle, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
@@ -43,6 +43,12 @@ import { useAgentStore } from "@/stores/use-agent-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes";
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
import type { CanvasPluginHost } from "@/types/canvas-plugin";
import {
CanvasNodeType,
type CanvasAssistantImage,
@@ -51,6 +57,7 @@ import {
type CanvasImageGenerationType,
type CanvasNodeData,
type CanvasNodeMetadata,
type CanvasNodeTypeId,
type ConnectionHandle,
type ContextMenuState,
type Position,
@@ -60,6 +67,9 @@ import {
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio } from "@/types/media";
// 内置节点注册到统一注册表(模块加载时执行一次)
registerBuiltinNodes();
type CanvasClipboard = {
nodes: CanvasNodeData[];
connections: CanvasConnection[];
@@ -104,7 +114,7 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
3. 尽量写成可直接用于生图模型的完整提示词。`;
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(type);
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
@@ -212,11 +222,13 @@ function ConnectionCreateOption({ theme, icon, title, description, onClick }: {
);
}
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: CanvasNodeType) => void; onClose: () => void }) {
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useNodeRegistryVersion();
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
return (
<div
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
className="absolute z-[120] max-h-[70vh] w-[300px] overflow-y-auto rounded-[18px] border p-3 shadow-2xl backdrop-blur thin-scrollbar"
data-canvas-no-zoom
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
@@ -228,12 +240,9 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
</button>
</div>
<div className="grid gap-1">
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频" onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频" onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="生成配置" onClick={() => onCreate(CanvasNodeType.Config)} />
<ConnectionCreateOption theme={theme} icon={<Group className="size-5" />} title="组" onClick={() => onCreate(CanvasNodeType.Group)} />
{definitions.map((def) => (
<ConnectionCreateOption key={def.type} theme={theme} icon={def.icon} title={def.title} description={def.description} onClick={() => onCreate(def.type)} />
))}
</div>
</div>
);
@@ -241,6 +250,8 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
function InfiniteCanvasPage() {
const { message, modal } = App.useApp();
// 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染
const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version);
const params = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -322,6 +333,7 @@ function InfiniteCanvasPage() {
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [editRequestNonce, setEditRequestNonce] = useState(0);
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [pluginManagerOpen, setPluginManagerOpen] = useState(false);
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
@@ -602,7 +614,7 @@ function InfiniteCanvasPage() {
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
setPendingConnectionCreate(null);
setConnecting(null);
},
@@ -791,8 +803,38 @@ function InfiniteCanvasPage() {
setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) });
return () => setAgentCanvasContext(null);
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
const pluginHost = useMemo<CanvasPluginHost>(
() => ({
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
getNodes: () => nodesRef.current,
getConnections: () => connectionsRef.current,
getUpstream: (nodeId) => connectionsRef.current.filter((conn) => conn.toNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
getDownstream: (nodeId) => connectionsRef.current.filter((conn) => conn.fromNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
applyOps: (ops) => applyAgentOps(ops),
}),
[applyAgentOps],
);
const renderPluginPanel = useCallback(
(panelNode: CanvasNodeData) => {
const Panel = getNodeDefinition(panelNode.type)?.Panel;
if (!Panel) return null;
const ctx = buildNodeContext(pluginHost, panelNode, theme, viewportRef.current.k);
return <Panel ctx={ctx} onClose={() => setDialogNodeId(null)} />;
},
[pluginHost, theme],
);
// 启动时加载已安装的远程插件
useEffect(() => {
void ensurePluginsLoaded();
}, []);
const createNode = useCallback(
(type: CanvasNodeType, position?: Position) => {
(type: CanvasNodeTypeId, position?: Position) => {
const targetPosition = position || getCanvasCenter();
const configMetadata =
type === CanvasNodeType.Config
@@ -807,7 +849,8 @@ function InfiniteCanvasPage() {
setNodes((prev) => [...prev, newNode]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
const definition = getNodeDefinition(type);
if (definition?.Panel || (isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group)) setDialogNodeId(newNode.id);
},
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
);
@@ -2518,6 +2561,7 @@ function InfiniteCanvasPage() {
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onOpenPlugins={() => setPluginManagerOpen(true)}
onUndo={undoCanvas}
onRedo={redoCanvas}
agentOpen={agentPanelOpen}
@@ -2600,8 +2644,12 @@ function InfiniteCanvasPage() {
showImageInfo={showImageInfo}
resourceLabel={resourceReferenceByNodeId.get(node.id)}
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
pluginHost={pluginHost}
registryVersion={nodeRegistryVersion}
renderPanel={(panelNode) =>
panelNode.type === CanvasNodeType.Config ? (
getNodeDefinition(panelNode.type)?.Panel ? (
renderPluginPanel(panelNode)
) : panelNode.type === CanvasNodeType.Config ? (
<CanvasConfigComposer
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
inputs={configInputsById.get(panelNode.id) || []}
@@ -2692,6 +2740,7 @@ function InfiniteCanvasPage() {
<CanvasNodeHoverToolbar
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
viewport={viewport}
extraTools={toolbarNode ? getNodeDefinition(toolbarNode.type)?.toolbar?.(buildNodeContext(pluginHost, toolbarNode, theme, viewport.k)) : undefined}
onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar}
onInfo={(node) => setInfoNodeId(node.id)}
@@ -2768,6 +2817,7 @@ function InfiniteCanvasPage() {
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
<CanvasPluginManagerModal open={pluginManagerOpen} onClose={() => setPluginManagerOpen(false)} />
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
@@ -2839,6 +2889,7 @@ function CanvasTopBar({
onCreateProject,
onDeleteProject,
onImportImage,
onOpenPlugins,
onUndo,
onRedo,
agentOpen,
@@ -2859,6 +2910,7 @@ function CanvasTopBar({
onCreateProject: () => void;
onDeleteProject: () => void;
onImportImage: () => void;
onOpenPlugins: () => void;
onUndo: () => void;
onRedo: () => void;
agentOpen: boolean;
@@ -2895,6 +2947,7 @@ function CanvasTopBar({
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
{ key: "plugins", icon: <Puzzle className="size-4" />, label: "节点插件", onClick: onOpenPlugins },
{ type: "divider" },
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
+43
View File
@@ -0,0 +1,43 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { localForageStorage } from "@/lib/localforage-storage";
export type InstalledPlugin = {
id: string;
name: string;
version: string;
description?: string;
url: string; // 安装来源,可用于更新
source: string; // 缓存的插件源码,离线可用、版本固定
enabled: boolean;
installedAt: string;
};
type PluginStore = {
plugins: InstalledPlugin[];
upsert: (plugin: Omit<InstalledPlugin, "installedAt"> & { installedAt?: string }) => void;
setEnabled: (id: string, enabled: boolean) => void;
remove: (id: string) => void;
};
export const usePluginStore = create<PluginStore>()(
persist(
(set) => ({
plugins: [],
upsert: (plugin) =>
set((state) => {
const installedAt = plugin.installedAt || new Date().toISOString();
const exists = state.plugins.some((item) => item.id === plugin.id);
const next = { ...plugin, installedAt };
return { plugins: exists ? state.plugins.map((item) => (item.id === plugin.id ? next : item)) : [next, ...state.plugins] };
}),
setEnabled: (id, enabled) => set((state) => ({ plugins: state.plugins.map((item) => (item.id === id ? { ...item, enabled } : item)) })),
remove: (id) => set((state) => ({ plugins: state.plugins.filter((item) => item.id !== id) })),
}),
{
name: "infinite-canvas:plugin_store",
storage: createJSONStorage(() => localForageStorage),
},
),
);
+102
View File
@@ -0,0 +1,102 @@
import type { ComponentType, ReactNode } from "react";
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasConnection, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
// 插件节点作为上游输入被消费时输出的资源
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
// 节点自带的工具栏按钮(追加到 hover 工具栏尾部)
export type CanvasNodeToolbarItem = {
id: string;
title: string;
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
danger?: boolean;
};
// 每个节点渲染时注入的上下文,是插件与画布交互的核心接口
export type CanvasNodeContext = {
node: CanvasNodeData;
theme: CanvasTheme;
scale: number;
// 自身数据
updateMetadata: (patch: CanvasNodeMetadata) => void;
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
// 图访问
getNode: (id: string) => CanvasNodeData | null;
getNodes: () => CanvasNodeData[];
getConnections: () => CanvasConnection[];
getUpstream: () => CanvasNodeData[];
getDownstream: () => CanvasNodeData[];
// 画布操作,复用 Agent 指令集(增删节点/连线/选择/视口/触发生成)
applyOps: (ops: CanvasAgentOp[]) => void;
// 节点间/插件间通信
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
// 插件私有持久化,命名空间隔离
storage: PluginStorage;
};
export type PluginStorage = {
get: <T = unknown>(key: string) => Promise<T | null>;
set: (key: string, value: unknown) => Promise<void>;
remove: (key: string) => Promise<void>;
};
// 画布宿主提供的、与具体节点无关的能力集合(由画布页面构建、注入渲染链路)
export type CanvasPluginHost = {
getNode: (id: string) => CanvasNodeData | null;
getNodes: () => CanvasNodeData[];
getConnections: () => CanvasConnection[];
getUpstream: (nodeId: string) => CanvasNodeData[];
getDownstream: (nodeId: string) => CanvasNodeData[];
updateNode: (nodeId: string, patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
updateMetadata: (nodeId: string, patch: CanvasNodeMetadata) => void;
applyOps: (ops: CanvasAgentOp[]) => void;
};
// 节点类型定义:内置节点与插件节点统一走这套结构
export type CanvasNodeDefinition = {
type: string; // 内置如 "image";插件建议 "<pluginId>:<name>"
title: string;
icon: ReactNode;
description?: string;
defaultSize: { width: number; height: number };
defaultMetadata?: CanvasNodeMetadata;
minimapColor?: string;
showInCreateMenu?: boolean; // 默认 true
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
Content?: ComponentType<{ ctx: CanvasNodeContext }>;
Panel?: ComponentType<{ ctx: CanvasNodeContext; onClose: () => void }>;
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
};
// 插件启动时可访问的应用能力
export type CanvasPluginApp = {
version: string;
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
// 注入插件样式,返回移除函数;传 key 时同 key 覆盖旧样式
injectCSS: (css: string, key?: string) => () => void;
};
// 插件包默认导出
export type CanvasPlugin = {
id: string;
name: string;
version: string;
description?: string;
minAppVersion?: string;
css?: string; // 插件样式,启用时自动注入、卸载/禁用时自动清理
nodes: CanvasNodeDefinition[];
setup?: (app: CanvasPluginApp) => void | (() => void);
};
+5 -2
View File
@@ -18,6 +18,9 @@ export enum CanvasNodeType {
Group = "group",
}
// 节点类型放开为字符串,内置类型用 CanvasNodeType,插件类型为 "<pluginId>:<name>"
export type CanvasNodeTypeId = CanvasNodeType | (string & {});
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
export type CanvasImageGenerationType = "generation" | "edit";
@@ -62,7 +65,7 @@ export type CanvasNodeMetadata = {
export type CanvasNodeData = {
id: string;
type: CanvasNodeType;
type: CanvasNodeTypeId;
title: string;
position: Position;
width: number;
@@ -78,7 +81,7 @@ export type CanvasConnection = {
export type CanvasAssistantReference = {
id: string;
type: CanvasNodeType;
type: CanvasNodeTypeId;
title: string;
dataUrl?: string;
storageKey?: string;
+5
View File
@@ -2,3 +2,8 @@
declare const __APP_VERSION__: string;
declare const __APP_RELEASES__: import("@/lib/release").ReleaseInfo[];
interface ImportMetaEnv {
// 逗号分隔的本地开发插件 URL,每次启动重新拉取(不缓存、不落库)
readonly VITE_DEV_PLUGINS?: string;
}