feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge

This commit is contained in:
yepwait
2026-08-26 15:02:24 +08:00
committed by ddCat
parent 6141bd688c
commit 86fc3fda2f
82 changed files with 11405 additions and 248 deletions

View File

@@ -88,6 +88,16 @@ export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
};
}
function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
return {
workflowProvider: undefined,
runningHubWorkflowId: undefined,
runningHubWorkflowKind: undefined,
comfyBridgeWorkflowId: undefined,
workflowParameters: undefined,
};
}
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
const mode = generationTaskMode(task, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image");
const prompt = node.metadata?.prompt || task.prompt;
@@ -125,7 +135,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
width: imageSize.width,
height: imageSize.height,
position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 },
metadata: { ...node.metadata, ...imageMetadata(normalizedImage), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...imageMetadata(normalizedImage), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
};
}
@@ -154,7 +164,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
...node,
type: CanvasNodeType.Video,
...geometry,
metadata: { ...node.metadata, ...videoMetadata(video), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...videoMetadata(video), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
};
}
@@ -163,7 +173,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
const audio = result.audio.storageKey
? { url: await resolveMediaUrl(result.audio.storageKey, result.audio.dataUrl), storageKey: result.audio.storageKey, durationMs: result.audio.durationMs, bytes: result.audio.bytes || 0, mimeType: result.audio.mimeType || "audio/mpeg" }
: await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), result.audio.format || "mp3");
return { ...node, type: CanvasNodeType.Audio, metadata: { ...node.metadata, ...audioMetadata(audio), prompt, ...completedTaskMetadata(task), errorDetails: undefined } };
return { ...node, type: CanvasNodeType.Audio, metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...audioMetadata(audio), prompt, ...completedTaskMetadata(task), errorDetails: undefined } };
}
if (!result.text) throw new Error("后端任务没有返回文本");

View File

