feat(panorama): enhance 3D panorama node with AI generation support and unified interaction toggle

This commit is contained in:
HouYunFei
2026-07-16 13:21:25 +08:00
parent 2d2c7e3d5e
commit 3f179a607a
12 changed files with 547 additions and 120 deletions
+119 -6
View File
@@ -4,11 +4,11 @@ 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 { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { DOCS_URL } from "@/constant/env";
import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { nanoid } from "nanoid";
@@ -49,7 +49,7 @@ 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 type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
import {
CanvasNodeType,
type CanvasAssistantImage,
@@ -819,6 +819,47 @@ function InfiniteCanvasPage() {
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
// 提供给插件节点的宿主能力(节点无关,方法接收 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,
@@ -829,8 +870,11 @@ function InfiniteCanvasPage() {
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],
[applyAgentOps, pluginAi],
);
const renderPluginPanel = useCallback(
@@ -843,6 +887,28 @@ function InfiniteCanvasPage() {
[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();
@@ -865,8 +931,15 @@ function InfiniteCanvasPage() {
setSelectedConnectionId(null);
const definition = getNodeDefinition(type);
// 纯展示型插件节点(hidePanel)不弹面板;插件自定义 Panel 需显式 autoOpenPanel 才在新建时打开;
// 声明了 useBuiltinPanel 的插件节点复用内置生成面板,新建即打开(与图片节点一致);
// 内置的图片/视频/配置类节点保持原有「新建即打开生图面板」行为。
const wantsPanel = definition?.hidePanel ? false : definition?.Panel ? Boolean(definition.autoOpenPanel) : isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group;
const wantsPanel = definition?.hidePanel
? false
: definition?.Panel
? Boolean(definition.autoOpenPanel)
: definition?.useBuiltinPanel
? true
: isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group;
if (wantsPanel) setDialogNodeId(newNode.id);
},
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
@@ -2080,6 +2153,45 @@ function InfiniteCanvasPage() {
return;
}
// 插件节点声明了 useBuiltinPanel.writeBackToSelf:复用内置面板生成,但结果写回节点自身。
// 目前支持 image 模式(全景等展示型节点),前缀由 useBuiltinPanel.promptPrefix 指定。
const builtinPanel = sourceNode ? getNodeDefinition(sourceNode.type)?.useBuiltinPanel : undefined;
if (sourceNode && builtinPanel?.writeBackToSelf && builtinPanel.mode === "image") {
const scene = prompt.trim();
if (!scene) return;
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId);
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: scene, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
try {
const fullPrompt = (builtinPanel.promptPrefix || "") + scene;
// 上游图片节点作为参考图(图生图);无上游则纯文生图
const upstreamNodes = connectionsRef.current
.filter((conn) => conn.toNodeId === nodeId)
.map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const refs = upstreamNodes.flatMap((up) =>
typeof up.metadata?.content === "string" && up.metadata.content && up.type !== sourceNode.type
? [{ id: up.id, name: `${up.title || up.id}.png`, type: up.metadata.mimeType || "image/png", dataUrl: up.metadata.content, storageKey: up.metadata.storageKey }]
: [],
);
const image = refs.length
? await requestEdit({ ...generationConfig, count: "1" }, fullPrompt, refs, undefined, { signal: controller.signal }).then((items) => items[0])
: await requestGeneration({ ...generationConfig, count: "1" }, fullPrompt, { signal: controller.signal }).then((items) => items[0]);
const uploaded = await uploadImage(image.dataUrl);
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), prompt: scene, model: generationConfig.model, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)));
setDialogNodeId(null);
} catch (error) {
if (!isGenerationCanceled(error)) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
message.error(errorDetails);
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
}
} finally {
finishGenerationRequest(nodeId, controller);
}
return;
}
setRunningNodeId(nodeId);
const runController = startGenerationRequest(nodeId, nodeId, nodeId);
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
@@ -2714,6 +2826,7 @@ function InfiniteCanvasPage() {
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onStop={confirmStopGeneration}
modeOverride={getNodeDefinition(panelNode.type)?.useBuiltinPanel?.mode}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
@@ -2790,7 +2903,7 @@ function InfiniteCanvasPage() {
<CanvasNodeHoverToolbar
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
viewport={viewport}
extraTools={toolbarNode ? getNodeDefinition(toolbarNode.type)?.toolbar?.(buildNodeContext(pluginHost, toolbarNode, theme, viewport.k)) : undefined}
extraTools={toolbarNode ? buildNodeToolbarItems(toolbarNode) : undefined}
onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar}
onInfo={(node) => setInfoNodeId(node.id)}