feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -274,6 +274,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
|
||||
onAddFolder,
|
||||
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
|
||||
onAddExtensionNode: onAddNode,
|
||||
onAddWorkflow: () => onAddNode(CanvasNodeType.Config),
|
||||
onChooseStyle,
|
||||
onOpenDirector,
|
||||
onUpload,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
299
web/src/components/workflow-field-mapping-editor.tsx
Normal file
299
web/src/components/workflow-field-mapping-editor.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Collapse, Empty, Input, InputNumber, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import { ListFilter, ListPlus, Power, PowerOff } from "lucide-react";
|
||||
|
||||
import type { WorkflowFieldMapping } from "@/stores/use-config-store";
|
||||
import { workflowFieldChoiceValues, workflowFieldConfigurationError, workflowFieldNumberBounds, workflowFieldPresetOptions } from "@/lib/model-capabilities";
|
||||
|
||||
const sourceOptions = [
|
||||
{ label: "保留工作流默认值", value: "" },
|
||||
{ label: "任务提示词", value: "prompt" },
|
||||
{ label: "系统提示词", value: "systemPrompt" },
|
||||
{ label: "参考图片", value: "referenceImage" },
|
||||
{ label: "参考视频", value: "referenceVideo" },
|
||||
{ label: "参考音频", value: "referenceAudio" },
|
||||
{ label: "蒙版", value: "mask" },
|
||||
{ label: "画面尺寸", value: "size" },
|
||||
{ label: "画面宽高比", value: "aspectRatio" },
|
||||
{ label: "画面宽度", value: "width" },
|
||||
{ label: "画面高度", value: "height" },
|
||||
{ label: "生成数量", value: "count" },
|
||||
{ label: "生成质量", value: "quality" },
|
||||
{ label: "透明背景", value: "transparentBackground" },
|
||||
{ label: "视频时长", value: "videoSeconds" },
|
||||
{ label: "视频质量", value: "vquality" },
|
||||
{ label: "视频生成音频", value: "videoGenerateAudio" },
|
||||
{ label: "视频水印", value: "videoWatermark" },
|
||||
{ label: "音色", value: "audioVoice" },
|
||||
{ label: "音频格式", value: "audioFormat" },
|
||||
{ label: "音频语速", value: "audioSpeed" },
|
||||
{ label: "音频指令", value: "audioInstructions" },
|
||||
] as const;
|
||||
|
||||
const fieldTypeOptions = [
|
||||
{ label: "文本", value: "TEXT" },
|
||||
{ label: "数字", value: "NUMBER" },
|
||||
{ label: "滑块", value: "SLIDER" },
|
||||
{ label: "开关", value: "BOOLEAN" },
|
||||
{ label: "下拉", value: "SELECT" },
|
||||
{ label: "图片", value: "IMAGE" },
|
||||
{ label: "视频", value: "VIDEO" },
|
||||
{ label: "音频", value: "AUDIO" },
|
||||
] as const;
|
||||
|
||||
type WorkflowFieldMappingEditorProps = {
|
||||
fields: WorkflowFieldMapping[];
|
||||
onChange: (fields: WorkflowFieldMapping[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function WorkflowFieldMappingEditor({ fields, onChange, disabled = false }: WorkflowFieldMappingEditorProps) {
|
||||
const [showOnlyEnabled, setShowOnlyEnabled] = useState(false);
|
||||
const enabledCount = useMemo(() => fields.filter((field) => field.enabled !== false).length, [fields]);
|
||||
const controllableFields = useMemo(() => fields.filter((field) => field.safeToOverride !== false), [fields]);
|
||||
const enabledControllableCount = useMemo(() => controllableFields.filter((field) => field.enabled !== false).length, [controllableFields]);
|
||||
const visibleFields = useMemo(() => fields.flatMap((field, index) => (showOnlyEnabled && field.enabled === false ? [] : [{ field, index }])), [fields, showOnlyEnabled]);
|
||||
const updateField = (index: number, patch: Partial<WorkflowFieldMapping>) => {
|
||||
onChange(fields.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)));
|
||||
};
|
||||
const updateAllFields = (enabled: boolean) => {
|
||||
onChange(fields.map((field) => (field.safeToOverride === false ? field : { ...field, enabled })));
|
||||
};
|
||||
|
||||
if (!fields.length) {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="尚未发现可配置字段,请先拉取工作流参数" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-field-mapping-editor space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-foreground/60">
|
||||
共 {fields.length} 个字段,已开启 {enabledCount} 个。字段调整会立即保存到当前工作流。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button type="text" size="small" icon={<Power className="size-3.5" />} disabled={disabled || enabledControllableCount === controllableFields.length} onClick={() => updateAllFields(true)}>
|
||||
开启全部
|
||||
</Button>
|
||||
<Button type="text" size="small" danger icon={<PowerOff className="size-3.5" />} disabled={disabled || enabledControllableCount === 0} onClick={() => updateAllFields(false)}>
|
||||
关闭全部
|
||||
</Button>
|
||||
<Button type={showOnlyEnabled ? "default" : "text"} size="small" icon={<ListFilter className="size-3.5" />} aria-pressed={showOnlyEnabled} onClick={() => setShowOnlyEnabled((current) => !current)}>
|
||||
{showOnlyEnabled ? "显示全部" : "仅显示已开启"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={visibleFields.map(({ field, index }) => {
|
||||
const source = String(field.source || "");
|
||||
const sourceLabel = sourceOptions.find((item) => item.value === source)?.label || source || "保留工作流默认值";
|
||||
const enabled = field.enabled !== false;
|
||||
const fieldType = workflowEditorFieldType(field);
|
||||
const numeric = fieldType === "NUMBER" || fieldType === "SLIDER";
|
||||
const choices = workflowFieldChoiceValues(field);
|
||||
const presetOptions = workflowFieldPresetOptions(field);
|
||||
const safeToOverride = field.safeToOverride !== false;
|
||||
const fieldDisabled = disabled || !enabled || !safeToOverride;
|
||||
const configurationError = workflowFieldConfigurationError(field);
|
||||
const roleLabel = field.role === "prompt" ? "提示词" : field.role === "media" ? "素材" : field.role === "internal" ? "内部参数" : "业务参数";
|
||||
return {
|
||||
key: field.id || `${field.nodeId}::${field.fieldName}::${index}`,
|
||||
label: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{field.label || field.fieldName}</span>
|
||||
<Tooltip title={`${field.nodeId}.${field.fieldName}`}>
|
||||
<span className="truncate text-xs text-foreground/50">
|
||||
{field.nodeId}.{field.fieldName}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tag className="ml-auto shrink-0">{sourceLabel}</Tag>
|
||||
<Tag color={safeToOverride ? undefined : "error"} className="shrink-0">
|
||||
{safeToOverride ? roleLabel : "禁止覆盖"}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
extra: (
|
||||
<span onClick={(event) => event.stopPropagation()}>
|
||||
<Switch size="small" checked={enabled} disabled={disabled || !safeToOverride} aria-label={`启用字段 ${field.label || field.fieldName}`} onChange={(checked) => updateField(index, { enabled: checked })} />
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<label className="grid gap-1 text-xs text-foreground/60">
|
||||
显示名称
|
||||
<Input value={field.label || ""} disabled={fieldDisabled} placeholder={field.fieldName} onChange={(event) => updateField(index, { label: event.target.value })} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs text-foreground/60">
|
||||
参数类型
|
||||
<Select value={fieldType} disabled={fieldDisabled} options={fieldTypeOptions.map((item) => ({ ...item }))} onChange={(value) => updateField(index, { fieldType: value })} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs text-foreground/60">
|
||||
输入来源
|
||||
<Select showSearch value={source} disabled={fieldDisabled} options={sourceOptions.map((item) => ({ ...item }))} optionFilterProp="label" onChange={(value) => updateField(index, sourcePatch(field, value))} />
|
||||
</label>
|
||||
{fieldType === "SELECT" || choices.length || presetOptions.length ? (
|
||||
<label className="grid gap-1 text-xs text-foreground/60 md:col-span-2 lg:col-span-3">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>下拉选项(每行一个)</span>
|
||||
{presetOptions.length ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ListPlus className="size-3.5" />}
|
||||
disabled={fieldDisabled}
|
||||
onClick={() => updateField(index, { fieldType: "SELECT", options: presetOptions, optionsSource: "preset" })}
|
||||
>
|
||||
常用模板
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
value={choices.map(workflowOptionText).join("\n")}
|
||||
disabled={fieldDisabled}
|
||||
placeholder={"1:1\n16:9\n9:16"}
|
||||
onChange={(event) => updateField(index, { options: parseWorkflowOptions(event.target.value), optionsSource: "manual" })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{numeric ? (
|
||||
<>
|
||||
<label className="grid gap-1 text-xs text-foreground/60">
|
||||
最小值
|
||||
<InputNumber className="w-full" value={numberOrUndefined(field.min)} max={numberOrUndefined(field.max)} disabled={fieldDisabled} onChange={(value) => updateField(index, { min: value ?? undefined })} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs text-foreground/60">
|
||||
最大值
|
||||
<InputNumber className="w-full" value={numberOrUndefined(field.max)} min={numberOrUndefined(field.min)} disabled={fieldDisabled} onChange={(value) => updateField(index, { max: value ?? undefined })} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs text-foreground/60">
|
||||
步长
|
||||
<InputNumber className="w-full" min={0.000001} value={numberOrUndefined(field.step)} disabled={fieldDisabled} onChange={(value) => updateField(index, { step: value ?? undefined })} />
|
||||
</label>
|
||||
{configurationError ? <div className="text-xs text-error md:col-span-2 lg:col-span-3">{configurationError}</div> : null}
|
||||
</>
|
||||
) : null}
|
||||
{sourceUsesIndex(source) ? (
|
||||
<label className="grid gap-1 text-xs text-foreground/60">
|
||||
{source === "referenceImage" ? "图片槽位" : source === "referenceVideo" ? "视频槽位" : "音频槽位"}
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={1}
|
||||
precision={0}
|
||||
value={source === "referenceImage" ? Math.max(1, Number(field.imageOrder) || 1) : Math.max(1, (Number(field.sourceIndex) || 0) + 1)}
|
||||
disabled={fieldDisabled}
|
||||
onChange={(value) => updateField(index, source === "referenceImage" ? { imageOrder: Number(value) || 1 } : { sourceIndex: Math.max(0, (Number(value) || 1) - 1) })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="flex items-center justify-between gap-3 text-xs text-foreground/60">
|
||||
缺失时阻止生成
|
||||
<Switch checked={field.required === true} disabled={fieldDisabled || !source} onChange={(required) => updateField(index, { required })} />
|
||||
</label>
|
||||
{fieldType === "NUMBER" ? (
|
||||
<label className="flex items-center justify-between gap-3 text-xs text-foreground/60">
|
||||
每次随机数字
|
||||
<Switch checked={field.randomEnabled === true} disabled={fieldDisabled || Boolean(source)} onChange={(randomEnabled) => updateField(index, { randomEnabled })} />
|
||||
</label>
|
||||
) : null}
|
||||
{!source ? (
|
||||
<label className="grid gap-1 text-xs text-foreground/60 md:col-span-2 lg:col-span-3">
|
||||
工作流默认值
|
||||
<DefaultValueInput field={field} disabled={fieldDisabled || field.randomEnabled === true} onChange={(fieldValue) => updateField(index, { fieldValue })} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{showOnlyEnabled && !visibleFields.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无已开启字段" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultValueInput({ field, disabled, onChange }: { field: WorkflowFieldMapping; disabled: boolean; onChange: (value: unknown) => void }) {
|
||||
const fieldType = String(field.fieldType || "").toUpperCase();
|
||||
const value = field.fieldValue ?? field.value ?? field.default;
|
||||
const bounds = workflowFieldNumberBounds(field);
|
||||
const choices = workflowFieldChoiceValues(field);
|
||||
if (fieldType === "BOOLEAN" || typeof value === "boolean") {
|
||||
return (
|
||||
<Select
|
||||
value={Boolean(value)}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ label: "true", value: true },
|
||||
{ label: "false", value: false },
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (choices.length) {
|
||||
return <Select showSearch value={value} disabled={disabled} options={choices.map((option) => ({ label: workflowOptionText(option), value: workflowOptionValue(option) }))} onChange={onChange} />;
|
||||
}
|
||||
if (fieldType === "NUMBER" || typeof value === "number" || bounds.min !== undefined || bounds.max !== undefined || bounds.step !== undefined) {
|
||||
return <InputNumber className="w-full" value={numberOrUndefined(value)} disabled={disabled} min={bounds.min} max={bounds.max} step={bounds.step} onChange={onChange} />;
|
||||
}
|
||||
return <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} value={displayValue(value)} disabled={disabled} onChange={(event) => onChange(event.target.value)} />;
|
||||
}
|
||||
|
||||
function sourcePatch(field: WorkflowFieldMapping, source: string): Partial<WorkflowFieldMapping> {
|
||||
if (source === "referenceImage") return { source, bindPrompt: false, sourceAutomatic: false, imageOrder: Math.max(1, Number(field.imageOrder) || 1), sourceIndex: undefined };
|
||||
if (source === "referenceVideo" || source === "referenceAudio") return { source, bindPrompt: false, sourceAutomatic: false, sourceIndex: Math.max(0, Number(field.sourceIndex) || 0), imageOrder: undefined };
|
||||
return { source, bindPrompt: false, sourceAutomatic: false, sourceIndex: undefined, imageOrder: undefined, required: source ? field.required : false };
|
||||
}
|
||||
|
||||
function sourceUsesIndex(source: string) {
|
||||
return source === "referenceImage" || source === "referenceVideo" || source === "referenceAudio";
|
||||
}
|
||||
|
||||
function workflowEditorFieldType(field: WorkflowFieldMapping) {
|
||||
const configured = String(field.fieldType || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (["FLOAT", "INT", "INTEGER"].includes(configured)) return "NUMBER";
|
||||
if (fieldTypeOptions.some((item) => item.value === configured)) return configured;
|
||||
const value = field.fieldValue ?? field.value ?? field.default;
|
||||
if (typeof value === "boolean") return "BOOLEAN";
|
||||
if (typeof value === "number") return "NUMBER";
|
||||
return "TEXT";
|
||||
}
|
||||
|
||||
function parseWorkflowOptions(value: string) {
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function workflowOptionValue(value: unknown): string | number {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const option = value as Record<string, unknown>;
|
||||
for (const key of ["value", "id", "key", "name", "label"]) {
|
||||
if (option[key] !== undefined && option[key] !== null) return typeof option[key] === "number" ? option[key] : String(option[key]);
|
||||
}
|
||||
}
|
||||
return typeof value === "number" ? value : String(value ?? "");
|
||||
}
|
||||
|
||||
function workflowOptionText(value: unknown) {
|
||||
return String(workflowOptionValue(value));
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown) {
|
||||
if (value === undefined || value === null || String(value).trim() === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
408
web/src/components/workflow-graph-editor.tsx
Normal file
408
web/src/components/workflow-graph-editor.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { Button, Empty, Select } from "antd";
|
||||
import { Maximize, Power, PowerOff, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
import { WorkflowFieldMappingEditor } from "@/components/workflow-field-mapping-editor";
|
||||
import type { WorkflowFieldMapping, WorkflowGraphPreview } from "@/stores/use-config-store";
|
||||
|
||||
type WorkflowNode = {
|
||||
id: string;
|
||||
title: string;
|
||||
classType: string;
|
||||
category: string;
|
||||
inputs: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type GraphNode = WorkflowNode & { x: number; y: number };
|
||||
type GraphEdge = { from: string; to: string };
|
||||
type WorkflowGraph = { nodes: GraphNode[]; edges: GraphEdge[]; width: number; height: number };
|
||||
|
||||
type WorkflowGraphEditorProps = {
|
||||
workflowJson?: Record<string, unknown>;
|
||||
workflowGraph?: WorkflowGraphPreview;
|
||||
fields: WorkflowFieldMapping[];
|
||||
onChange: (fields: WorkflowFieldMapping[]) => void;
|
||||
disabled?: boolean;
|
||||
emptyDescription?: string;
|
||||
};
|
||||
|
||||
const nodeWidth = 156;
|
||||
const nodeHeight = 58;
|
||||
const columnGap = 58;
|
||||
const rowGap = 20;
|
||||
const graphPadding = 24;
|
||||
const allEnabledFieldsNodeId = "__all_enabled_fields__";
|
||||
|
||||
export function WorkflowGraphEditor({ workflowJson, workflowGraph, fields, onChange, disabled = false, emptyDescription = "尚未读取到工作流拓扑" }: WorkflowGraphEditorProps) {
|
||||
const graph = useMemo(() => buildWorkflowGraph(workflowJson, workflowGraph), [workflowGraph, workflowJson]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [scale, setScale] = useState(1);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const panRef = useRef<{ pointerId: number; x: number; y: number; left: number; top: number } | null>(null);
|
||||
const nodesWithFields = useMemo(() => new Set(fields.map((field) => String(field.nodeId))), [fields]);
|
||||
const enabledFields = useMemo(() => fields.filter((field) => field.enabled !== false), [fields]);
|
||||
const controllableFields = useMemo(() => fields.filter((field) => field.safeToOverride !== false), [fields]);
|
||||
const enabledControllableCount = useMemo(() => controllableFields.filter((field) => field.enabled !== false).length, [controllableFields]);
|
||||
const selectedFields = selectedNodeId === allEnabledFieldsNodeId ? enabledFields : selectedNodeId ? fields.filter((field) => String(field.nodeId) === selectedNodeId) : fields;
|
||||
const selectedNode = graph.nodes.find((node) => node.id === selectedNodeId);
|
||||
|
||||
useEffect(() => {
|
||||
const firstMapped = graph.nodes.find((node) => nodesWithFields.has(node.id));
|
||||
setSelectedNodeId((current) => (current === allEnabledFieldsNodeId || graph.nodes.some((node) => node.id === current) ? current : firstMapped?.id || graph.nodes[0]?.id || ""));
|
||||
}, [graph.nodes, nodesWithFields]);
|
||||
|
||||
const updateSelectedFields = (nextSelected: WorkflowFieldMapping[]) => {
|
||||
const replacements = new Map(nextSelected.map((field) => [fieldKey(field), field]));
|
||||
onChange(fields.map((field) => replacements.get(fieldKey(field)) || field));
|
||||
};
|
||||
|
||||
const updateAllFields = (enabled: boolean) => {
|
||||
onChange(fields.map((field) => (field.safeToOverride === false ? field : { ...field, enabled })));
|
||||
};
|
||||
|
||||
const fitGraph = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || !graph.width || !graph.height) return;
|
||||
const next = Math.max(0.3, Math.min(1.6, Math.min((viewport.clientWidth - 32) / graph.width, (viewport.clientHeight - 32) / graph.height)));
|
||||
setScale(next);
|
||||
requestAnimationFrame(() => {
|
||||
viewport.scrollLeft = Math.max(0, (graph.width * next - viewport.clientWidth) / 2);
|
||||
viewport.scrollTop = Math.max(0, (graph.height * next - viewport.clientHeight) / 2);
|
||||
});
|
||||
}, [graph.height, graph.width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!graph.nodes.length) return;
|
||||
// 工作流切换后自动适配一次;用户后续缩放不应被普通重渲染重置。
|
||||
const frame = requestAnimationFrame(fitGraph);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [fitGraph, graph.nodes.length]);
|
||||
|
||||
const zoom = (direction: number) => setScale((current) => Math.max(0.3, Math.min(2.4, current * (direction > 0 ? 1.2 : 1 / 1.2))));
|
||||
|
||||
const handleViewportWheel = useCallback(
|
||||
(event: WheelEvent) => {
|
||||
if (!graph.nodes.length) return;
|
||||
event.preventDefault();
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const pointerX = event.clientX - rect.left;
|
||||
const pointerY = event.clientY - rect.top;
|
||||
const contentX = viewport.scrollLeft + pointerX;
|
||||
const contentY = viewport.scrollTop + pointerY;
|
||||
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
setScale((current) => {
|
||||
const next = Math.max(0.3, Math.min(2.4, current * factor));
|
||||
if (next === current) return current;
|
||||
const ratio = next / current;
|
||||
requestAnimationFrame(() => {
|
||||
if (viewportRef.current !== viewport) return;
|
||||
viewport.scrollLeft = contentX * ratio - pointerX;
|
||||
viewport.scrollTop = contentY * ratio - pointerY;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[graph.nodes.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
// React 的 wheel 事件在部分浏览器中可能被注册为 passive,使用原生监听确保能阻止外层页面滚动。
|
||||
viewport.addEventListener("wheel", handleViewportWheel, { passive: false });
|
||||
return () => viewport.removeEventListener("wheel", handleViewportWheel);
|
||||
}, [handleViewportWheel]);
|
||||
|
||||
const startPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || (event.target as Element).closest("[data-workflow-node]")) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
panRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const movePan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const pan = panRef.current;
|
||||
const viewport = viewportRef.current;
|
||||
if (!pan || !viewport || pan.pointerId !== event.pointerId) return;
|
||||
viewport.scrollLeft = pan.left - (event.clientX - pan.x);
|
||||
viewport.scrollTop = pan.top - (event.clientY - pan.y);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workflow-graph-editor">
|
||||
<aside className="workflow-graph-sidebar">
|
||||
<div className="workflow-graph-summary">
|
||||
<span>
|
||||
<small>节点</small>
|
||||
<strong>{graph.nodes.length}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>字段</small>
|
||||
<strong>
|
||||
{enabledFields.length} / {fields.length}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>素材</small>
|
||||
<strong>{mediaFieldCount(enabledFields)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button size="small" type="text" icon={<PowerOff className="size-3.5" />} danger disabled={disabled || enabledControllableCount === 0} onClick={() => updateAllFields(false)}>
|
||||
关闭全部开关
|
||||
</Button>
|
||||
<Button size="small" type="text" icon={<Power className="size-3.5" />} disabled={disabled || enabledControllableCount === controllableFields.length} onClick={() => updateAllFields(true)}>
|
||||
开启全部开关
|
||||
</Button>
|
||||
</div>
|
||||
<label className="workflow-graph-node-select">
|
||||
<span>正在设置的节点</span>
|
||||
<Select
|
||||
showSearch
|
||||
value={selectedNodeId || undefined}
|
||||
placeholder="选择节点"
|
||||
optionFilterProp="label"
|
||||
options={[{ label: `全部已开启字段 · ${enabledFields.length}`, value: allEnabledFieldsNodeId }, ...graph.nodes.map((node) => ({ label: `${node.title} · #${node.id}`, value: node.id }))]}
|
||||
onChange={setSelectedNodeId}
|
||||
/>
|
||||
</label>
|
||||
<div className="workflow-graph-selected-heading">
|
||||
<strong>{selectedNodeId === allEnabledFieldsNodeId ? "全部已开启字段" : selectedNode?.title || "工作流字段"}</strong>
|
||||
<span>{selectedNodeId === allEnabledFieldsNodeId ? `${enabledFields.length} 个字段` : selectedNode ? `${selectedNode.classType} · #${selectedNode.id}` : "选择右侧节点进行设置"}</span>
|
||||
</div>
|
||||
<WorkflowFieldMappingEditor fields={selectedFields} disabled={disabled} onChange={updateSelectedFields} />
|
||||
</aside>
|
||||
|
||||
<section className="workflow-graph-stage">
|
||||
<div className="workflow-graph-controls">
|
||||
<Button size="small" type="text" icon={<ZoomOut />} aria-label="缩小工作流" onClick={() => zoom(-1)} />
|
||||
<span>{Math.round(scale * 100)}%</span>
|
||||
<Button size="small" type="text" icon={<ZoomIn />} aria-label="放大工作流" onClick={() => zoom(1)} />
|
||||
<Button size="small" type="text" icon={<Maximize />} aria-label="适应窗口" onClick={fitGraph} />
|
||||
</div>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="workflow-graph-viewport"
|
||||
onPointerDown={startPan}
|
||||
onPointerMove={movePan}
|
||||
onPointerUp={(event) => {
|
||||
panRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
panRef.current = null;
|
||||
}}
|
||||
>
|
||||
{graph.nodes.length ? (
|
||||
<svg className="workflow-graph-svg" width={graph.width * scale} height={graph.height * scale} viewBox={`0 0 ${graph.width} ${graph.height}`}>
|
||||
<g>
|
||||
{graph.edges.map((edge) => {
|
||||
const from = graph.nodes.find((node) => node.id === edge.from)!;
|
||||
const to = graph.nodes.find((node) => node.id === edge.to)!;
|
||||
const x1 = from.x + nodeWidth;
|
||||
const y1 = from.y + nodeHeight / 2;
|
||||
const x2 = to.x;
|
||||
const y2 = to.y + nodeHeight / 2;
|
||||
const curve = Math.max(36, (x2 - x1) / 2);
|
||||
return <path key={`${edge.from}:${edge.to}`} className="workflow-graph-edge" d={`M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`} />;
|
||||
})}
|
||||
{graph.nodes.map((node) => {
|
||||
const fieldCount = fields.filter((field) => String(field.nodeId) === node.id && field.enabled !== false).length;
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
data-workflow-node
|
||||
className={`workflow-graph-node is-${node.category}${fieldCount ? " has-fields" : ""}${selectedNodeId === node.id ? " is-selected" : ""}`}
|
||||
transform={`translate(${node.x},${node.y})`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedNodeId(node.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSelectedNodeId(node.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<rect width={nodeWidth} height={nodeHeight} rx="10" />
|
||||
<text className="workflow-graph-node-title" x="11" y="22">
|
||||
{truncate(node.title, 18)}
|
||||
</text>
|
||||
<text className="workflow-graph-node-type" x="11" y="40">
|
||||
{truncate(node.classType, 21)}
|
||||
</text>
|
||||
<text className="workflow-graph-node-id" x={nodeWidth - 9} y="20" textAnchor="end">
|
||||
#{node.id}
|
||||
</text>
|
||||
{fieldCount ? (
|
||||
<text className="workflow-graph-node-count" x={nodeWidth - 9} y="43" textAnchor="end">
|
||||
{fieldCount}
|
||||
</text>
|
||||
) : null}
|
||||
<title>
|
||||
{node.title} · {node.classType} · #{node.id}
|
||||
</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkflowGraph(rawWorkflow?: Record<string, unknown>, preview?: WorkflowGraphPreview): WorkflowGraph {
|
||||
const workflow = unwrapWorkflow(rawWorkflow);
|
||||
const apiNodes: WorkflowNode[] = Object.entries(workflow).flatMap(([id, value]) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const raw = value as Record<string, unknown>;
|
||||
const meta = raw._meta && typeof raw._meta === "object" && !Array.isArray(raw._meta) ? (raw._meta as Record<string, unknown>) : {};
|
||||
const classType = String(raw.class_type || raw.classType || "Unknown");
|
||||
return [{ id, title: String(meta.title || raw.title || classType), classType, category: nodeCategory(classType), inputs: raw.inputs && typeof raw.inputs === "object" && !Array.isArray(raw.inputs) ? (raw.inputs as Record<string, unknown>) : {} }];
|
||||
});
|
||||
const canvas = canvasWorkflowGraph(workflow);
|
||||
const nodes = apiNodes.length
|
||||
? apiNodes
|
||||
: (preview?.nodes || canvas?.nodes || []).map((node) => {
|
||||
const classType = String(node.classType || "Unknown");
|
||||
return { id: String(node.id), title: String(node.title || classType), classType, category: nodeCategory(classType), inputs: {} };
|
||||
});
|
||||
const edges = apiNodes.length ? workflowEdges(nodes) : preview?.edges || canvas?.edges || [];
|
||||
const positions = layoutWorkflowNodes(nodes, edges);
|
||||
const graphNodes = nodes.map((node) => ({ ...node, ...(positions.get(node.id) || { x: graphPadding, y: graphPadding }) }));
|
||||
const width = Math.max(640, ...graphNodes.map((node) => node.x + nodeWidth + graphPadding));
|
||||
const height = Math.max(420, ...graphNodes.map((node) => node.y + nodeHeight + graphPadding));
|
||||
return { nodes: graphNodes, edges, width, height };
|
||||
}
|
||||
|
||||
function workflowEdges(nodes: WorkflowNode[]): GraphEdge[] {
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges: GraphEdge[] = [];
|
||||
const edgeKeys = new Set<string>();
|
||||
nodes.forEach((node) =>
|
||||
Object.values(node.inputs).forEach((input) => {
|
||||
if (!Array.isArray(input) || input.length !== 2) return;
|
||||
const from = String(input[0]);
|
||||
const key = `${from}:${node.id}`;
|
||||
if (!nodeIds.has(from) || edgeKeys.has(key)) return;
|
||||
edgeKeys.add(key);
|
||||
edges.push({ from, to: node.id });
|
||||
}),
|
||||
);
|
||||
return edges;
|
||||
}
|
||||
|
||||
function canvasWorkflowGraph(workflow: Record<string, unknown>): WorkflowGraphPreview | undefined {
|
||||
if (!Array.isArray(workflow.nodes)) return undefined;
|
||||
const nodes = workflow.nodes.flatMap((value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const raw = value as Record<string, unknown>;
|
||||
const id = String(raw.id ?? "").trim();
|
||||
if (!id) return [];
|
||||
const properties = raw.properties && typeof raw.properties === "object" && !Array.isArray(raw.properties) ? (raw.properties as Record<string, unknown>) : {};
|
||||
const classType = String(raw.type || raw.class_type || "Unknown");
|
||||
return [{ id, title: String(raw.title || properties["Node name for S&R"] || classType), classType }];
|
||||
});
|
||||
if (!nodes.length) return undefined;
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges: Array<{ from: string; to: string }> = [];
|
||||
const edgeKeys = new Set<string>();
|
||||
if (Array.isArray(workflow.links)) {
|
||||
workflow.links.forEach((value) => {
|
||||
if (!Array.isArray(value) || value.length < 4) return;
|
||||
const from = String(value[1]);
|
||||
const to = String(value[3]);
|
||||
const key = `${from}:${to}`;
|
||||
if (!nodeIds.has(from) || !nodeIds.has(to) || from === to || edgeKeys.has(key)) return;
|
||||
edgeKeys.add(key);
|
||||
edges.push({ from, to });
|
||||
});
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function layoutWorkflowNodes(nodes: WorkflowNode[], edges: GraphEdge[]) {
|
||||
const inbound = new Map(nodes.map((node) => [node.id, 0]));
|
||||
const outbound = new Map(nodes.map((node) => [node.id, [] as string[]]));
|
||||
edges.forEach((edge) => {
|
||||
inbound.set(edge.to, (inbound.get(edge.to) || 0) + 1);
|
||||
outbound.get(edge.from)?.push(edge.to);
|
||||
});
|
||||
const queue = nodes.filter((node) => !inbound.get(node.id)).map((node) => node.id);
|
||||
const layer = new Map(queue.map((id) => [id, 0]));
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
outbound.get(id)?.forEach((target) => {
|
||||
layer.set(target, Math.max(layer.get(target) || 0, (layer.get(id) || 0) + 1));
|
||||
inbound.set(target, (inbound.get(target) || 1) - 1);
|
||||
if (!inbound.get(target)) queue.push(target);
|
||||
});
|
||||
}
|
||||
nodes.forEach((node) => {
|
||||
if (!layer.has(node.id)) layer.set(node.id, 0);
|
||||
});
|
||||
const columns = new Map<number, WorkflowNode[]>();
|
||||
nodes.forEach((node) => {
|
||||
const value = layer.get(node.id) || 0;
|
||||
columns.set(value, [...(columns.get(value) || []), node]);
|
||||
});
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
[...columns.keys()]
|
||||
.sort((left, right) => left - right)
|
||||
.forEach((column) => {
|
||||
const items = columns.get(column)!.sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
|
||||
items.forEach((node, row) => positions.set(node.id, { x: graphPadding + column * (nodeWidth + columnGap), y: graphPadding + row * (nodeHeight + rowGap) }));
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
function unwrapWorkflow(value?: Record<string, unknown>) {
|
||||
let current = value || {};
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const nested = current.prompt ?? current.workflow;
|
||||
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
||||
current = nested as Record<string, unknown>;
|
||||
continue;
|
||||
}
|
||||
if (typeof nested === "string" && nested.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(nested);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
current = parsed;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function fieldKey(field: WorkflowFieldMapping) {
|
||||
return field.id || `${field.nodeId}::${field.fieldName}`;
|
||||
}
|
||||
function mediaFieldCount(fields: WorkflowFieldMapping[]) {
|
||||
return fields.filter((field) => ["referenceImage", "referenceVideo", "referenceAudio", "mask"].includes(String(field.source || ""))).length;
|
||||
}
|
||||
function truncate(value: string, length: number) {
|
||||
return Array.from(value).length > length ? `${Array.from(value).slice(0, length).join("")}…` : value;
|
||||
}
|
||||
function nodeCategory(classType: string) {
|
||||
const value = classType.toLowerCase();
|
||||
if (value.includes("load") || value.includes("loader")) return "loader";
|
||||
if (value.includes("save") || value.includes("preview") || value.includes("output")) return "output";
|
||||
if (value.includes("sampler") || value.includes("scheduler") || value.includes("guide")) return "sampler";
|
||||
if (value.includes("image") || value.includes("vae")) return "image";
|
||||
if (value.includes("video")) return "video";
|
||||
return "default";
|
||||
}
|
||||
569
web/src/components/workflow-test-workbench.tsx
Normal file
569
web/src/components/workflow-test-workbench.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import { App, Button, Empty, Input, InputNumber, Progress, Select, Switch } from "antd";
|
||||
import { FileAudio, FileImage, Film, Grip, Play, RotateCcw, Square, Upload, WandSparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { runBackendGenerationTask, type BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { useConfigStore, type AiConfig, type RunningHubCapability, type WorkflowFieldMapping } from "@/stores/use-config-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
type WorkflowProvider = "runninghub" | "comfyui";
|
||||
type MediaKind = "image" | "video" | "audio";
|
||||
type Point = { x: number; y: number };
|
||||
type NodePositionMap = Record<string, Point>;
|
||||
type TestFile = { id: string; file: File; url: string };
|
||||
|
||||
type WorkflowTestWorkbenchProps = {
|
||||
provider: WorkflowProvider;
|
||||
workflowId: string;
|
||||
workflowKind?: "workflow" | "app";
|
||||
title: string;
|
||||
capability: RunningHubCapability;
|
||||
fields: WorkflowFieldMapping[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
};
|
||||
|
||||
const initialPositions: NodePositionMap = {
|
||||
prompt: { x: 36, y: 46 },
|
||||
image: { x: 36, y: 244 },
|
||||
video: { x: 36, y: 410 },
|
||||
audio: { x: 36, y: 576 },
|
||||
workflow: { x: 382, y: 132 },
|
||||
output: { x: 758, y: 188 },
|
||||
};
|
||||
|
||||
export function WorkflowTestWorkbench({ provider, workflowId, workflowKind = "workflow", title, capability, fields, disabled = false, disabledReason }: WorkflowTestWorkbenchProps) {
|
||||
const { message } = App.useApp();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [files, setFiles] = useState<Record<MediaKind, TestFile[]>>({ image: [], video: [], audio: [] });
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, unknown>>(() => initialFieldValues(fields, config));
|
||||
const [positions, setPositions] = useState<NodePositionMap>(initialPositions);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [stage, setStage] = useState("等待运行");
|
||||
const [result, setResult] = useState<BackendGenerationResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const autoOutputXRef = useRef(initialPositions.output.x);
|
||||
const [canvasWidth, setCanvasWidth] = useState(1040);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const filesRef = useRef(files);
|
||||
|
||||
const activeFields = useMemo(() => fields.filter((field) => field.enabled !== false), [fields]);
|
||||
const slots = useMemo(() => mediaSlotCounts(activeFields), [activeFields]);
|
||||
const imageReferenceCount = useMemo(() => referenceImageSlotCount(activeFields), [activeFields]);
|
||||
const maskEnabled = useMemo(() => activeFields.some((field) => field.source === "mask"), [activeFields]);
|
||||
const parameterFields = useMemo(() => uniqueParameterFields(activeFields), [activeFields]);
|
||||
const visibleMediaKinds = (Object.keys(slots) as MediaKind[]).filter((kind) => slots[kind] > 0);
|
||||
const resultUrls = generationResultUrls(result, capability);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValues(initialFieldValues(fields, config));
|
||||
setResult(null);
|
||||
setError("");
|
||||
setProgress(0);
|
||||
setStage("等待运行");
|
||||
}, [workflowId, workflowKind, fields, config.size, config.quality, config.vquality]);
|
||||
|
||||
useEffect(() => {
|
||||
filesRef.current = files;
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const scroll = canvas?.parentElement;
|
||||
if (!scroll) return;
|
||||
const measure = () => setCanvasWidth(Math.max(1040, scroll.clientWidth));
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(scroll);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const nextOutputX = Math.max(758, canvasWidth - 324 - 36);
|
||||
setPositions((current) => {
|
||||
// 用户手动拖动过输出节点后尊重其位置,不再被窗口尺寸变化覆盖。
|
||||
if (current.output.x !== autoOutputXRef.current || current.output.x === nextOutputX) return current;
|
||||
autoOutputXRef.current = nextOutputX;
|
||||
return { ...current, output: { ...current.output, x: nextOutputX } };
|
||||
});
|
||||
}, [canvasWidth]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
abortRef.current?.abort();
|
||||
Object.values(filesRef.current)
|
||||
.flat()
|
||||
.forEach((item) => URL.revokeObjectURL(item.url));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const replaceFiles = (kind: MediaKind, selected: FileList | null) => {
|
||||
if (!selected?.length) return;
|
||||
const limit = Math.max(1, slots[kind]);
|
||||
const next = Array.from(selected)
|
||||
.slice(0, limit)
|
||||
.map((file) => ({ id: crypto.randomUUID(), file, url: URL.createObjectURL(file) }));
|
||||
setFiles((current) => {
|
||||
current[kind].forEach((item) => URL.revokeObjectURL(item.url));
|
||||
return { ...current, [kind]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const resetTest = () => {
|
||||
Object.values(files)
|
||||
.flat()
|
||||
.forEach((item) => URL.revokeObjectURL(item.url));
|
||||
setFiles({ image: [], video: [], audio: [] });
|
||||
setPrompt("");
|
||||
setFieldValues(initialFieldValues(fields, config));
|
||||
const nextOutputX = Math.max(758, canvasWidth - 324 - 36);
|
||||
autoOutputXRef.current = nextOutputX;
|
||||
setPositions({ ...initialPositions, output: { ...initialPositions.output, x: nextOutputX } });
|
||||
setResult(null);
|
||||
setError("");
|
||||
setProgress(0);
|
||||
setStage("等待运行");
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
if (disabled) return message.warning(disabledReason || "请先完成工作流配置");
|
||||
const missing = missingRequiredInput(activeFields, prompt, files, fieldValues);
|
||||
if (missing) return message.warning(missing);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
setError("");
|
||||
setProgress(0);
|
||||
setStage("正在提交任务");
|
||||
try {
|
||||
const testConfig = buildTestConfig(config, provider, workflowId, workflowKind, activeFields, fieldValues);
|
||||
const response = await runBackendGenerationTask({
|
||||
mode: capability,
|
||||
prompt,
|
||||
config: testConfig,
|
||||
referenceImages: files.image.slice(0, imageReferenceCount).map(toReferenceImage),
|
||||
referenceVideos: files.video.map(toReferenceVideo),
|
||||
referenceAudios: files.audio.map(toReferenceAudio),
|
||||
mask: maskEnabled && files.image[imageReferenceCount] ? toReferenceImage(files.image[imageReferenceCount]) : undefined,
|
||||
signal: controller.signal,
|
||||
metadata: { source: "workflow_settings_test", workflowProvider: provider, workflowId },
|
||||
onTaskUpdate: (task) => {
|
||||
setProgress(Math.max(0, Math.min(100, Number(task.progress) || 0)));
|
||||
setStage(taskStageLabel(task.status, task.stage));
|
||||
},
|
||||
});
|
||||
setResult(response);
|
||||
setProgress(100);
|
||||
setStage("测试完成");
|
||||
message.success("工作流测试完成");
|
||||
} catch (reason) {
|
||||
if (controller.signal.aborted) {
|
||||
setStage("已停止等待");
|
||||
return;
|
||||
}
|
||||
const detail = generationErrorMessage(reason);
|
||||
setError(detail);
|
||||
setStage("测试失败");
|
||||
message.error(detail);
|
||||
} finally {
|
||||
if (abortRef.current === controller) abortRef.current = null;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopTest = () => {
|
||||
abortRef.current?.abort();
|
||||
setStage("正在停止等待");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workflow-test-workbench">
|
||||
<div className="workflow-test-toolbar">
|
||||
<div className="min-w-0">
|
||||
<strong>{title || workflowId || "未选择工作流"}</strong>
|
||||
<span>
|
||||
{provider === "runninghub" ? "RunningHub" : "ComfyUI Bridge"} · {capabilityName(capability)}工作流 · 测试值不会覆盖保存配置
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-test-toolbar-actions">
|
||||
<Button icon={<RotateCcw className="size-4" />} disabled={running} onClick={resetTest}>
|
||||
重置测试
|
||||
</Button>
|
||||
{running ? (
|
||||
<Button danger icon={<Square className="size-4" />} onClick={stopTest}>
|
||||
停止等待
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<Play className="size-4" />} disabled={disabled} onClick={() => void runTest()}>
|
||||
运行测试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{disabled && disabledReason ? <div className="workflow-test-notice">{disabledReason}</div> : null}
|
||||
<div className="workflow-test-canvas-scroll">
|
||||
<div ref={canvasRef} className="workflow-test-canvas" data-canvas-no-zoom>
|
||||
<svg className="workflow-test-lines" aria-hidden="true" viewBox={`0 0 ${canvasWidth} 760`} preserveAspectRatio="none">
|
||||
<WorkflowLine from={positions.prompt} to={positions.workflow} fromWidth={284} toOffsetY={74} />
|
||||
{visibleMediaKinds.map((kind, index) => (
|
||||
<WorkflowLine key={kind} from={positions[kind]} to={positions.workflow} fromWidth={284} fromOffsetY={64} toOffsetY={112 + index * 26} />
|
||||
))}
|
||||
<WorkflowLine from={positions.workflow} to={positions.output} fromWidth={324} fromOffsetY={84} toOffsetY={70} />
|
||||
</svg>
|
||||
|
||||
<WorkflowNode id="prompt" title="提示词" icon={<WandSparkles />} position={positions.prompt} onMove={(point) => setPositions((current) => ({ ...current, prompt: point }))}>
|
||||
<Input.TextArea value={prompt} autoSize={{ minRows: 4, maxRows: 7 }} placeholder="输入本次测试提示词" onChange={(event) => setPrompt(event.target.value)} />
|
||||
</WorkflowNode>
|
||||
|
||||
{visibleMediaKinds.map((kind) => (
|
||||
<WorkflowNode
|
||||
key={kind}
|
||||
id={kind}
|
||||
title={`${kind === "image" && maskEnabled ? "图片 / 蒙版" : mediaName(kind)}素材 · ${slots[kind]} 个槽位`}
|
||||
icon={mediaIcon(kind)}
|
||||
position={positions[kind]}
|
||||
onMove={(point) => setPositions((current) => ({ ...current, [kind]: point }))}
|
||||
>
|
||||
<MediaPicker kind={kind} limit={slots[kind]} files={files[kind]} maskIndex={kind === "image" && maskEnabled ? imageReferenceCount : undefined} onChange={(selected) => replaceFiles(kind, selected)} />
|
||||
</WorkflowNode>
|
||||
))}
|
||||
|
||||
<WorkflowNode id="workflow" title={title || "工作流动态参数"} icon={<Grip />} position={positions.workflow} wide onMove={(point) => setPositions((current) => ({ ...current, workflow: point }))}>
|
||||
<div className="workflow-test-parameters">
|
||||
{parameterFields.length ? (
|
||||
parameterFields.map((field) => {
|
||||
const key = testFieldKey(field);
|
||||
return <WorkflowParameter key={key} field={field} value={fieldValues[key]} onChange={(value) => setFieldValues((current) => ({ ...current, [key]: value }))} />;
|
||||
})
|
||||
) : (
|
||||
<span className="workflow-test-empty-text">此工作流没有额外动态参数</span>
|
||||
)}
|
||||
</div>
|
||||
</WorkflowNode>
|
||||
|
||||
<WorkflowNode
|
||||
id="output"
|
||||
title={`${capabilityName(capability)}输出`}
|
||||
icon={capability === "image" ? <FileImage /> : capability === "video" ? <Film /> : <FileAudio />}
|
||||
position={positions.output}
|
||||
onMove={(point) => setPositions((current) => ({ ...current, output: point }))}
|
||||
>
|
||||
<div className="workflow-test-output">{resultUrls.length ? <ResultPreview capability={capability} urls={resultUrls} /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={error || stage} />}</div>
|
||||
<Progress percent={progress} size="small" status={error ? "exception" : running ? "active" : undefined} showInfo={running || progress > 0} />
|
||||
</WorkflowNode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowNode({ id, title, icon, position, onMove, wide = false, children }: { id: string; title: string; icon: ReactNode; position: Point; onMove: (point: Point) => void; wide?: boolean; children: ReactNode }) {
|
||||
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
|
||||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget.parentElement;
|
||||
const canvas = target?.parentElement;
|
||||
if (!target || !canvas) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return;
|
||||
const canvas = event.currentTarget.parentElement?.parentElement;
|
||||
const node = event.currentTarget.parentElement;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = wide ? 324 : 284;
|
||||
const height = node?.offsetHeight || 92;
|
||||
onMove({ x: Math.max(12, Math.min(rect.width - width - 12, event.clientX - rect.left - dragRef.current.offsetX)), y: Math.max(12, Math.min(rect.height - height - 12, event.clientY - rect.top - dragRef.current.offsetY)) });
|
||||
};
|
||||
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
dragRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
return (
|
||||
<section className={`workflow-test-node${wide ? " is-wide" : ""}`} style={{ transform: `translate(${position.x}px, ${position.y}px)` }} data-node-id={id}>
|
||||
<div className="workflow-test-node-header" onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={finishDrag} onPointerCancel={finishDrag}>
|
||||
<span>{icon}</span>
|
||||
<strong title={title}>{title}</strong>
|
||||
<Grip className="workflow-test-node-grip" />
|
||||
</div>
|
||||
<div className="workflow-test-node-body">{children}</div>
|
||||
<i className="workflow-test-port is-input" />
|
||||
<i className="workflow-test-port is-output" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowLine({ from, to, fromWidth, fromOffsetY = 54, toOffsetY }: { from: Point; to: Point; fromWidth: number; fromOffsetY?: number; toOffsetY: number }) {
|
||||
const x1 = from.x + fromWidth;
|
||||
const y1 = from.y + fromOffsetY;
|
||||
const x2 = to.x;
|
||||
const y2 = to.y + toOffsetY;
|
||||
const curve = Math.max(52, Math.abs(x2 - x1) * 0.46);
|
||||
return <path d={`M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`} />;
|
||||
}
|
||||
|
||||
function MediaPicker({ kind, limit, files, maskIndex, onChange }: { kind: MediaKind; limit: number; files: TestFile[]; maskIndex?: number; onChange: (files: FileList | null) => void }) {
|
||||
return (
|
||||
<label className="workflow-test-upload">
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
multiple={limit > 1}
|
||||
accept={kind === "image" ? "image/*" : kind === "video" ? "video/*" : "audio/*"}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
{files.length ? (
|
||||
<div className="workflow-test-file-list">
|
||||
{files.map((item, index) => (
|
||||
<span key={item.id} title={item.file.name}>
|
||||
{kind === "image" ? <img src={item.url} alt="" /> : mediaIcon(kind)}
|
||||
<b>{maskIndex === index ? "蒙版" : index + 1}</b>
|
||||
<em>{item.file.name}</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
<Upload />
|
||||
选择{mediaName(kind)}(最多 {limit} 个)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowParameter({ field, value, onChange }: { field: WorkflowFieldMapping; value: unknown; onChange: (value: unknown) => void }) {
|
||||
const label = field.label || field.fieldName;
|
||||
const type = String(field.fieldType || "").toUpperCase();
|
||||
if (field.randomEnabled && !field.source)
|
||||
return (
|
||||
<label>
|
||||
<span title={`${field.nodeId}.${field.fieldName}`}>
|
||||
{label}
|
||||
<small>
|
||||
{field.nodeId}.{field.fieldName}
|
||||
</small>
|
||||
</span>
|
||||
<Input value="每次运行随机生成" disabled />
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<label>
|
||||
<span title={`${field.nodeId}.${field.fieldName}`}>
|
||||
{label}
|
||||
<small>
|
||||
{field.nodeId}.{field.fieldName}
|
||||
</small>
|
||||
</span>
|
||||
{Array.isArray(field.options) && field.options.length ? (
|
||||
<Select showSearch value={value} options={field.options.map((option) => ({ label: String(option), value: option }))} onChange={onChange} />
|
||||
) : type === "BOOLEAN" || typeof value === "boolean" ? (
|
||||
<Switch checked={value === true || value === "true"} onChange={onChange} />
|
||||
) : type === "NUMBER" || typeof value === "number" ? (
|
||||
<InputNumber className="w-full" value={numberValue(value)} min={numberValue(field.min)} max={numberValue(field.max)} step={numberValue(field.step)} onChange={onChange} />
|
||||
) : (
|
||||
<Input value={displayValue(value)} onChange={(event) => onChange(event.target.value)} />
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPreview({ capability, urls }: { capability: RunningHubCapability; urls: string[] }) {
|
||||
if (capability === "video") return <video src={urls[0]} controls playsInline />;
|
||||
if (capability === "audio") return <audio src={urls[0]} controls />;
|
||||
return (
|
||||
<div className="workflow-test-image-results">
|
||||
{urls.map((url) => (
|
||||
<img key={url} src={url} alt="工作流测试输出" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 测试参数只写入本次任务配置副本,正式工作流仍由设置页的显式保存动作维护。
|
||||
function buildTestConfig(config: AiConfig, provider: WorkflowProvider, workflowId: string, workflowKind: "workflow" | "app", fields: WorkflowFieldMapping[], values: Record<string, unknown>): AiConfig {
|
||||
const patchedFields = fields.map((field) => {
|
||||
const value = values[testFieldKey(field)];
|
||||
return value === undefined ? { ...field } : { ...field, fieldValue: value };
|
||||
});
|
||||
const next: AiConfig = { ...config, taskWorkflowProvider: provider };
|
||||
applySourceValues(next, fields, values);
|
||||
if (provider === "runninghub") {
|
||||
next.runningHub = {
|
||||
...config.runningHub,
|
||||
enabled: true,
|
||||
workflowId,
|
||||
selectedKind: workflowKind,
|
||||
workflows: config.runningHub.workflows.map((item) => (item.workflowId.trim() === workflowId.trim() && (item.kind === "app" ? "app" : "workflow") === workflowKind ? { ...item, fields: patchedFields } : item)),
|
||||
};
|
||||
} else {
|
||||
next.comfyBridge = { ...config.comfyBridge, enabled: true, workflowId, workflows: config.comfyBridge.workflows.map((item) => (item.workflowId.trim() === workflowId.trim() ? { ...item, fields: patchedFields } : item)) };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applySourceValues(config: AiConfig, fields: WorkflowFieldMapping[], values: Record<string, unknown>) {
|
||||
const assigned = new Set<string>();
|
||||
fields.forEach((field) => {
|
||||
const source = String(field.source || "");
|
||||
if (!source || assigned.has(source) || isPromptOrMediaSource(source)) return;
|
||||
assigned.add(source);
|
||||
const value = values[testFieldKey(field)];
|
||||
if (source === "size" || source === "aspectRatio") config.size = String(value || config.size);
|
||||
else if (source === "width" || source === "height") {
|
||||
const width = String(values[sourceValueKey(fields, "width")] || dimensionFromSize(config.size, 0));
|
||||
const height = String(values[sourceValueKey(fields, "height")] || dimensionFromSize(config.size, 1));
|
||||
if (width && height) config.size = `${width}x${height}`;
|
||||
} else if (source in config && typeof config[source as keyof AiConfig] === "string") {
|
||||
(config as unknown as Record<string, unknown>)[source] = String(value ?? "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initialFieldValues(fields: WorkflowFieldMapping[], config: AiConfig) {
|
||||
const values: Record<string, unknown> = {};
|
||||
fields.forEach((field) => {
|
||||
if (isPromptOrMediaSource(String(field.source || ""))) return;
|
||||
const source = String(field.source || "");
|
||||
const workflowValue = field.fieldValue ?? field.value ?? field.defaultValue ?? field.default;
|
||||
const configValue = source === "aspectRatio" ? config.size : source === "width" ? dimensionFromSize(config.size, 0) : source === "height" ? dimensionFromSize(config.size, 1) : (config as unknown as Record<string, unknown>)[source];
|
||||
values[testFieldKey(field)] = workflowValue !== undefined && workflowValue !== "" ? workflowValue : configValue !== undefined && configValue !== "" ? configValue : "";
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function uniqueParameterFields(fields: WorkflowFieldMapping[]) {
|
||||
const keys = new Set<string>();
|
||||
return fields.filter((field) => {
|
||||
const source = String(field.source || "");
|
||||
if (isPromptOrMediaSource(source)) return false;
|
||||
const key = testFieldKey(field);
|
||||
if (keys.has(key)) return false;
|
||||
keys.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function testFieldKey(field: WorkflowFieldMapping) {
|
||||
const source = String(field.source || "");
|
||||
return source ? `source:${source}` : `field:${field.nodeId}:${field.fieldName}`;
|
||||
}
|
||||
|
||||
function sourceValueKey(fields: WorkflowFieldMapping[], source: string) {
|
||||
const field = fields.find((item) => item.source === source);
|
||||
return field ? testFieldKey(field) : `source:${source}`;
|
||||
}
|
||||
|
||||
function mediaSlotCounts(fields: WorkflowFieldMapping[]) {
|
||||
const result: Record<MediaKind, number> = { image: 0, video: 0, audio: 0 };
|
||||
const imageReferences = referenceImageSlotCount(fields);
|
||||
const hasMask = fields.some((field) => field.source === "mask");
|
||||
result.image = imageReferences + (hasMask ? 1 : 0);
|
||||
fields.forEach((field) => {
|
||||
const source = String(field.source || "");
|
||||
if (source === "referenceVideo") result.video = Math.max(result.video, Number(field.sourceIndex) + 1 || 1);
|
||||
if (source === "referenceAudio") result.audio = Math.max(result.audio, Number(field.sourceIndex) + 1 || 1);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function missingRequiredInput(fields: WorkflowFieldMapping[], prompt: string, files: Record<MediaKind, TestFile[]>, values: Record<string, unknown>) {
|
||||
if (!prompt.trim()) return "请填写测试提示词";
|
||||
const imageReferences = referenceImageSlotCount(fields);
|
||||
for (const field of fields) {
|
||||
if (!field.required) continue;
|
||||
const source = String(field.source || "");
|
||||
if (source === "prompt" && !prompt.trim()) return "请填写测试提示词";
|
||||
const kind = source === "referenceImage" || source === "mask" ? "image" : source === "referenceVideo" ? "video" : source === "referenceAudio" ? "audio" : null;
|
||||
const index = source === "mask" ? imageReferences : source === "referenceImage" ? Math.max(0, (Number(field.imageOrder) || 1) - 1) : Math.max(0, Number(field.sourceIndex) || 0);
|
||||
if (kind && !files[kind][index]) return `请上传第 ${index + 1} 个${mediaName(kind)}素材`;
|
||||
if (!source && !field.randomEnabled && isEmptyTestValue(values[testFieldKey(field)])) return `请填写动态参数:${field.label || field.fieldName}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function referenceImageSlotCount(fields: WorkflowFieldMapping[]) {
|
||||
return fields.reduce((count, field) => (field.source === "referenceImage" ? Math.max(count, Number(field.imageOrder) || Number(field.sourceIndex) + 1 || 1) : count), 0);
|
||||
}
|
||||
|
||||
function isPromptOrMediaSource(source: string) {
|
||||
return ["prompt", "referenceImage", "referenceVideo", "referenceAudio", "mask"].includes(source);
|
||||
}
|
||||
|
||||
function generationResultUrls(result: BackendGenerationResult | null, capability: RunningHubCapability) {
|
||||
const media = capability === "image" ? result?.images || [] : capability === "video" ? (result?.video ? [result.video] : []) : result?.audio ? [result.audio] : [];
|
||||
return media
|
||||
.map((item) => {
|
||||
const resourceId = resourceIdFromStorageKey(item.storageKey);
|
||||
return resourceId ? resourceFileUrl(resourceId) : item.dataUrl || "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toReferenceImage(item: TestFile): ReferenceImage {
|
||||
return { id: item.id, name: item.file.name, type: item.file.type, dataUrl: item.url, bytes: item.file.size };
|
||||
}
|
||||
function toReferenceVideo(item: TestFile): ReferenceVideo {
|
||||
return { id: item.id, name: item.file.name, type: item.file.type, url: item.url, bytes: item.file.size };
|
||||
}
|
||||
function toReferenceAudio(item: TestFile): ReferenceAudio {
|
||||
return { id: item.id, name: item.file.name, type: item.file.type, url: item.url, bytes: item.file.size };
|
||||
}
|
||||
function dimensionFromSize(size: string, index: number) {
|
||||
const normalized = size.toLowerCase().trim();
|
||||
const pixels = normalized.split("x");
|
||||
if (pixels.length === 2 && pixels.every((item) => Number(item.trim()) > 0)) return pixels[index]?.trim() || "";
|
||||
const ratio = normalized
|
||||
.replace(/-(1k|2k|4k)$/, "")
|
||||
.split(":")
|
||||
.map(Number);
|
||||
if (ratio.length !== 2 || ratio.some((item) => !Number.isFinite(item) || item <= 0)) return "";
|
||||
const longEdgeTier = normalized.endsWith("-4k") ? 3840 : normalized.endsWith("-2k") ? 2048 : 0;
|
||||
const dimensions =
|
||||
longEdgeTier > 0
|
||||
? ratio[0] >= ratio[1]
|
||||
? [longEdgeTier, Math.round((longEdgeTier * ratio[1]) / ratio[0])]
|
||||
: [Math.round((longEdgeTier * ratio[0]) / ratio[1]), longEdgeTier]
|
||||
: ratio[0] >= ratio[1]
|
||||
? [Math.round((1024 * ratio[0]) / ratio[1]), 1024]
|
||||
: [1024, Math.round((1024 * ratio[1]) / ratio[0])];
|
||||
return String(dimensions[index]);
|
||||
}
|
||||
function isEmptyTestValue(value: unknown) {
|
||||
return value === undefined || value === null || (typeof value === "string" && !value.trim());
|
||||
}
|
||||
function numberValue(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
function displayValue(value: unknown) {
|
||||
return value === undefined || value === null ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
function capabilityName(value: RunningHubCapability) {
|
||||
return value === "video" ? "视频" : value === "audio" ? "音频" : "图片";
|
||||
}
|
||||
function mediaName(value: MediaKind) {
|
||||
return value === "video" ? "视频" : value === "audio" ? "音频" : "图片";
|
||||
}
|
||||
function mediaIcon(value: MediaKind) {
|
||||
return value === "video" ? <Film /> : value === "audio" ? <FileAudio /> : <FileImage />;
|
||||
}
|
||||
function taskStageLabel(status: string, stage?: string) {
|
||||
if (status === "queued") return "任务已排队";
|
||||
if (status === "succeeded") return "正在读取结果";
|
||||
if (status === "failed") return "任务失败";
|
||||
return stage ? `运行中 · ${stage}` : "工作流运行中";
|
||||
}
|
||||
Reference in New Issue
Block a user