Files
ttstd-infinite-canvas/web/src/components/workflow-test-workbench.tsx
2026-09-14 01:11:56 +08:00

570 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Input, InputNumber, Progress, Select } from "antd";
import { Switch } from "@/components/ui/base/switch";
import { EmptyState } from "@/components/ui/product/empty-state";
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";
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>
RunningHub · {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} /> : <EmptyState size="compact" title={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)),
};
}
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}` : "工作流运行中";
}