+
🌐
+
e.stopPropagation()} style={{ display: "flex", gap: 10 }}>
+ onPick(e.target.files?.[0])} />
+
+
- );
- }
- return
e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }} />;
+ 支持 2:1 等距柱状全景图
+
+ );
+}
+
+function PanoramaContent({ ctx }: CanvasNodeContentProps) {
+ const upstreamImage = ctx
+ .getUpstream()
+ .map((node) => node.metadata?.content)
+ .find((content): content is string => typeof content === "string" && Boolean(content));
+ const source = (typeof ctx.node.metadata?.content === "string" ? ctx.node.metadata.content : "") || upstreamImage || "";
+
+ if (!source) return
;
+ return
;
}
export default definePlugin({
id: "panorama",
name: "3D 全景节点",
- version: "1.0.0",
- description: "查看 360° 等距柱状全景图,可从上游图片节点取图",
+ version: "1.1.0",
+ description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
nodes: [
{
type: "panorama:viewer",
title: "3D 全景",
icon: "🌐",
- description: "360° 全景查看器",
+ description: "360° 全景查看器(上传 / AI 生成)",
defaultSize: { width: 480, height: 300 },
defaultMetadata: {},
minimapColor: "#0ea5e9",
+ // 宿主自动提供「交互 ⇄ 移动」开关:默认移动(拖动节点),切到交互后可转全景视角
+ interactionToggle: true,
+ // 复用宿主内置生成面板(模型选择/设置/提示词库完全一致),生成结果写回本节点。
+ // 前缀约束模型产出可用于球面贴图的 equirectangular 全景图。
+ useBuiltinPanel: { mode: "image", promptPrefix: PANORAMA_SYSTEM_PROMPT, writeBackToSelf: true },
+ // 作为上游被消费时,输出自身全景图,供下游节点引用
+ resource: (node) => {
+ const content = node.metadata?.content;
+ return typeof content === "string" && content ? { kind: "image", url: content } : null;
+ },
Content: PanoramaContent,
+ // 工具条始终提供「AI 生成」(打开下方内置面板);有图时额外提供「换图」
+ toolbar: (ctx) => {
+ const hasOwnContent = typeof ctx.node.metadata?.content === "string" && Boolean(ctx.node.metadata?.content);
+ return [
+ {
+ id: "panorama-generate",
+ title: "用 AI 生成全景图(打开下方面板)",
+ label: "AI 生成",
+ icon: "✨",
+ onClick: () => ctx.openPanel(),
+ },
+ ...(hasOwnContent
+ ? [
+ {
+ id: "panorama-reset",
+ title: "清空当前全景图,重新上传",
+ label: "换图",
+ icon: "🔄",
+ onClick: () => ctx.updateMetadata({ content: "" }),
+ },
+ ]
+ : []),
+ ];
+ },
},
],
});
diff --git a/plugins/canvas/sdk/src/types.ts b/plugins/canvas/sdk/src/types.ts
index e851073..32edf76 100644
--- a/plugins/canvas/sdk/src/types.ts
+++ b/plugins/canvas/sdk/src/types.ts
@@ -135,6 +135,70 @@ export type CanvasAgentOp =
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
+// ---------------------------------------------------------------------------
+// AI 生成:插件直接复用宿主的模型/密钥配置发起生成(生图/生视频/生文本/生音频)
+//
+// 插件本身拿不到 API Key 与模型配置,这些能力由宿主注入。前置/系统提示词由
+// 插件自行拼进 prompt(宿主不感知),因此不同插件可各自定制自己的提示词策略。
+// 若宿主 AI 配置未就绪,会抛错(并由宿主提示用户去配置),插件用 try/catch 处理即可。
+// ---------------------------------------------------------------------------
+
+// 生成公共可选项;references 为图生图/图生视频的参考图(dataURL 或可访问 URL)
+export type GenerateOptions = {
+ signal?: AbortSignal;
+ references?: string[];
+ model?: string; // 指定模型(取自 ai.listModels 的 value);缺省用宿主当前配置
+};
+
+export type GenerateImageOptions = GenerateOptions & {
+ count?: number; // 期望生成张数(宿主会按模型上限裁剪)
+ size?: string; // 形如 "1024x1024" / "auto";缺省用宿主当前配置
+};
+
+export type GenerateImageResult = {
+ // 生成图的 dataURL(已可直接作为
![]()
或 three 纹理使用)
+ images: string[];
+};
+
+export type GenerateVideoOptions = GenerateOptions & {
+ size?: string;
+ seconds?: string;
+};
+
+export type GenerateVideoResult = {
+ url: string; // 视频可访问 URL
+ mimeType: string;
+ width?: number;
+ height?: number;
+ durationMs?: number;
+};
+
+export type GenerateTextOptions = {
+ signal?: AbortSignal;
+ model?: string;
+ system?: string; // 附加系统提示词(拼在宿主系统提示之后)
+ onDelta?: (text: string) => void; // 流式增量回调
+};
+
+export type GenerateTextResult = {
+ text: string;
+};
+
+// 一个可选模型:value 传回给 generateXxx({ model }),label 用于展示
+export type ModelCapability = "image" | "video" | "text" | "audio";
+export type ModelOption = { value: string; label: string };
+
+// 宿主注入的 AI 生成能力,挂在 ctx.ai 下。任何插件均可调用。
+export type CanvasPluginAi = {
+ generateImage: (prompt: string, options?: GenerateImageOptions) => Promise
;
+ generateVideo: (prompt: string, options?: GenerateVideoOptions) => Promise;
+ generateText: (prompt: string, options?: GenerateTextOptions) => Promise;
+ // 列出某能力下用户已配置的可选模型;不传能力则返回全部
+ listModels: (capability?: ModelCapability) => ModelOption[];
+ // 该能力当前默认选中的模型 value(可作为下拉框初始值)
+ defaultModel: (capability: ModelCapability) => string;
+};
+
// ---------------------------------------------------------------------------
// 节点上下文:每个节点渲染时注入,是插件与画布交互的核心接口
// ---------------------------------------------------------------------------
@@ -164,6 +228,11 @@ export type CanvasNodeContext = {
// 节点间/插件间通信
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
+ // AI 生成能力(生图/生视频/生文本),复用宿主模型配置
+ ai: CanvasPluginAi;
+ // 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
+ openPanel: () => void;
+ closePanel: () => void;
// 插件私有持久化,按插件 id 命名空间隔离
storage: PluginStorage;
};
@@ -185,6 +254,16 @@ export type CanvasNodeToolbarItem = {
export type CanvasNodeContentProps = { ctx: CanvasNodeContext };
export type CanvasNodePanelProps = { ctx: CanvasNodeContext; onClose: () => void };
+// 复用宿主内置生成面板(与图片/视频/文本节点同一个组件:模型选择、参数设置、
+// 提示词库、运行/停止状态全部一致)。声明它即可获得完整生成体验,无需自写面板。
+export type CanvasBuiltinPanelConfig = {
+ mode: "image" | "video" | "text" | "audio"; // 生成类型,决定面板里的模型/设置项
+ // 提交给模型前自动拼在用户提示词前面的固定前缀(如全景图的 equirectangular 约束)
+ promptPrefix?: string;
+ // true(默认)时生成结果写回本节点自身 metadata.content;false 则按内置逻辑生成到下游新节点
+ writeBackToSelf?: boolean;
+};
+
export type CanvasNodeDefinition = {
type: string; // 建议 ":",全局唯一
title: string;
@@ -197,11 +276,20 @@ export type CanvasNodeDefinition = {
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
+ // 复用宿主内置生成面板;与自定义 Panel 二选一(同时提供时优先 Panel)
+ useBuiltinPanel?: CanvasBuiltinPanelConfig;
+ // 为 true 时:宿主自动在工具条加「交互 ⇄ 移动」开关,并按 metadata.interactive 控制内容层指针事件。
+ // 默认(interactive 未设/为 false)为「移动」态:内容不吃指针,拖动整块移动节点;
+ // 切到「交互」态后内容可点击/拖拽(如全景转视角、iframe 操作)。适合内部有交互的展示型节点。
+ interactionToggle?: boolean;
+ // 配合 interactionToggle:返回 true 表示当前节点内容「强制可交互」(如编辑态),
+ // 此时忽略 interactive 标志、始终允许操作,并隐藏移动/交互开关。缺省视为 false。
+ forceInteractive?: (node: CanvasNodeData) => boolean;
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染
Content?: ComponentType;
- Panel?: ComponentType; // 节点下方面板
+ Panel?: ComponentType; // 节点下方面板(自定义)
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
};
diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx
index ad1ee7e..a96e32b 100644
--- a/web/src/components/canvas/canvas-node-prompt-panel.tsx
+++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx
@@ -25,13 +25,14 @@ type CanvasNodePromptPanelProps = {
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
+ modeOverride?: CanvasNodeGenerationMode; // 插件节点用 useBuiltinPanel.mode 指定生成类型
};
-export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
+export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
- const mode = defaultMode(node.type);
+ const mode = modeOverride ?? defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx
index 62f43de..a0d5164 100644
--- a/web/src/components/canvas/canvas-node.tsx
+++ b/web/src/components/canvas/canvas-node.tsx
@@ -129,6 +129,11 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isGroup = data.type === CanvasNodeType.Group;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
+ // 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。
+ // forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。
+ const supportsInteractionToggle = Boolean(definition?.interactionToggle);
+ const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
+ const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
@@ -374,6 +379,7 @@ export const CanvasNode = React.memo(function CanvasNode({
style={
{
background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
+ pointerEvents: contentInteractive ? undefined : "none",
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
diff --git a/web/src/lib/canvas/plugin-node-context.ts b/web/src/lib/canvas/plugin-node-context.ts
index b34c830..1d848d0 100644
--- a/web/src/lib/canvas/plugin-node-context.ts
+++ b/web/src/lib/canvas/plugin-node-context.ts
@@ -22,6 +22,9 @@ export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, t
applyOps: (ops) => host.applyOps(ops),
emit: (event, payload) => emitCanvasEvent(event, payload),
on: (event, handler) => onCanvasEvent(event, handler),
+ ai: host.ai,
+ openPanel: () => host.openPanel(node.id),
+ closePanel: () => host.closePanel(),
storage,
};
}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index fa8e377..aa89252 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -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(() => {
+ // 把插件传入的参考图(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,
@@ -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() {
setInfoNodeId(node.id)}
diff --git a/web/src/types/canvas-plugin.ts b/web/src/types/canvas-plugin.ts
index 6859f7e..3993637 100644
--- a/web/src/types/canvas-plugin.ts
+++ b/web/src/types/canvas-plugin.ts
@@ -8,6 +8,25 @@ import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references
// 插件节点作为上游输入被消费时输出的资源
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
+// --- AI 生成能力(生图/生视频/生文本),由宿主注入,复用宿主模型/密钥配置 ---
+export type GenerateOptions = { signal?: AbortSignal; references?: string[]; model?: string };
+export type GenerateImageOptions = GenerateOptions & { count?: number; size?: string };
+export type GenerateImageResult = { images: string[] };
+export type GenerateVideoOptions = GenerateOptions & { size?: string; seconds?: string };
+export type GenerateVideoResult = { url: string; mimeType: string; width?: number; height?: number; durationMs?: number };
+export type GenerateTextOptions = { signal?: AbortSignal; model?: string; system?: string; onDelta?: (text: string) => void };
+export type GenerateTextResult = { text: string };
+export type PluginModelCapability = "image" | "video" | "text" | "audio";
+export type ModelOption = { value: string; label: string };
+
+export type CanvasPluginAi = {
+ generateImage: (prompt: string, options?: GenerateImageOptions) => Promise;
+ generateVideo: (prompt: string, options?: GenerateVideoOptions) => Promise;
+ generateText: (prompt: string, options?: GenerateTextOptions) => Promise;
+ listModels: (capability?: PluginModelCapability) => ModelOption[];
+ defaultModel: (capability: PluginModelCapability) => string;
+};
+
// 节点自带的工具栏按钮(追加到 hover 工具栏尾部)
export type CanvasNodeToolbarItem = {
id: string;
@@ -39,6 +58,11 @@ export type CanvasNodeContext = {
// 节点间/插件间通信
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
+ // AI 生成能力(生图/生视频/生文本),复用宿主模型配置
+ ai: CanvasPluginAi;
+ // 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
+ openPanel: () => void;
+ closePanel: () => void;
// 插件私有持久化,命名空间隔离
storage: PluginStorage;
};
@@ -59,6 +83,18 @@ export type CanvasPluginHost = {
updateNode: (nodeId: string, patch: Partial>) => void;
updateMetadata: (nodeId: string, patch: CanvasNodeMetadata) => void;
applyOps: (ops: CanvasAgentOp[]) => void;
+ // AI 生成能力,复用画布页面当前的模型/密钥配置
+ ai: CanvasPluginAi;
+ // 打开/关闭指定节点下方的自定义 Panel
+ openPanel: (nodeId: string) => void;
+ closePanel: () => void;
+};
+
+// 复用宿主内置生成面板的配置(见 SDK CanvasBuiltinPanelConfig)
+export type CanvasBuiltinPanelConfig = {
+ mode: "image" | "video" | "text" | "audio";
+ promptPrefix?: string;
+ writeBackToSelf?: boolean;
};
// 节点类型定义:内置节点与插件节点统一走这套结构
@@ -74,6 +110,11 @@ export type CanvasNodeDefinition = {
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
+ useBuiltinPanel?: CanvasBuiltinPanelConfig; // 复用宿主内置生成面板(与自定义 Panel 二选一)
+ // 为 true 时:宿主自动提供「交互 ⇄ 移动」工具条开关,并按 metadata.interactive 控制内容层指针事件
+ interactionToggle?: boolean;
+ // 配合 interactionToggle:返回 true 表示内容强制可交互(如编辑态),忽略 interactive 并隐藏开关
+ forceInteractive?: (node: CanvasNodeData) => boolean;
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index ba33a95..4256c36 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -61,6 +61,7 @@ export type CanvasNodeMetadata = {
bytes?: number;
durationMs?: number;
groupId?: string;
+ interactive?: boolean; // 插件节点「交互 ⇄ 移动」开关状态(见 CanvasNodeDefinition.interactionToggle)
};
export type CanvasNodeData = {