mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user