mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
feat(panorama): enhance 3D panorama node with AI generation support and unified interaction toggle
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user