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
+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>
);
}