diff --git a/web/src/pages/canvas/hooks/use-plugin-host.tsx b/web/src/pages/canvas/hooks/use-plugin-host.tsx new file mode 100644 index 0000000..d768a1d --- /dev/null +++ b/web/src/pages/canvas/hooks/use-plugin-host.tsx @@ -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; + connectionsRef: MutableRefObject; + viewportRef: MutableRefObject; + setNodes: Dispatch>; + setDialogNodeId: Dispatch>; + 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(() => { + // 把插件传入的参考图(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( + () => ({ + 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 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 }; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index ab4def5..ba2f25f 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -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(() => { - // 把插件传入的参考图(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( - () => ({ - 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 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();