refactor(canvas-project): extract plugin host into usePluginHost hook

Move the plugin node host capabilities (pluginAi generation bridge, pluginHost
canvas accessors, renderPluginPanel, buildNodeToolbarItems) and the plugin-load
effect out of InfiniteCanvasPage into usePluginHost. The hook receives the
shared config/refs/setters explicitly and keeps identical dependency arrays.

This also relocates the AI-service and plugin imports (requestVideoGeneration,
selectableModelsByCapability, buildNodeContext, ensurePluginsLoaded, the
canvas-plugin types, etc.) that were only used by this cluster.

project.tsx is now ~2950 lines, down from 3536 at the start of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
HouYunFei
2026-07-17 14:19:00 +08:00
parent cf0561d12a
commit 430d1bd956
2 changed files with 159 additions and 109 deletions
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { buildGenerationConfig } from "@/lib/canvas/canvas-generation-helpers";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
import type { ReferenceImage } from "@/types/image";
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type PluginHostParams = {
effectiveConfig: AiConfig;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
openConfigDialog: (open: boolean) => void;
theme: CanvasTheme;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
viewportRef: MutableRefObject<ViewportTransform>;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
applyAgentOps: (ops?: CanvasAgentOp[]) => unknown;
};
/**
* 插件节点宿主能力:把宿主侧的 AI 生成、画布读写、面板开关等封装成插件可调用的 host/ai 对象,
* 并在挂载时加载已安装的远程插件。返回给画布用于渲染插件面板与工具条。
*/
export function usePluginHost(params: PluginHostParams) {
const { effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps } = params;
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
const pluginAi = useMemo<CanvasPluginAi>(() => {
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
const ensureReady = (config: AiConfig) => {
if (!isAiConfigReady(config, config.model)) {
openConfigDialog(true);
throw new Error("AI 配置未就绪,请先在设置里配置模型与密钥");
}
};
return {
generateImage: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
ensureReady(config);
const references = toReferences(options?.references);
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
return { images: items.map((item) => item.dataUrl) };
},
generateVideo: async (prompt, options) => {
const config = {
...buildGenerationConfig(effectiveConfig, undefined, "video"),
...(options?.model ? { model: options.model } : {}),
...(options?.size ? { size: options.size } : {}),
...(options?.seconds ? { videoSeconds: options.seconds } : {}),
};
ensureReady(config);
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
},
generateText: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "text"), ...(options?.model ? { model: options.model } : {}) };
ensureReady(config);
const messages: AiTextMessage[] = [...(options?.system ? [{ role: "system" as const, content: options.system }] : []), { role: "user" as const, content: prompt }];
const text = await requestImageQuestion(config, messages, (delta) => options?.onDelta?.(delta), { signal: options?.signal });
return { text };
},
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
};
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
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),
ai: pluginAi,
openPanel: (nodeId) => setDialogNodeId(nodeId),
closePanel: () => setDialogNodeId(null),
}),
[applyAgentOps, pluginAi],
);
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],
);
// 组装节点悬浮工具条按钮:插件自定义 toolbar +(声明 interactionToggle 时)宿主自动注入的「交互 ⇄ 移动」开关
const buildNodeToolbarItems = useCallback(
(node: CanvasNodeData): CanvasNodeToolbarItem[] => {
const definition = getNodeDefinition(node.type);
const ctx = buildNodeContext(pluginHost, node, theme, viewportRef.current.k);
const custom = definition?.toolbar?.(ctx) || [];
// 仅在节点有内容(展示态)且非强制交互态(如编辑态)时提供「交互/移动」开关
if (!definition?.interactionToggle || !node.metadata?.content || definition.forceInteractive?.(node)) return custom;
const interactive = Boolean(node.metadata?.interactive);
const toggle: CanvasNodeToolbarItem = {
id: "node-interaction-toggle",
title: interactive ? "当前:交互中。点击切回「移动」——拖动可移动节点" : "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)",
label: interactive ? "移动" : "交互",
icon: interactive ? "✋" : "🖐",
active: interactive,
onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }),
};
return [toggle, ...custom];
},
[pluginHost, theme],
);
// 启动时加载已安装的远程插件
useEffect(() => {
void ensurePluginsLoaded();
}, []);
return { pluginHost, renderPluginPanel, buildNodeToolbarItems };
}
+15 -109
View File
@@ -4,10 +4,10 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Group, Video } from "lucide-react";
import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { defaultConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { uploadImage } from "@/services/image-storage";
import { uploadMediaFile } from "@/services/file-storage";
import { nanoid } from "nanoid";
@@ -41,6 +41,7 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { useAgentStore } from "@/stores/use-agent-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useAgentBridge } from "@/pages/canvas/hooks/use-agent-bridge";
import { usePluginHost } from "@/pages/canvas/hooks/use-plugin-host";
import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory";
import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry";
@@ -63,14 +64,11 @@ import {
sourceNodeReferenceImages,
} from "@/lib/canvas/canvas-generation-helpers";
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, 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 { CanvasRefreshShell } from "@/components/canvas/canvas-refresh-shell";
import { CanvasTopBar } from "@/components/canvas/canvas-top-bar";
import { ConnectionCreateMenu, NodeCreateMenu, type PendingConnectionCreate } from "@/components/canvas/canvas-create-menus";
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
import {
CanvasNodeType,
type CanvasAssistantImage,
@@ -668,110 +666,18 @@ function InfiniteCanvasPage() {
setContextMenu,
});
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
const pluginAi = useMemo<CanvasPluginAi>(() => {
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
const ensureReady = (config: AiConfig) => {
if (!isAiConfigReady(config, config.model)) {
openConfigDialog(true);
throw new Error("AI 配置未就绪,请先在设置里配置模型与密钥");
}
};
return {
generateImage: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
ensureReady(config);
const references = toReferences(options?.references);
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
return { images: items.map((item) => item.dataUrl) };
},
generateVideo: async (prompt, options) => {
const config = {
...buildGenerationConfig(effectiveConfig, undefined, "video"),
...(options?.model ? { model: options.model } : {}),
...(options?.size ? { size: options.size } : {}),
...(options?.seconds ? { videoSeconds: options.seconds } : {}),
};
ensureReady(config);
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
},
generateText: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "text"), ...(options?.model ? { model: options.model } : {}) };
ensureReady(config);
const messages: AiTextMessage[] = [...(options?.system ? [{ role: "system" as const, content: options.system }] : []), { role: "user" as const, content: prompt }];
const text = await requestImageQuestion(config, messages, (delta) => options?.onDelta?.(delta), { signal: options?.signal });
return { text };
},
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
};
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
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),
ai: pluginAi,
openPanel: (nodeId) => setDialogNodeId(nodeId),
closePanel: () => setDialogNodeId(null),
}),
[applyAgentOps, pluginAi],
);
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],
);
// 组装节点悬浮工具条按钮:插件自定义 toolbar +(声明 interactionToggle 时)宿主自动注入的「交互 ⇄ 移动」开关
const buildNodeToolbarItems = useCallback(
(node: CanvasNodeData): CanvasNodeToolbarItem[] => {
const definition = getNodeDefinition(node.type);
const ctx = buildNodeContext(pluginHost, node, theme, viewportRef.current.k);
const custom = definition?.toolbar?.(ctx) || [];
// 仅在节点有内容(展示态)且非强制交互态(如编辑态)时提供「交互/移动」开关
if (!definition?.interactionToggle || !node.metadata?.content || definition.forceInteractive?.(node)) return custom;
const interactive = Boolean(node.metadata?.interactive);
const toggle: CanvasNodeToolbarItem = {
id: "node-interaction-toggle",
title: interactive ? "当前:交互中。点击切回「移动」——拖动可移动节点" : "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)",
label: interactive ? "移动" : "交互",
icon: interactive ? "✋" : "🖐",
active: interactive,
onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }),
};
return [toggle, ...custom];
},
[pluginHost, theme],
);
// 启动时加载已安装的远程插件
useEffect(() => {
void ensurePluginsLoaded();
}, []);
const { pluginHost, renderPluginPanel, buildNodeToolbarItems } = usePluginHost({
effectiveConfig,
isAiConfigReady,
openConfigDialog,
theme,
nodesRef,
connectionsRef,
viewportRef,
setNodes,
setDialogNodeId,
applyAgentOps,
});
const createNode = useCallback(
(type: CanvasNodeTypeId, position?: Position) => {
const targetPosition = position || getCanvasCenter();