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

@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ChevronDown, ChevronUp, Clock3, Coins, ListTodo, LoaderCircle } from "lucide-react";
import { ChevronDown, ChevronUp, Clock3, Coins, ListTodo, LoaderCircle, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { formatCredits } from "@/constant/credits";
@@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
// 顶栏是绝对定位浮层,面板必须按调用方传入的 topInset 避让;专注模式隐藏顶栏时传小间距。
export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(--canvas-topbar-offset)" }: { tasks: GenerationTask[]; align?: "left" | "right"; topInset?: string }) {
export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(--canvas-topbar-offset)", onCancelTask }: { tasks: GenerationTask[]; align?: "left" | "right"; topInset?: string; onCancelTask?: (task: GenerationTask) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const reducedMotion = useReducedMotion();
@@ -96,6 +96,7 @@ export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(
theme={theme}
expanded={expandedTaskId === task.id}
onToggle={() => setExpandedTaskId((current) => (current === task.id ? null : task.id))}
onCancelTask={onCancelTask}
reducedMotion={Boolean(reducedMotion)}
creditsEnabled={creditsEnabled}
/>
@@ -116,6 +117,7 @@ function ActiveTaskCard({
theme,
expanded,
onToggle,
onCancelTask,
reducedMotion,
creditsEnabled,
}: {
@@ -124,6 +126,7 @@ function ActiveTaskCard({
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
expanded: boolean;
onToggle: () => void;
onCancelTask?: (task: GenerationTask) => void;
reducedMotion: boolean;
creditsEnabled: boolean;
}) {
@@ -221,6 +224,21 @@ function ActiveTaskCard({
{generationTaskStageLabel(task)}
</span>
</div>
{onCancelTask && (task.status === "queued" || task.status === "running") ? (
<button
type="button"
className="mt-3 inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors"
style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }}
onClick={(event) => {
event.stopPropagation();
onCancelTask(task);
}}
onMouseDown={(event) => event.stopPropagation()}
>
<XCircle className="size-3" />
取消任务
</button>
) : null}
</motion.div>
) : null}
</AnimatePresence>

View File

@@ -4,6 +4,7 @@ import { Button, Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Pencil, Sparkles, Video, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { NodeGenerationInput } from "./canvas-node-generation";
@@ -52,6 +53,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [presetOpen, setPresetOpen] = useState(false);
const simpleMode = workspaceMode === "simple";
const workflowVideoReferenceMode = generationMode === "video" && isCanvasWorkflowProvider(metadata);
const tokens = useMemo(() => parseComposerTokens(value), [value]);
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
const videoFrameOptions = useMemo(
@@ -168,7 +170,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold">{simpleMode ? "快速生成" : "组装提示词"}</div>
<div className="truncate text-[var(--fs-label)] opacity-55">{simpleMode ? "已连接素材会自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
<div className="truncate text-[var(--fs-label)] opacity-55">{simpleMode ? "已连接素材会自动带入" : workflowVideoReferenceMode ? "已连接媒体会按工作流字段顺序自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{simpleMode ? null : <CanvasPresetPicker mode={generationMode || "image"} skillReferences={skillReferences} open={presetOpen} onOpenChange={setPresetOpen} onSelect={insertPreset} />}
@@ -177,7 +179,17 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
</div>
{generationMode === "video" && onMetadataChange && !simpleMode ? (
<div className="mb-2 border-y px-1 py-1.5" style={{ borderColor: theme.node.stroke }}>
<CanvasVideoPromptTools metadata={metadata} frameOptions={videoFrameOptions} onMetadataChange={onMetadataChange} />
<CanvasVideoPromptTools
metadata={metadata}
frameOptions={videoFrameOptions}
referenceMode={workflowVideoReferenceMode ? "all" : "frames"}
referenceSummary={{
imageCount: inputs.filter((input) => input.type === "image" || input.type === "character").length,
videoCount: inputs.filter((input) => input.type === "video").length,
audioCount: inputs.filter((input) => input.type === "audio").length,
}}
onMetadataChange={onMetadataChange}
/>
</div>
) : null}
<div className="relative rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>

View File

@@ -1,14 +1,15 @@
import type { CSSProperties } from "react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react";
import { Button, InputNumber, Segmented, Select } from "antd";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, Dice5, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Sparkles, Video, Workflow as WorkflowIcon } from "lucide-react";
import { Button, Input, InputNumber, Segmented, Select, Slider, Switch, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, normalizeRunningHubCapability, resolveModelChannel, useEffectiveConfig, type AiConfig, type RunningHubCapability, type RunningHubWorkflow, type RunningHubWorkflowKind } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities";
import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -27,6 +28,15 @@ type CanvasConfigNodePanelProps = {
workspaceMode?: CanvasWorkspaceMode;
};
type WorkflowSelectOption = {
label: ReactNode;
value?: string;
workflowId?: string;
kind?: RunningHubWorkflowKind;
title?: string;
options?: WorkflowSelectOption[];
};
const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperation }> = [
{ label: "文生视频", value: "text_to_video" },
{ label: "图生视频", value: "image_to_video" },
@@ -40,12 +50,26 @@ const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperat
{ label: "版本对比", value: "compare_versions" },
];
function capabilityLabel(capability: RunningHubCapability) {
return capability === "video" ? "视频" : capability === "audio" ? "音频" : "图片";
}
function runningHubWorkflowKind(workflow: RunningHubWorkflow): RunningHubWorkflowKind {
return workflow.kind === "app" ? "app" : "workflow";
}
function runningHubWorkflowEntryKey(workflow: RunningHubWorkflow): string {
return `${runningHubWorkflowKind(workflow)}:${workflow.workflowId.trim()}`;
}
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) {
const globalConfig = useEffectiveConfig();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const mode = node.metadata?.generationMode || "image";
const simpleMode = workspaceMode === "simple";
const workflowProvider = mode === "text" ? "model" : resolveCanvasWorkflowProvider(node.metadata);
const workflowCapability: RunningHubCapability = mode === "video" ? "video" : mode === "audio" ? "audio" : "image";
const requirements: ModelRequirements = {
capability: mode,
input: inputSummary,
@@ -66,29 +90,96 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
}, mode),
};
const config = buildNodeConfig(globalConfig, node, mode, requirements);
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
const defaultWorkflowCapability = normalizeRunningHubCapability(globalConfig.runningHub.capability);
const selectedRunningHubWorkflow = globalConfig.runningHub.workflows.find((item) => (
item.workflowId.trim() === node.metadata?.runningHubWorkflowId?.trim()
&& (!node.metadata?.runningHubWorkflowKind || runningHubWorkflowKind(item) === node.metadata.runningHubWorkflowKind)
));
const selectedRunningHubCapability = selectedRunningHubWorkflow ? normalizeRunningHubCapability(selectedRunningHubWorkflow.capability, defaultWorkflowCapability) : undefined;
const selectedComfyBridgeWorkflow = globalConfig.comfyBridge.workflows.find((item) => item.workflowId.trim() === node.metadata?.comfyBridgeWorkflowId?.trim());
const selectedWorkflowFields: WorkflowVideoFieldLike[] = workflowProvider === "runninghub"
? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)
: workflowProvider === "comfyui"
? selectedComfyBridgeWorkflow?.fields?.length ? selectedComfyBridgeWorkflow.fields : workflowVideoFieldsFromJson(selectedComfyBridgeWorkflow?.workflowJson)
: [];
const dynamicWorkflowFields = workflowParameterFields(selectedWorkflowFields);
useEffect(() => {
if (mode === "text") return;
if (workflowProvider === "runninghub" && !node.metadata?.runningHubWorkflowId?.trim()) {
const capability = normalizeRunningHubCapability(globalConfig.runningHub.capability);
// 模式切换后优先按当前能力选择工作流,不能沿用全局默认的视频工作流,
// 否则点击“生图”会在下一次渲染时被自动改回“视频”。
const workflow = globalConfig.runningHub.workflows.find((item) => (
normalizeRunningHubCapability(item.capability, capability) === workflowCapability
&& item.workflowId.trim() === globalConfig.runningHub.workflowId.trim()
&& runningHubWorkflowKind(item) === globalConfig.runningHub.selectedKind
))
|| globalConfig.runningHub.workflows.find((item) => normalizeRunningHubCapability(item.capability, capability) === workflowCapability);
if (workflow) {
const workflowMode = normalizeRunningHubCapability(workflow.capability, capability);
onConfigChange(node.id, { generationMode: workflowMode, runningHubWorkflowId: workflow.workflowId, runningHubWorkflowKind: runningHubWorkflowKind(workflow), workflowParameters: {} });
}
return;
}
if (workflowProvider === "comfyui" && !node.metadata?.comfyBridgeWorkflowId?.trim()) {
const workflow = globalConfig.comfyBridge.workflows.find((item) => item.capability === workflowCapability);
if (workflow) onConfigChange(node.id, { generationMode: workflow.capability, comfyBridgeWorkflowId: workflow.workflowId, workflowParameters: {} });
}
}, [globalConfig.comfyBridge.workflows, globalConfig.comfyBridge.workflowId, globalConfig.runningHub.capability, globalConfig.runningHub.selectedKind, globalConfig.runningHub.workflowId, globalConfig.runningHub.workflows, mode, node.id, node.metadata?.comfyBridgeWorkflowId, node.metadata?.runningHubWorkflowId, onConfigChange, workflowCapability, workflowProvider]);
const runningHubEntries = globalConfig.runningHub.workflows
// 正常情况下只显示当前模式的条目;旧画布已引用的错配条目保留在列表中,
// 这样用户可以看到并重新选择,而不是出现“选项消失”的死路。
.filter((item) => normalizeRunningHubCapability(item.capability, defaultWorkflowCapability) === workflowCapability || (
item.workflowId.trim() === node.metadata?.runningHubWorkflowId?.trim()
&& (!node.metadata?.runningHubWorkflowKind || runningHubWorkflowKind(item) === node.metadata.runningHubWorkflowKind)
))
.map((item) => ({
label: item.title || item.workflowId,
value: runningHubWorkflowEntryKey(item),
workflowId: item.workflowId,
kind: runningHubWorkflowKind(item),
title: `${item.title || item.workflowId} · ${capabilityLabel(normalizeRunningHubCapability(item.capability, defaultWorkflowCapability))}`,
}));
const runningHubApps = runningHubEntries.filter((item) => item.kind === "app");
const runningHubWorkflows = runningHubEntries.filter((item) => item.kind === "workflow");
const runningHubOptions: WorkflowSelectOption[] = [
...(runningHubApps.length ? [{ label: <WorkflowOptionGroupLabel label="AI 应用" count={runningHubApps.length} />, options: runningHubApps }] : []),
...(runningHubWorkflows.length ? [{ label: <WorkflowOptionGroupLabel label="工作流" count={runningHubWorkflows.length} />, options: runningHubWorkflows }] : []),
];
const comfyBridgeEntries = globalConfig.comfyBridge.workflows
.filter((item) => item.capability === workflowCapability || item.workflowId.trim() === node.metadata?.comfyBridgeWorkflowId?.trim())
.map((item) => ({ label: item.title || item.workflowId, value: item.workflowId, kind: "workflow" as const, title: `${item.title || item.workflowId} · ${capabilityLabel(item.capability)}` }));
const comfyBridgeOptions: WorkflowSelectOption[] = comfyBridgeEntries.length ? [{ label: <WorkflowOptionGroupLabel label="工作流" count={comfyBridgeEntries.length} />, options: comfyBridgeEntries }] : [];
const videoProfile = mode === "video" ? (workflowProvider === "model" ? modelCapabilityConfigFor(config, config.model).video! : undefined) : undefined;
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const textCountValue = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))));
const priceChannel = resolveModelChannel(config, config.model);
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
const hasPrice = creditsEnabled && credits !== null;
const hasPrice = workflowProvider === "model" && creditsEnabled && credits !== null;
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount || inputSummary.characterCount);
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
const capabilityError = modelCompatibilityError(config, config.model, requirements);
const workflowParameterError = workflowProvider === "model" ? "" : firstWorkflowParameterError(dynamicWorkflowFields, node.metadata?.workflowParameters || {});
const capabilityError = workflowParameterError || (workflowProvider === "runninghub"
? (!globalConfig.runningHub.enabled ? "请先在设置中启用 RunningHub" : !node.metadata?.runningHubWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流或 App` : !selectedRunningHubWorkflow ? "当前画布引用的 RunningHub 条目已不存在,请重新选择" : selectedRunningHubCapability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedRunningHubCapability || "image")},请切换画布模式或重新选择条目` : undefined)
: workflowProvider === "comfyui"
? (!globalConfig.comfyBridge.enabled ? "请先在设置中启用 ComfyUI Bridge" : !globalConfig.comfyBridge.bridgeId ? "请选择在线 Bridge" : !node.metadata?.comfyBridgeWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流` : !selectedComfyBridgeWorkflow ? "当前画布引用的 ComfyUI 条目已不存在,请重新选择" : selectedComfyBridgeWorkflow.capability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedComfyBridgeWorkflow.capability)},请切换画布模式或重新选择条目` : undefined)
: modelCompatibilityError(config, config.model, requirements));
const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError;
return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold">{simpleMode ? "快速生成" : "生成配置"}</div>
{simpleMode ? <span className="rounded-md px-2 py-1 text-[var(--fs-tiny)]" style={{ background: theme.node.fill, color: theme.node.muted }}>自动配置</span> : <div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
<div className={workflowProvider === "model" ? "flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" : "thin-scrollbar flex h-full w-full cursor-move flex-col gap-3 overflow-y-auto px-3 pb-3 pt-7 text-sm"} style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className={workflowProvider === "model" ? "mb-2 flex items-center justify-between gap-3" : "flex min-h-8 items-center justify-between gap-3"}>
<div className="shrink-0 text-sm font-semibold">{simpleMode ? "快速生成" : workflowProvider === "model" ? "生成配置" : "工作流生成"}</div>
{simpleMode ? <span className="rounded-md px-2 py-1 text-[var(--fs-tiny)]" style={{ background: theme.node.fill, color: theme.node.muted }}>自动配置</span> : <div className="cursor-default" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Segmented
size="small"
className="canvas-config-mode !rounded-md !p-0.5"
value={mode}
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
onChange={(value) => onConfigChange(node.id, workflowProvider === "model"
? { generationMode: value as CanvasGenerationMode }
: { generationMode: value as CanvasGenerationMode, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} })}
options={[
{
value: "image",
@@ -131,7 +222,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</div>}
</div>
<div className="mb-2 flex flex-wrap gap-1.5">
<div className={workflowProvider === "model" ? "mb-2 flex flex-wrap gap-1.5" : "flex min-w-0 flex-wrap items-center gap-2"}>
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
@@ -144,46 +235,129 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</div>
{mode === "video" && !simpleMode ? (
<div className="mb-2 cursor-default" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Select
size="small"
className="canvas-compact-control canvas-control-select !h-9 !w-full"
value={node.metadata?.videoEditOperation || defaultVideoOperation(inputSummary)}
options={operationOptions}
placement="bottomLeft"
popupMatchSelectWidth={false}
styles={{ popup: { root: { minWidth: 180, maxWidth: 260 } } }}
popupRender={(menu) => (
<div data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
onChange={(value) => onConfigChange(node.id, { videoEditOperation: value })}
/>
<div className={workflowProvider === "model" ? "mb-2 cursor-default" : "cursor-default pt-1"} data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{workflowProvider === "model" ? (
<Select
size="small"
className="canvas-compact-control canvas-control-select !h-9 !w-full"
value={node.metadata?.videoEditOperation || defaultVideoOperation(inputSummary)}
options={operationOptions}
placement="bottomLeft"
popupMatchSelectWidth={false}
styles={{ popup: { root: { minWidth: 180, maxWidth: 260 } } }}
popupRender={(menu) => (
<div data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
onChange={(value) => onConfigChange(node.id, { videoEditOperation: value })}
/>
) : (
<div className="flex h-9 min-w-0 items-center gap-2 rounded-lg border px-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }} title="已连接的图片、视频和音频会按当前工作流字段映射自动传入">
<ImageIcon className="size-3.5 shrink-0 opacity-75" />
<span className="shrink-0 font-medium">全能参考</span>
<span className="min-w-0 truncate opacity-60">已连接媒体自动映射</span>
</div>
)}
</div>
) : null}
{simpleMode ? (
<div className="mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
<div className={workflowProvider === "model" ? "mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" : "rounded-lg px-2 py-2 text-[var(--fs-label)]"} style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
) : (
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" || mode === "text" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
{mode === "text" ? (
<div className="flex h-10 min-w-0 cursor-default items-center justify-between gap-2 rounded-lg border px-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className={workflowProvider === "model" ? "mb-2" : "flex min-w-0 flex-col gap-3"}>
{mode !== "text" ? <div data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Segmented
block
size="small"
className="canvas-config-provider"
value={workflowProvider}
options={[{ label: "基础模型", value: "model" }, { label: "RunningHub", value: "runninghub" }, { label: "ComfyUI", value: "comfyui" }]}
onChange={(value) => {
const nextProvider = value as "model" | "runninghub" | "comfyui";
if (nextProvider === "model") {
onConfigChange(node.id, { workflowProvider: "model", workflowTitle: undefined, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} });
} else if (nextProvider === "runninghub") {
onConfigChange(node.id, { workflowProvider: "runninghub", workflowTitle: "RunningHub 工作流", comfyBridgeWorkflowId: undefined, workflowParameters: {} });
} else {
onConfigChange(node.id, { workflowProvider: "comfyui", workflowTitle: "ComfyUI Bridge", runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, workflowParameters: {} });
}
}}
/>
</div> : null}
<div data-canvas-no-zoom className={workflowProvider === "model" ? "grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_148px] items-center gap-2" : "grid min-w-0 cursor-default items-center gap-3"} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{workflowProvider === "runninghub" ? (
<Select<string, WorkflowSelectOption>
className="canvas-compact-control !h-9 w-full"
value={selectedRunningHubWorkflow ? runningHubWorkflowEntryKey(selectedRunningHubWorkflow) : undefined}
title={selectedRunningHubWorkflow ? `${selectedRunningHubWorkflow.kind === "app" ? "App" : "工作流"} · ${selectedRunningHubWorkflow.title || selectedRunningHubWorkflow.workflowId}` : undefined}
placeholder={`选择${capabilityLabel(workflowCapability)}工作流或 App`}
showSearch
options={runningHubOptions}
optionFilterProp="label"
optionLabelProp="label"
labelRender={(selected) => {
const workflow = runningHubEntries.find((item) => item.value === selected.value);
return <WorkflowSelectedLabel kind={workflow?.kind || "workflow"} label={workflow?.label || String(selected.label || "")} />;
}}
notFoundContent={`暂无${capabilityLabel(workflowCapability)}工作流`}
popupMatchSelectWidth={false}
virtual={false}
listHeight={320}
styles={{ popup: { root: { minWidth: 320, maxWidth: "min(420px, calc(100vw - 32px))" } } }}
optionRender={(option) => {
if (option.data.options) return option.label;
return <WorkflowOptionLabel kind={option.data.kind === "app" ? "app" : "workflow"} label={String(option.data.label || "")} title={String(option.data.title || option.data.label || "")} />;
}}
onChange={(value) => {
const workflow = runningHubEntries.find((item) => item.value === value);
onConfigChange(node.id, { runningHubWorkflowId: workflow?.workflowId, runningHubWorkflowKind: workflow?.kind, workflowParameters: {} });
}}
/>
) : workflowProvider === "comfyui" ? (
<Select<string, WorkflowSelectOption>
className="canvas-compact-control !h-9 w-full"
value={node.metadata?.comfyBridgeWorkflowId}
title={selectedComfyBridgeWorkflow?.title || selectedComfyBridgeWorkflow?.workflowId}
placeholder={`选择${capabilityLabel(workflowCapability)}工作流`}
showSearch
options={comfyBridgeOptions}
optionFilterProp="label"
optionLabelProp="label"
labelRender={(selected) => <WorkflowSelectedLabel kind="workflow" label={comfyBridgeEntries.find((item) => item.value === selected.value)?.label || String(selected.label || "")} />}
notFoundContent={`暂无${capabilityLabel(workflowCapability)}工作流`}
popupMatchSelectWidth={false}
virtual={false}
listHeight={320}
styles={{ popup: { root: { minWidth: 320, maxWidth: "min(420px, calc(100vw - 32px))" } } }}
optionRender={(option) => {
if (option.data.options) return option.label;
return <WorkflowOptionLabel kind="workflow" label={String(option.data.label || "")} title={String(option.data.title || option.data.label || "")} />;
}}
onChange={(value) => onConfigChange(node.id, { comfyBridgeWorkflowId: value, workflowParameters: {} })}
/>
) : (
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
)}
{workflowProvider === "model" && mode === "text" ? (
<div className="flex h-10 min-w-0 cursor-default items-center justify-between gap-2 rounded-lg border px-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
<span className="inline-flex items-center gap-1 text-[var(--fs-tiny)] font-semibold" style={{ color: theme.node.muted }}><MessageSquare className="size-3.5" />文本份数</span>
<InputNumber size="small" min={1} max={15} value={textCountValue} onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} aria-label="文本生成份数" />
</div>
) : mode === "video" ? (
) : workflowProvider === "model" && mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
) : mode === "image" ? (
) : workflowProvider === "model" && mode === "image" ? (
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : mode === "audio" ? (
) : workflowProvider === "model" && mode === "audio" ? (
<CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : null}
</div>
</div>
)}
{capabilityError ? <div className="mb-2 rounded-md px-2 py-1.5 text-[var(--fs-tiny)]" style={{ background: theme.accent.danger + "18", color: theme.accent.danger }}>{capabilityError}</div> : null}
{workflowProvider !== "model" && dynamicWorkflowFields.length ? <WorkflowParameterControls fields={dynamicWorkflowFields} node={node} theme={theme} onConfigChange={onConfigChange} /> : null}
{capabilityError ? <div className={workflowProvider === "model" ? "mb-2 rounded-md px-2 py-1.5 text-[var(--fs-tiny)]" : "rounded-md px-2 py-1.5 text-[var(--fs-tiny)]"} style={{ background: theme.accent.danger + "18", color: theme.accent.danger }}>{capabilityError}</div> : null}
<Button
type="primary"
@@ -193,14 +367,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
onClick={() => onGenerate(node.id)}
>
<span className="inline-flex items-center gap-1.5">
{isRunning ? (
<>
<LoaderCircle className="size-4 animate-spin" />
<span>生成中</span>
{isRunning ? (
<>
<LoaderCircle className="size-4 animate-spin" />
<span>生成中</span>
</>
) : (
<>
{!creditsEnabled ? (
{!creditsEnabled || workflowProvider !== "model" ? (
<span>生成</span>
) : hasPrice ? (
<span className="inline-flex items-center gap-1">
@@ -230,6 +404,109 @@ function defaultVideoOperation(inputSummary: CanvasConfigNodePanelProps["inputSu
return "text_to_video";
}
export function WorkflowParameterControls({ fields, node, theme, onConfigChange, defaultExpanded = true }: { fields: WorkflowVideoFieldLike[]; node: CanvasNodeData; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void; defaultExpanded?: boolean }) {
const [expanded, setExpanded] = useState(defaultExpanded);
const values = node.metadata?.workflowParameters || {};
const update = (field: WorkflowVideoFieldLike, value: unknown) => {
const key = workflowFieldKey(field);
onConfigChange(node.id, { workflowParameters: { ...values, [key]: value } });
};
const updateRandom = (field: WorkflowVideoFieldLike, randomEnabled: boolean) => {
const key = workflowFieldRandomKey(field);
onConfigChange(node.id, { workflowParameters: { ...values, [key]: randomEnabled } });
};
return (
<div className="flex min-w-0 flex-col gap-2 rounded-lg px-2 py-2" style={{ background: theme.node.fill }} data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="flex h-7 min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
<span className="font-semibold">工作流参数</span>
<span>{fields.length} 项</span>
</div>
<Tooltip title={expanded ? "折叠工作流参数" : "展开工作流参数"}>
<Button
type="text"
size="small"
className="!size-7 !shrink-0 !p-0"
icon={<ChevronDown className={`size-3.5 transition-transform ${expanded ? "" : "-rotate-90"}`} />}
aria-expanded={expanded}
aria-label={expanded ? "折叠工作流参数" : "展开工作流参数"}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => setExpanded((current) => !current)}
/>
</Tooltip>
</div>
{expanded ? fields.map((field) => {
const key = workflowFieldKey(field);
const randomKey = workflowFieldRandomKey(field);
const value = workflowFieldSubmissionValue(field, workflowFieldCurrentValue(field, values) ?? "");
const randomEnabled = typeof values[randomKey] === "boolean" ? values[randomKey] === true : field.randomEnabled === true;
const rawFieldType = String(field.fieldType || "").toUpperCase();
const fieldType = ["FLOAT", "INT", "INTEGER"].includes(rawFieldType) ? "NUMBER" : rawFieldType;
const options = workflowFieldChoiceValues(field);
const selectOptions = options.length ? options : fieldType === "SELECT" && value !== "" ? [value] : [];
const bounds = workflowFieldNumberBounds(field);
const numeric = fieldType === "NUMBER" || fieldType === "SLIDER" || typeof value === "number" || bounds.min !== undefined || bounds.max !== undefined || bounds.step !== undefined;
const numericValue = value === "" || !Number.isFinite(Number(value)) ? undefined : Number(value);
const valueError = randomEnabled ? "" : workflowFieldValueError(field, value);
const control = fieldType === "SELECT" || selectOptions.length ? (
<Select status={valueError ? "error" : undefined} size="small" className="w-full" value={value === "" ? undefined : value as string | number} options={selectOptions.map((option) => ({ label: workflowParameterOptionLabel(option), value: workflowParameterOptionValue(option) }))} onChange={(next) => update(field, next)} />
) : fieldType === "BOOLEAN" || typeof value === "boolean" ? (
<Switch size="small" checked={value === true || value === "true"} onChange={(checked) => update(field, checked)} />
) : fieldType === "SLIDER" && bounds.min !== undefined && bounds.max !== undefined ? (
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_64px] items-center gap-2">
<Slider disabled={randomEnabled} className="m-0" min={bounds.min} max={bounds.max} step={bounds.step || 0.01} value={numericValue ?? bounds.min} tooltip={{ open: false }} onChange={(next) => update(field, next)} />
<InputNumber disabled={randomEnabled} status={valueError ? "error" : undefined} size="small" className="w-full" value={numericValue} min={bounds.min} max={bounds.max} step={bounds.step} onChange={(next) => update(field, next)} />
</div>
) : numeric ? (
<InputNumber disabled={randomEnabled} status={valueError ? "error" : undefined} size="small" className="w-full" value={numericValue} min={bounds.min} max={bounds.max} step={bounds.step} onChange={(next) => update(field, next)} />
) : (
<Input status={valueError ? "error" : undefined} size="small" value={String(value ?? "")} onChange={(event) => update(field, event.target.value)} />
);
return (
<label key={key} className="grid min-w-0 grid-cols-[minmax(72px,0.8fr)_minmax(0,1.2fr)] items-center gap-2 text-[var(--fs-label)]" title={valueError || `${field.nodeId}.${field.fieldName}${bounds.min !== undefined || bounds.max !== undefined ? ` · ${bounds.min ?? ""}-${bounds.max ?? ""}${bounds.step !== undefined ? ` / step ${bounds.step}` : ""}` : ""}`}>
<span className="min-w-0 truncate" style={{ color: theme.node.muted }}>{field.label || field.fieldName}</span>
<div className={field.randomEnabled ? "grid min-w-0 grid-cols-[minmax(0,1fr)_28px] items-center gap-1" : "min-w-0"}>
{control}
{field.randomEnabled ? (
<Tooltip title={randomEnabled ? "每次生成使用随机值,点击改为固定值" : "当前使用固定值,点击恢复随机"}>
<Button type="text" size="small" className="!size-7 !p-0" style={{ background: randomEnabled ? theme.accent.primarySoft : "transparent", color: randomEnabled ? theme.accent.primary : theme.node.muted }} icon={<Dice5 className="size-3.5" />} aria-pressed={randomEnabled} onClick={(event) => { event.preventDefault(); updateRandom(field, !randomEnabled); }} />
</Tooltip>
) : null}
</div>
</label>
);
}) : null}
</div>
);
}
export function firstWorkflowParameterError(fields: WorkflowVideoFieldLike[], values: Record<string, unknown>) {
for (const field of fields) {
const randomKey = workflowFieldRandomKey(field);
const randomEnabled = typeof values[randomKey] === "boolean" ? values[randomKey] === true : field.randomEnabled === true;
if (randomEnabled) continue;
const key = workflowFieldKey(field);
const value = workflowFieldCurrentValue(field, values) ?? "";
const error = workflowFieldValueError(field, value);
if (error) return `${field.label || field.fieldName}:${error}`;
}
return "";
}
function workflowParameterOptionValue(value: unknown): string | number {
if (value && typeof value === "object" && !Array.isArray(value)) {
const item = value as Record<string, unknown>;
for (const key of ["value", "id", "key", "name", "label"]) {
if (item[key] !== undefined && item[key] !== null) return typeof item[key] === "number" ? item[key] : String(item[key]);
}
}
return typeof value === "number" ? value : String(value ?? "");
}
function workflowParameterOptionLabel(value: unknown) {
return String(workflowParameterOptionValue(value));
}
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
return (
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[var(--fs-label)]" style={style}>
@@ -239,7 +516,86 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
);
}
function WorkflowOptionGroupLabel({ label, count }: { label: string; count: number }) {
return <span className="canvas-workflow-group-label"><span>{label}</span><b>{count}</b></span>;
}
function WorkflowOptionLabel({ kind, label, title }: { kind: "app" | "workflow"; label: string; title: string }) {
return <div className="canvas-workflow-option" title={title}>
{kind === "app" ? <Sparkles /> : <WorkflowIcon />}
<span>{label}</span>
<i aria-hidden="true" />
</div>;
}
function WorkflowSelectedLabel({ kind, label }: { kind: "app" | "workflow"; label: string }) {
return <span className="canvas-workflow-selected-label">
{kind === "app" ? <Sparkles /> : <WorkflowIcon />}
<span>{label}</span>
</span>;
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig {
const workflowProvider = mode === "text" ? "model" : resolveCanvasWorkflowProvider(node.metadata);
if (workflowProvider === "model") return buildModelNodeConfig(globalConfig, node, mode, requirements);
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node.metadata?.model;
const preferredModel = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
const model = preferredModel;
const selectedRunningHubWorkflowId = node.metadata?.runningHubWorkflowId?.trim();
const selectedComfyBridgeWorkflowId = node.metadata?.comfyBridgeWorkflowId?.trim();
const modeCapability = mode === "video" || mode === "audio" ? mode : "image";
const selectedRunningHubWorkflow = selectedRunningHubWorkflowId
? globalConfig.runningHub.workflows.find((item) => item.workflowId.trim() === selectedRunningHubWorkflowId && (!node.metadata?.runningHubWorkflowKind || runningHubWorkflowKind(item) === node.metadata.runningHubWorkflowKind))
: undefined;
const selectedComfyBridgeWorkflow = selectedComfyBridgeWorkflowId
? globalConfig.comfyBridge.workflows.find((item) => item.workflowId.trim() === selectedComfyBridgeWorkflowId)
: undefined;
const selectedWorkflowFields = workflowProvider === "runninghub"
? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)
: workflowProvider === "comfyui"
? selectedComfyBridgeWorkflow?.fields?.length ? selectedComfyBridgeWorkflow.fields : workflowVideoFieldsFromJson(selectedComfyBridgeWorkflow?.workflowJson)
: [];
const capabilityProfile = {
...defaultModelCapabilityConfig(),
image: workflowImageCapabilityConfig(selectedWorkflowFields, defaultModelCapabilityConfig().image!),
video: workflowVideoCapabilityConfig(selectedWorkflowFields, defaultModelCapabilityConfig().video!),
};
const imageProfile = mode === "image" ? capabilityProfile.image! : undefined;
const workflowParameters = node.metadata?.workflowParameters || {};
const workflowOutputSize = workflowOutputSizeValue(selectedWorkflowFields, workflowParameters);
const workflowParameter = (source: string) => workflowParameters[`source:${source}`] === undefined ? "" : String(workflowParameters[`source:${source}`]);
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || workflowParameter("quality") || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined;
const videoProfile = mode === "video" ? capabilityProfile.video! : undefined;
const rawVideoSettings = { seconds: node.metadata?.seconds || workflowParameter("videoSeconds") || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || workflowParameter("vquality") || globalConfig.vquality || defaultConfig.vquality };
const normalizedVideo = videoProfile
? { seconds: String(rawVideoSettings.seconds), ratio: rawVideoSettings.ratio, resolution: rawVideoSettings.resolution }
: undefined;
const runningHub = { ...globalConfig.runningHub, enabled: mode !== "text" && workflowProvider === "runninghub" && globalConfig.runningHub.enabled, selectedKind: selectedRunningHubWorkflow ? runningHubWorkflowKind(selectedRunningHubWorkflow) : globalConfig.runningHub.selectedKind, workflowId: selectedRunningHubWorkflowId || "", capability: normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(globalConfig.runningHub.capability)) };
const comfyBridge = { ...globalConfig.comfyBridge, enabled: mode !== "text" && workflowProvider === "comfyui" && globalConfig.comfyBridge.enabled, workflowId: selectedComfyBridgeWorkflowId || "", capability: selectedComfyBridgeWorkflow?.capability || modeCapability };
return {
...globalConfig,
taskWorkflowProvider: workflowProvider,
runningHub,
comfyBridge,
model,
quality: workflowParameter("quality") || normalizedImage?.quality || node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: normalizedImage?.size || normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
transparentBackground: normalizedImage?.transparentBackground || ((node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false"),
videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: String(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false",
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
count: normalizedImage?.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
};
}
function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node.metadata?.model;
@@ -277,6 +633,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined;
return {
...globalConfig,
taskWorkflowProvider: "model",
model,
quality: generationDefaults.quality || globalConfig.quality || defaultConfig.quality,
size: generationDefaults.size || globalConfig.size || defaultConfig.size,

View File

@@ -274,6 +274,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
onAddFolder,
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
onAddExtensionNode: onAddNode,
onAddWorkflow: () => onAddNode(CanvasNodeType.Config),
onChooseStyle,
onOpenDirector,
onUpload,

View File

@@ -11,7 +11,7 @@ export type CanvasCreateCommand = {
label: string;
icon: ReactNode;
badge?: string;
section: "node" | "extension" | "project" | "resource";
section: "node" | "extension" | "workflow" | "project" | "resource";
onClick: () => void;
};
@@ -20,6 +20,7 @@ export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[]
const projectCommands = commands.filter((command) => command.section === "project");
const nodeCommands = commands.filter((command) => command.section === "node");
const extensionCommands = commands.filter((command) => command.section === "extension");
const workflowCommands = commands.filter((command) => command.section === "workflow");
const resourceCommands = commands.filter((command) => command.section === "resource");
return (
@@ -51,19 +52,25 @@ export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[]
<CanvasCreateCommandGrid commands={extensionCommands} variant="node" />
</>
) : null}
{workflowCommands.length ? (
<>
<MenuSection title="工作流" color={theme.node.muted} spaced />
<CanvasCreateCommandGrid commands={workflowCommands} variant="workflow" />
</>
) : null}
<MenuSection title="导入资源" color={theme.node.muted} spaced />
<CanvasCreateCommandGrid commands={resourceCommands} variant="resource" />
<CanvasCreateCommandGrid commands={resourceCommands} variant="compact" />
</div>
);
}
function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreateCommand[]; variant: "node" | "resource" }) {
function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreateCommand[]; variant: "node" | "compact" | "workflow" }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
return (
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-4" : "grid-cols-2")}>
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-4" : variant === "workflow" ? "grid-cols-1" : "grid-cols-2")}>
{commands.map((command) => (
<motion.button
key={command.id}
@@ -75,7 +82,9 @@ function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreate
"group min-w-0 overflow-hidden border border-black/10 bg-white/70 outline-none transition-colors hover:border-black/20 hover:bg-black/5 focus-visible:ring-2 dark:border-white/10 dark:bg-white/[.04] dark:hover:border-white/20 dark:hover:bg-white/8",
variant === "node"
? "flex h-[var(--canvas-create-node-height)] flex-col items-start justify-between rounded-[var(--dock-item-radius)] px-1 py-1.5 text-left"
: "flex h-[var(--canvas-create-resource-height)] items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-center",
: variant === "workflow"
? "flex h-[var(--canvas-create-resource-height)] items-center justify-start gap-2 rounded-[var(--dock-item-radius)] px-2 text-left"
: "flex h-[var(--canvas-create-resource-height)] items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-center",
)}
style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties}
title={command.label}

View File

@@ -10,6 +10,7 @@ import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import type { ModelReferenceLimits } from "@/lib/model-selection";
import type { Asset } from "@/stores/use-asset-store";
@@ -71,8 +72,18 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
const inputs = [...connectedInputs, ...portraitTextureInput, ...buildAssetGenerationInputs(assets)];
const storyboardInputs = getConnectedStoryboardRows(nodeId, nodes, connections);
const hasExplicitResourceMention = /@\[(?:node|asset):[^\]]+\]/.test(normalizeLegacyNodeMentions(prompt, inputs));
if ((sourceNode?.type === CanvasNodeType.Config && Boolean(sourceNode.metadata?.composerContent?.trim())) || hasExplicitResourceMention) {
return buildComposerGenerationContext(inputs, prompt, [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)), promptOnly);
const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata);
if ((Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
const autoIncludeWorkflowMedia = isWorkflowSource;
return buildComposerGenerationContext(
inputs,
prompt,
// 工作流节点由字段映射接收全部连线媒体;视频节点的历史首尾帧字段不能再额外追加参考图。
autoIncludeWorkflowMedia ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
promptOnly,
autoIncludeWorkflowMedia,
connectedInputs,
);
}
const isStoryboardMedia = sourceNode?.type === CanvasNodeType.Image || sourceNode?.type === CanvasNodeType.Video;
@@ -120,7 +131,14 @@ function removeTrailingInputBlocks(prompt: string, inputs: NodeGenerationInput[]
return next;
}
function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: string, videoFrameNodeIds: string[] = [], promptOnly = false): NodeGenerationContext {
function buildComposerGenerationContext(
inputs: NodeGenerationInput[],
prompt: string,
videoFrameNodeIds: string[] = [],
promptOnly = false,
autoIncludeWorkflowMedia = false,
workflowMediaInputs: NodeGenerationInput[] = [],
): NodeGenerationContext {
const normalizedPrompt = normalizeLegacyNodeMentions(prompt, inputs);
const inputByToken = new Map(inputs.map((input) => [generationInputToken(input), input]));
const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input]));
@@ -132,6 +150,16 @@ function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: s
let lastIndex = 0;
let nextPrompt = "";
if (autoIncludeWorkflowMedia) {
// 先固定“图片1/视频1”等提示词标签的顺序,和工作流槽位保持一致;
// 用户先 @ 第二张图时,提示词也不会把它误标成第一张。
workflowMediaInputs.forEach((input) => {
if (input.type === "text" || labelByNodeId.has(input.nodeId)) return;
const labelKind = input.sourceKind === "drawing" ? "drawing" : input.type;
labelByNodeId.set(input.nodeId, generationLabel(labelKind, counts[labelKind]++));
});
}
for (const match of normalizedPrompt.matchAll(/@\[(node|asset):([^\]]+)\]/g)) {
if (match.index === undefined) continue;
hasToken = true;
@@ -153,6 +181,24 @@ function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: s
nextPrompt += normalizedPrompt.slice(lastIndex);
if (textBlocks.length && !promptOnly) nextPrompt = `${nextPrompt.trim()}\n\n${textBlocks.join("\n\n")}`;
if (autoIncludeWorkflowMedia) {
// RunningHub/ComfyUI 工作流按保存的字段槽位接收图片、视频和音频;
// 配置节点不能因为提示词里没有逐个 @ 就丢失已连接媒体。
// 先按连线顺序放入媒体,避免用户在提示词里 @图片2 后改变工作流槽位的索引;
// 素材库中的显式 @ 引用仍保留在后面,不会被自动模式吞掉。
const explicitInputs = selectedInputs.splice(0);
const selectedNodeIds = new Set<string>();
workflowMediaInputs.forEach((input) => {
if (input.type === "text" || selectedNodeIds.has(input.nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(input.nodeId);
});
explicitInputs.forEach((input) => {
if (selectedNodeIds.has(input.nodeId)) return;
selectedInputs.push(input);
selectedNodeIds.add(input.nodeId);
});
}
// 首尾帧是结构化生成参数,不受提示词中的 @ 引用筛选影响。
const selectedNodeIds = new Set(selectedInputs.map((input) => input.nodeId));
videoFrameNodeIds.forEach((nodeId) => {
@@ -501,12 +547,12 @@ function readReferenceImage(node: CanvasNodeData, nodes: CanvasNodeData[], conne
}
function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
if (node.type !== CanvasNodeType.Video || !node.metadata?.content) return null;
if (node.type !== CanvasNodeType.Video || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp4`,
type: node.metadata.mimeType || "video/mp4",
url: node.metadata.content,
url: node.metadata.content || "",
storageKey: node.metadata.storageKey,
bytes: node.metadata.bytes,
width: node.metadata.naturalWidth,
@@ -516,12 +562,12 @@ function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
}
function readReferenceAudio(node: CanvasNodeData): ReferenceAudio | null {
if (node.type !== CanvasNodeType.Audio || !node.metadata?.content) return null;
if (node.type !== CanvasNodeType.Audio || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp3`,
type: node.metadata.mimeType || "audio/mpeg",
url: node.metadata.content,
url: node.metadata.content || "",
storageKey: node.metadata.storageKey,
bytes: node.metadata.bytes,
durationMs: node.metadata.durationMs,

View File

@@ -35,6 +35,7 @@ export function CanvasToolbar({
onAddFolder,
onAddDrawing,
onAddExtensionNode,
onAddWorkflow,
onOpenDirector,
onUndo,
onRedo,
@@ -66,6 +67,7 @@ export function CanvasToolbar({
onAddFolder: () => void;
onAddDrawing: () => void;
onAddExtensionNode: (type: CanvasNodeTypeId) => void;
onAddWorkflow: () => void;
onOpenDirector: () => void;
onUndo: () => void;
onRedo: () => void;
@@ -129,6 +131,7 @@ export function CanvasToolbar({
onAddFolder,
onAddDrawing,
onAddExtensionNode,
onAddWorkflow,
onChooseStyle,
onOpenDirector,
onUpload,

View File

@@ -23,6 +23,8 @@ type CanvasVideoPromptToolsProps = {
metadata?: CanvasNodeMetadata;
frameOptions: VideoFrameOption[];
onMetadataChange: (patch: Partial<CanvasNodeMetadata>) => void;
referenceMode?: "frames" | "all";
referenceSummary?: { imageCount: number; videoCount: number; audioCount: number };
};
const EMPTY_FRAME_VALUE = "__none__";
@@ -31,7 +33,7 @@ const MENU_MARGIN = 8;
const MENU_ITEM_HEIGHT = 28;
const CONTROL_TEXT_STYLE: CSSProperties = { fontFamily: "inherit", fontSize: 11, fontWeight: 400, letterSpacing: 0, lineHeight: 1 };
export function CanvasVideoPromptTools({ metadata, frameOptions, onMetadataChange }: CanvasVideoPromptToolsProps) {
export function CanvasVideoPromptTools({ metadata, frameOptions, onMetadataChange, referenceMode = "frames", referenceSummary }: CanvasVideoPromptToolsProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const startFrame = metadata?.videoStartFrameNodeId || EMPTY_FRAME_VALUE;
const endFrame = metadata?.videoEndFrameNodeId || EMPTY_FRAME_VALUE;
@@ -41,6 +43,30 @@ export function CanvasVideoPromptTools({ metadata, frameOptions, onMetadataChang
onMetadataChange(key === "videoStartFrameNodeId" ? { videoStartFrameNodeId: next } : { videoEndFrameNodeId: next });
};
if (referenceMode === "all") {
const summary = referenceSummary
? [
referenceSummary.imageCount ? `${referenceSummary.imageCount} 图` : "",
referenceSummary.videoCount ? `${referenceSummary.videoCount} 视频` : "",
referenceSummary.audioCount ? `${referenceSummary.audioCount} 音频` : "",
].filter(Boolean).join(" · ")
: "";
return (
<div
className="flex min-h-8 min-w-0 items-center gap-2 rounded-md px-2 py-1.5"
data-canvas-no-zoom
style={{ background: theme.toolbar.itemHover, color: theme.node.text }}
title="已连接的图片、视频和音频会按工作流字段映射传入"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<ImageIcon className="size-3.5 shrink-0 opacity-80" />
<span className="shrink-0 text-[var(--fs-tiny)] font-medium">工作流参考</span>
<span className="min-w-0 truncate text-[var(--fs-micro)] opacity-65">{summary || "尚未连接媒体"}</span>
</div>
);
}
if (!frameOptions.length) return null;
return (

View File

@@ -1,6 +1,6 @@
import { motion, useReducedMotion } from "motion/react";
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { ChevronRight, Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, WandSparkles, X } from "lucide-react";
import { ChevronRight, Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, WandSparkles, Workflow as WorkflowIcon, X } from "lucide-react";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
@@ -165,13 +165,13 @@ function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform
return clamp(Math.round(node.width * viewport.k * 1.5), 680, 920);
}
export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject<HTMLDivElement | null>; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => void; onClose: () => void }) {
export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject<HTMLDivElement | null>; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, provider?: "runninghub" | "comfyui") => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, provider?: "runninghub" | "comfyui") => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const menuRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("connection-create-menu", "var(--z-modal-overlay)");
const menuWidth = 248;
const menuHeight = canCreateDrawing ? 420 : 376;
const menuHeight = canCreateDrawing ? 456 : 412;
const gap = 12;
const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap);
@@ -221,6 +221,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<WorkflowIcon className="size-4" />} title="工作流生成" description="在同一节点切换 RunningHub 或 ComfyUI" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} />
{canCreateDrawing ? <ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />

View File

@@ -25,6 +25,7 @@ type InfiniteCanvasProps = {
};
const CANVAS_WHEEL_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-canvas-wheel-scroll],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown";
const CANVAS_POINTER_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-connection-create-menu],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown";
const WHEEL_ZOOM_DELTA = 72;
const TRACKPAD_PINCH_ZOOM_DELTA = 24;
@@ -191,8 +192,8 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom]")) return;
if (target?.closest("[data-connection-create-menu]")) return;
// AntD 浮层通过 Portal 渲染到节点 DOM 之外;若不统一排除,会被误判为画布空白并捕获指针。
if (target?.closest(CANVAS_POINTER_IGNORE_SELECTOR)) return;
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
const isTouch = event.pointerType === "touch";