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
+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 },