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; 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>({ image: [], video: [], audio: [] }); const [fieldValues, setFieldValues] = useState>(() => initialFieldValues(fields, config)); const [positions, setPositions] = useState(initialPositions); const [running, setRunning] = useState(false); const [progress, setProgress] = useState(0); const [stage, setStage] = useState("等待运行"); const [result, setResult] = useState(null); const [error, setError] = useState(""); const canvasRef = useRef(null); const autoOutputXRef = useRef(initialPositions.output.x); const [canvasWidth, setCanvasWidth] = useState(1040); const abortRef = useRef(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 (
{title || workflowId || "未选择工作流"} {provider === "runninghub" ? "RunningHub" : "ComfyUI Bridge"} · {capabilityName(capability)}工作流 · 测试值不会覆盖保存配置
{running ? ( ) : ( )}
{disabled && disabledReason ?
{disabledReason}
: null}
} position={positions.prompt} onMove={(point) => setPositions((current) => ({ ...current, prompt: point }))}> setPrompt(event.target.value)} /> {visibleMediaKinds.map((kind) => ( setPositions((current) => ({ ...current, [kind]: point }))} > replaceFiles(kind, selected)} /> ))} } position={positions.workflow} wide onMove={(point) => setPositions((current) => ({ ...current, workflow: point }))}>
{parameterFields.length ? ( parameterFields.map((field) => { const key = testFieldKey(field); return setFieldValues((current) => ({ ...current, [key]: value }))} />; }) ) : ( 此工作流没有额外动态参数 )}
: capability === "video" ? : } position={positions.output} onMove={(point) => setPositions((current) => ({ ...current, output: point }))} >
{resultUrls.length ? : }
0} />
); } 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) => { 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) => { 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) => { dragRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; return (
{icon} {title}
{children}
); } 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 ; } function MediaPicker({ kind, limit, files, maskIndex, onChange }: { kind: MediaKind; limit: number; files: TestFile[]; maskIndex?: number; onChange: (files: FileList | null) => void }) { return ( ); } 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 ( ); return ( ); } function ResultPreview({ capability, urls }: { capability: RunningHubCapability; urls: string[] }) { if (capability === "video") return