@@ -1,16 +1,17 @@
import { type GenerationTask } from "@/services/api/task-center";
import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, normalizeRunningHubCapability, resolveModelRequestConfig, type AiConfig, type WorkflowFieldMapping } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { modelCapabilityConfigFor, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowOutputSizeValue, workflowVideoFieldsFromJson } from "@/lib/model-capabilities";
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import { isCanvasWorkflowProvider, resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas";
import type { ReferenceImage } from "@/types/image";
@@ -75,6 +76,8 @@ export async function runBackendCanvasGenerationTask(
}
export function canvasImageReferenceLimitError(config: AiConfig, referenceImages: ReferenceImage[]) {
// 工作流的参考素材上限由字段映射槽位决定,不能再套用当前普通图片模型的能力表。
if ((config.taskWorkflowProvider || "model") !== "model") return "";
const maxImages = modelCapabilityConfigFor(config, config.model).image?.references.maxImages;
if (maxImages === undefined || referenceImages.length <= maxImages) return "";
return `当前图片模型最多支持 ${maxImages} 张参考图,当前已连接 ${referenceImages.length} 张。请移除多余连线后重试`;
@@ -85,7 +88,9 @@ export function assertCanvasImageReferenceLimit(config: AiConfig, referenceImage
if (error) throw new Error(error);
}
export { backendProviderConfig, logicalModelIDForConfig };
export { backendProviderConfig };
// 兼容故事板执行器的旧导入路径。
export { logicalModelIDForConfig };
const generationOperationLocks = new Map<string, Promise<unknown>>();
@@ -214,6 +219,7 @@ export function audioExtension(mimeType?: string) {
export function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
return {
...generationWorkflowMetadata(config),
generationType: type,
model: config.model,
size: config.size,
@@ -225,18 +231,19 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
}
export function nodeReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
if (node.type !== CanvasNodeType.Image || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
return {
id: node.id,
name: `reference-${node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
dataUrl: node.metadata.content || "",
storageKey: node.metadata.storageKey,
};
}
export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
return {
...generationWorkflowMetadata(config),
model: config.model,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
@@ -312,12 +319,17 @@ export function buildVideoGenerationMetadata(
referenceVideos: ReferenceVideo[];
referenceAudios: ReferenceAudio[];
},
config?: AiConfig,
): CanvasNodeMetadata {
const metadata = node?.metadata;
const referenceImageIds = new Set((context?.referenceImages || []).map((image) => image.id));
const startFrame = requireConnectedVideoFrame(metadata?.videoStartFrameNodeId, "首帧", referenceImageIds);
const endFrame = requireConnectedVideoFrame(metadata?.videoEndFrameNodeId, "尾帧", referenceImageIds);
// 工作流视频把已连接媒体交给字段映射处理,不再把历史首尾帧选择当成硬约束。
// 这样旧节点切换到 RunningHub/ComfyUI 后,不会因为残留的首尾帧 ID 阻断生成。
const workflowVideo = node?.type === CanvasNodeType.Config && ((config?.taskWorkflowProvider === "runninghub" || config?.taskWorkflowProvider === "comfyui") || isCanvasWorkflowProvider(metadata));
const startFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoStartFrameNodeId, "首帧", referenceImageIds);
const endFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoEndFrameNodeId, "尾帧", referenceImageIds);
return {
...(config ? generationWorkflowMetadata(config) : {}),
videoEditOperation: resolveVideoEditOperation(node, context),
videoCameraMoveId: metadata?.videoCameraMoveId,
videoCameraMovePrompt: metadata?.videoCameraMovePrompt,
@@ -343,8 +355,8 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
nodes.map(async (node) => {
const content = node.metadata?.content;
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
if (node.type === CanvasNodeType.Image && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content, { cacheMiss: true }) } };
if (node.type !== CanvasNodeType.Image || !content) return node;
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content, { cacheMiss: true }) } };
if (!content.startsWith("data:image/")) return node;
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
}),
@@ -377,19 +389,52 @@ export function getGenerationCount(count: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
}
export function generationWorkflowMetadata(config: AiConfig): Pick<CanvasNodeMetadata, "workflowProvider" | "runningHubWorkflowId" | "runningHubWorkflowKind" | "comfyBridgeWorkflowId"> {
const provider = config.taskWorkflowProvider || "model";
return {
workflowProvider: provider,
runningHubWorkflowId: provider === "runninghub" ? config.runningHub.workflowId : undefined,
runningHubWorkflowKind: provider === "runninghub" ? config.runningHub.selectedKind : undefined,
comfyBridgeWorkflowId: provider === "comfyui" ? config.comfyBridge.workflowId : undefined,
};
}
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig {
// 只有独立 Config 节点读取工作流元数据;普通图片/视频/音频节点始终按基础模型生成。
const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config ? resolveCanvasWorkflowProvider(node.metadata) : "model";
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
// 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。
const workflowParameters = node?.metadata?.workflowParameters || {};
const runningHubWorkflowId = node?.metadata?.runningHubWorkflowId?.trim() || config.runningHub.workflowId.trim();
const selectedRunningHubWorkflow = workflowProvider === "runninghub"
? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind))
: undefined;
const comfyBridgeWorkflowId = node?.metadata?.comfyBridgeWorkflowId?.trim() || config.comfyBridge.workflowId.trim();
const selectedComfyBridgeWorkflow = workflowProvider === "comfyui"
? config.comfyBridge.workflows.find((item) => item.workflowId.trim() === comfyBridgeWorkflowId)
: undefined;
const selectedWorkflowFields = workflowProvider === "runninghub"
? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)
: workflowProvider === "comfyui"
? selectedComfyBridgeWorkflow?.fields?.length ? selectedComfyBridgeWorkflow.fields : workflowVideoFieldsFromJson(selectedComfyBridgeWorkflow?.workflowJson)
: [];
const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters);
const workflowParameterValue = (source: string) => {
const value = workflowParameters[`source:${source}`];
return value === undefined || value === null ? undefined : String(value);
};
const requestedConfig: AiConfig = {
...config,
quality: node?.metadata?.quality ?? config.quality ?? defaultConfig.quality,
size: node?.metadata?.size ?? config.size ?? defaultConfig.size,
quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality,
size: workflowProvider === "model"
? node?.metadata?.size ?? config.size ?? defaultConfig.size
: workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground,
videoSeconds: normalizeVideoDuration(node?.metadata?.seconds ?? config.videoSeconds ?? defaultConfig.videoSeconds),
vquality: normalizeVideoResolution(node?.metadata?.vquality ?? config.vquality ?? defaultConfig.vquality),
videoSeconds: workflowProvider === "model" ? normalizeVideoDuration(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds) : String(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds),
vquality: workflowProvider === "model" ? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality) : String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality),
videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark,
videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload,
@@ -402,42 +447,53 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
const imageSize = mode === "image" ? requestedConfig.size : undefined;
// 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型,
// 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。
const liveOptions = modelRequestOptions(requestedConfig, mode);
const baseRequirements = requirements?.capability
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } }
: { capability: mode, options: liveOptions };
const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel;
const generationDefaults = resolveModelGenerationDefaults(
config,
model,
mode === "image" ? "image" : mode === "video" ? "video" : undefined,
mode === "image"
? {
size: node?.metadata?.size,
quality: node?.metadata?.quality,
transparentBackground: node?.metadata?.transparentBackground,
const liveOptions = modelRequestOptions(requestedConfig, mode);
const baseRequirements = requirements?.capability
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } }
: { capability: mode, options: liveOptions };
const model = workflowProvider === "model"
? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel
: preferredModel;
const generationDefaults: Partial<ModelGenerationDefaults> = workflowProvider === "model"
? resolveModelGenerationDefaults(
config,
model,
mode === "image" ? "image" : mode === "video" ? "video" : undefined,
mode === "image"
? {
size: node?.metadata?.size,
quality: node?.metadata?.quality,
transparentBackground: node?.metadata?.transparentBackground,
count: requestedConfig.count,
}
: {
size: node?.metadata?.size,
videoSeconds: node?.metadata?.seconds,
vquality: node?.metadata?.vquality,
videoGenerateAudio: node?.metadata?.generateAudio,
videoWatermark: node?.metadata?.watermark,
},
{
size: requestedConfig.size,
quality: requestedConfig.quality,
transparentBackground: requestedConfig.transparentBackground,
count: requestedConfig.count,
}
: {
size: node?.metadata?.size,
videoSeconds: node?.metadata?.seconds,
vquality: node?.metadata?.vquality,
videoGenerateAudio: node?.metadata?.generateAudio,
videoWatermark: node?.metadata?.watermark,
videoSeconds: requestedConfig.videoSeconds,
vquality: requestedConfig.vquality,
videoGenerateAudio: requestedConfig.videoGenerateAudio,
videoWatermark: requestedConfig.videoWatermark,
},
{
size: requestedConfig.size,
quality: requestedConfig.quality,
transparentBackground: requestedConfig.transparentBackground,
count: requestedConfig.count,
videoSeconds: requestedConfig.videoSeconds,
vquality: requestedConfig.vquality,
videoGenerateAudio: requestedConfig.videoGenerateAudio,
videoWatermark: requestedConfig.videoWatermark,
},
);
)
: {};
const modeCapability = mode === "video" || mode === "audio" ? mode : "image";
const runningHubCapability = normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability));
const runningHub = { ...config.runningHub, enabled: workflowProvider === "runninghub" && config.runningHub.enabled, selectedKind: selectedRunningHubWorkflow?.kind === "app" ? "app" as const : "workflow" as const, workflowId: runningHubWorkflowId, capability: runningHubCapability, workflows: workflowProvider === "runninghub" ? config.runningHub.workflows.map((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind) ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.runningHub.workflows };
const comfyBridge = { ...config.comfyBridge, enabled: workflowProvider === "comfyui" && config.comfyBridge.enabled, workflowId: comfyBridgeWorkflowId, capability: selectedComfyBridgeWorkflow?.capability || modeCapability, workflows: workflowProvider === "comfyui" ? config.comfyBridge.workflows.map((item) => item.workflowId.trim() === comfyBridgeWorkflowId ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.comfyBridge.workflows };
return {
...requestedConfig,
taskWorkflowProvider: workflowProvider,
runningHub,
comfyBridge,
model,
quality: generationDefaults.quality || requestedConfig.quality,
size: generationDefaults.size || requestedConfig.size,
@@ -446,6 +502,7 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
vquality: generationDefaults.vquality || requestedConfig.vquality,
videoGenerateAudio: generationDefaults.videoGenerateAudio || requestedConfig.videoGenerateAudio,
videoWatermark: generationDefaults.videoWatermark || requestedConfig.videoWatermark,
videoArkPrivateAssetUpload: requestedConfig.videoArkPrivateAssetUpload,
count: generationDefaults.count || requestedConfig.count,
};
}
@@ -457,6 +514,28 @@ export function resolveCanvasGenerationModel(config: AiConfig, model: string | u
return configuredModelMatchesCapability(config, normalized, mode) ? normalized : "";
}
function applyWorkflowParameterValues(fields: WorkflowFieldMapping[] | undefined, values: Record<string, unknown>) {
if (!fields?.length || !Object.keys(values).length) return fields;
return fields.map((field) => {
const key = workflowFieldKey(field);
const randomKey = workflowFieldRandomKey(field);
const hasValue = workflowFieldHasStoredValue(field, values);
const hasRandom = Object.prototype.hasOwnProperty.call(values, randomKey);
if (!hasValue && !hasRandom) return field;
const value = workflowFieldSubmissionValue(field, workflowFieldCurrentValue(field, values));
// 画布动态参数按具体字段覆盖,不能继续走 aspectRatio/quality 等全局来源,
// 否则同一工作流中的两个同类参数会被折叠成同一个值。
return {
...field,
...(hasValue ? { fieldValue: value, value } : {}),
source: "",
sourceAutomatic: false,
sourceFromUpstream: false,
randomEnabled: hasRandom ? values[randomKey] === true : hasValue ? false : field.randomEnabled,
};
});
}
export function supportsVideoReferenceAudio(config: AiConfig) {
const interfaceType = resolveModelRequestConfig(config, config.model).interfaceType;
return interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || isSeedanceVideoConfig(config);

View File

@@ -0,0 +1,19 @@
import type { CanvasNodeMetadata } from "@/types/canvas";
export type CanvasWorkflowProvider = "model" | "runninghub" | "comfyui";
/**
* 统一解析画布节点的工作流渠道;旧节点可能没有 workflowProvider,
* 但已经保存了具体工作流 ID,此时不能退回普通模型的首尾帧逻辑。
*/
export function resolveCanvasWorkflowProvider(metadata?: CanvasNodeMetadata): CanvasWorkflowProvider {
if (metadata?.workflowProvider) return metadata.workflowProvider;
if (metadata?.runningHubWorkflowId?.trim()) return "runninghub";
if (metadata?.comfyBridgeWorkflowId?.trim()) return "comfyui";
return "model";
}
export function isCanvasWorkflowProvider(metadata?: CanvasNodeMetadata) {
const provider = resolveCanvasWorkflowProvider(metadata);
return provider === "runninghub" || provider === "comfyui";
}

View File

@@ -1,4 +1,4 @@
import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video } from "lucide-react";
import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video, Workflow } from "lucide-react";
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
@@ -39,6 +39,8 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
extensionCommand(CanvasNodeType.Compare, 50),
extensionCommand(CanvasNodeType.Chart, 60),
extensionCommand(CanvasNodeType.ColorGrade, 70),
// 云端和本地工作流共用独立配置节点,不进入基础模型节点的渠道选择。
{ id: "workflow", label: "工作流", icon: <Workflow />, section: "workflow", defaultOrder: 10, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddWorkflow() },
// 导入资源
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },

View File

@@ -38,6 +38,7 @@ export type ToolbarHandlers = {
onAddFrame: () => void;
onAddFolder: () => void;
onAddDrawing: () => void;
onAddWorkflow: () => void;
/**
* 扩展节点(Markdown / SVG / HTML / 全景 / 对比 / 图表 / 调色)统一走这一个入口。
* 不给每种扩展节点单开一个 onAddXxx —— 那会让 ToolbarHandlers 随节点数线性膨胀,
@@ -140,6 +141,7 @@ export type AddNodeMenuContext = {
| "onAddFrame"
| "onAddFolder"
| "onAddDrawing"
| "onAddWorkflow"
| "onAddExtensionNode"
| "onChooseStyle"
| "onOpenDirector"
@@ -181,7 +183,7 @@ export type AddNodeMenuCommand = {
icon: ReactNode;
badge?: string;
// extension:展示与加工类扩展节点。单独一区,避免挤散 node 区调好的四列网格。
section: "node" | "extension" | "project" | "resource";
section: "node" | "extension" | "workflow" | "project" | "resource";
defaultOrder: number;
applicable?: (ctx: AddNodeMenuContext) => boolean;
run: (ctx: AddNodeMenuContext) => void;