Files
ttstd-infinite-canvas/web/src/pages/admin/logical-models/model-routing-capabilities.tsx
ddCat 9ce158aaa0 refactor(admin): 管理后台 UI - 独立设计系统并与用户端隔离
把后台 token、Ant 主题、壳层控件和抽屉弹窗从用户工作台拆开,去掉树状侧栏装饰线,色板仍用原来的中性黑白台阶。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 21:29:35 +08:00

767 lines
39 KiB
TypeScript

import { Button, InputNumber, Select, Tag } from "antd";
import { Switch } from "@/pages/admin/ui/controls";
import { Callout } from "@/pages/admin/ui/controls";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import type { CapabilityImageSize, CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
import type { ChannelModel } from "@/services/api/wallet";
import { STANDARD_IMAGE_SIZE_VALUES } from "@/lib/model-capabilities";
export type CapabilityKind = CapabilitySpec["capability"];
export type Scalar = string | number | boolean;
type CapabilityScopeEditorProps = {
capability: CapabilityKind;
sourceSpecs: CapabilitySpec[];
value?: CapabilitySpec;
onChange?: (value: CapabilitySpec) => void;
mode: "front" | "route";
};
const inputDefinitions: Record<CapabilityKind, Array<{ name: string; label: string; unit: string }>> = {
text: [
{ name: "image", label: "参考图片", unit: "张" },
{ name: "video", label: "参考视频", unit: "个" },
],
image: [
{ name: "image", label: "参考图片", unit: "张" },
{ name: "mask", label: "蒙版", unit: "张" },
],
video: [
{ name: "image", label: "参考图片", unit: "张" },
{ name: "video", label: "参考视频", unit: "个" },
{ name: "audio", label: "参考音频", unit: "个" },
],
audio: [],
};
const optionDefinitions: Record<CapabilityKind, Array<{ name: string; label: string; unit?: string }>> = {
text: [],
image: [
{ name: "size", label: "画面尺寸或比例" },
{ name: "quality", label: "生成质量" },
{ name: "transparentBackground", label: "透明背景" },
{ name: "count", label: "输出数量", unit: "张" },
],
video: [
{ name: "size", label: "画面比例" },
{ name: "videoSeconds", label: "视频时长", unit: "秒" },
{ name: "vquality", label: "输出分辨率" },
{ name: "videoGenerateAudio", label: "同步生成音频" },
{ name: "videoWatermark", label: "输出水印" },
],
audio: [
{ name: "audioVoice", label: "音色" },
{ name: "audioFormat", label: "音频格式" },
{ name: "audioSpeed", label: "语速", unit: "倍" },
{ name: "audioInstructions", label: "朗读指令" },
],
};
export function capabilityLabel(value: CapabilityKind) {
return { text: "文本", image: "图片", video: "视频", audio: "音频" }[value];
}
export function emptyCapabilitySpec(capability: CapabilityKind): CapabilitySpec {
return { version: 1, capability, operations: [], inputs: {}, options: {} };
}
function imageSizeOptionValues(values: string[], allowCustom: boolean) {
const concreteValues = Array.from(new Set(values.map((value) => String(normalizeScalar(value)).trim()).filter((value) => value && value !== "*")));
const result = concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
if (allowCustom) result.push("*");
return result;
}
export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilitySpec | null {
if (!item?.capability) return null;
const capability = item.capability;
if (capability === "text") {
const text = item.capabilityConfig?.text;
if (!text) return null;
return {
version: 1,
capability,
inputs: compactInputs({
image: { min: 0, max: text.references.maxImages },
video: { min: 0, max: text.references.maxVideos },
}),
options: {},
};
}
if (capability === "image") {
const image = item.capabilityConfig?.image;
if (!image) return null;
return {
version: 1,
capability,
inputs: compactInputs({
image: { min: 0, max: image.references.maxImages },
mask: { min: 0, max: image.references.maskSupported ? 1 : 0 },
}),
options: compactOptions({
size: image.size.parameter === "none" ? undefined : { values: imageSizeOptionValues(image.size.values, image.size.allowCustom) },
quality: image.quality.supported ? { values: image.quality.values } : undefined,
transparentBackground: { values: image.transparentBackground.supported ? [false, true] : [false] },
count: { min: 1, max: image.maxOutputs, step: 1 },
}),
imageSize: image.size.parameter === "none" ? undefined : { parameter: image.size.parameter, allowCustom: image.size.allowCustom, presets: image.size.presets },
};
}
if (capability === "video") {
const video = item.capabilityConfig?.video;
if (!video) return null;
const duration: OptionConstraint = video.duration.selection === "enum" ? { values: video.duration.values || [] } : { min: video.duration.min, max: video.duration.max, step: video.duration.step };
return {
version: 1,
capability,
operations: video.operations,
inputs: compactInputs({
image: { min: video.references.minImages, max: video.references.maxImages },
video: { min: 0, max: video.references.maxVideos },
audio: { min: 0, max: video.references.maxAudios },
}),
options: compactOptions({
videoSeconds: duration,
size: { values: video.ratios },
vquality: video.resolutions.length ? { values: video.resolutions } : undefined,
videoGenerateAudio: { values: video.generateAudio.supported ? [false, true] : [false] },
videoWatermark: { values: video.watermark.supported ? [false, true] : [false] },
}),
};
}
return emptyCapabilitySpec("audio");
}
export function mergeCapabilitySpecs(capability: CapabilityKind, specs: CapabilitySpec[]) {
const matching = specs.filter((item) => item.capability === capability);
if (matching.length === 0) return emptyCapabilitySpec(capability);
const result = emptyCapabilitySpec(capability);
result.operations = Array.from(new Set(matching.flatMap((item) => item.operations || [])));
for (const definition of inputDefinitions[capability]) {
const declared = matching.some((item) => item.inputs?.[definition.name]);
if (declared) {
const constraints = matching.map((item) => item.inputs?.[definition.name] || { min: 0, max: 0 });
result.inputs![definition.name] = widestCoveredInputRange(constraints);
}
}
for (const definition of optionDefinitions[capability]) {
const constraints = matching.map((item) => item.options?.[definition.name]).filter(Boolean) as OptionConstraint[];
if (constraints.length === 0) continue;
if (constraints.some(isWildcardConstraint)) {
const values = uniqueScalars(constraints.flatMap((item) => item.values || []));
result.options![definition.name] = { values: definition.name === "size" ? imageSizeOptionValues(values.map(String), true) : values };
continue;
}
if (constraints.every((item) => item.values)) {
const values = uniqueScalars(constraints.flatMap((item) => item.values || []));
result.options![definition.name] = { values: definition.name === "size" ? imageSizeOptionValues(values.map(String), values.includes("*")) : values };
continue;
}
const minimums = constraints.map((item) => item.min).filter((value): value is number => typeof value === "number");
const maximums = constraints.map((item) => item.max).filter((value): value is number => typeof value === "number");
const steps = constraints.map((item) => item.step).filter((value): value is number => typeof value === "number" && value > 0);
if (minimums.length && maximums.length) {
const candidate = { min: Math.min(...minimums), max: Math.max(...maximums), step: steps.length ? Math.min(...steps) : undefined };
if (optionConstraintCovered(candidate, definition.name, matching)) {
result.options![definition.name] = candidate;
continue;
}
}
const enumValues = uniqueScalars(constraints.flatMap((item) => item.values || []));
if (enumValues.length && optionConstraintCovered({ values: enumValues }, definition.name, matching)) {
result.options![definition.name] = { values: enumValues };
continue;
}
result.options![definition.name] = preferredSourceConstraint(constraints);
}
result.imageSize = mergeImageSize(matching);
return result;
}
function mergeImageSize(specs: CapabilitySpec[]): CapabilityImageSize | undefined {
const parts = specs.map((item) => item.imageSize).filter((item): item is CapabilityImageSize => Boolean(item));
if (!parts.length) return undefined;
const parameters = [...new Set(parts.map((item) => item.parameter).filter((value): value is NonNullable<CapabilityImageSize["parameter"]> => Boolean(value)))];
const presets = [...new Map(parts.flatMap((item) => item.presets || []).map((preset) => [`${preset.tier}:${preset.ratio}:${preset.size}`, preset])).values()];
return {
parameter: parameters.includes("aspect_ratio") ? "aspect_ratio" : parameters[0],
allowCustom: parts.some((item) => item.allowCustom),
presets,
};
}
/**
* 旧版本把“允许自定义”保存成只有 `*` 的能力范围。
* 管理端编辑时用当前供应线路的标准值补回展示范围,保存后即可完成数据修复。
*/
export function normalizeCapabilitySpecForSources(value: CapabilitySpec | undefined, sourceSpecs: CapabilitySpec[]) {
if (!value) return value;
const source = mergeCapabilitySpecs(value.capability, sourceSpecs);
const options = { ...(value.options || {}) };
for (const [name, constraint] of Object.entries(options)) {
const sourceConstraint = source.options?.[name];
if (!sourceConstraint || !isWildcardConstraint(constraint) || !sourceConstraint.values) continue;
options[name] = { values: uniqueScalars([...(sourceConstraint.values || []), ...(constraint.values || [])]) };
}
return { ...value, options, imageSize: value.imageSize?.presets?.length ? value.imageSize : source.imageSize || value.imageSize };
}
export function capabilitySourceError(capability: CapabilityKind, sourceSpecs: CapabilitySpec[], value?: CapabilitySpec) {
const matching = sourceSpecs.filter((item) => item.capability === capability);
const current = value?.capability === capability ? value : emptyCapabilitySpec(capability);
const currentOperations = current.operations || [];
if (!matching.length) return "没有可用的渠道模型能力来源";
if (!currentOperations.length && matching.every((item) => (item.operations || []).length > 0)) {
return "请选择至少一种供应线路支持的生成方式";
}
if (currentOperations.some((operation) => !matching.some((item) => !(item.operations || []).length || item.operations?.includes(operation)))) {
return "当前生成方式已不受供应线路支持";
}
for (const [name, constraint] of Object.entries(current.inputs || {})) {
if (!inputConstraintCovered(constraint, name, matching)) return `当前${inputLabel(capability, name)}范围存在供应线路无法覆盖的数量`;
}
for (const [name, constraint] of Object.entries(current.options || {})) {
if (!optionConstraintCovered(constraint, name, matching)) return `当前${optionLabel(capability, name)}范围存在供应线路无法覆盖的值`;
}
return undefined;
}
export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange }: CapabilityScopeEditorProps) {
const source = mergeCapabilitySpecs(capability, sourceSpecs);
const current = value?.capability === capability ? normalizeCapabilitySpecForSources(value, sourceSpecs)! : emptyCapabilitySpec(capability);
const hasSource = sourceSpecs.some((item) => item.capability === capability);
const sourceInputs = inputDefinitions[capability].filter((item) => source.inputs?.[item.name]);
const sourceOptions = optionDefinitions[capability].filter((item) => source.options?.[item.name]);
const sourceError = capabilitySourceError(capability, sourceSpecs, current);
const update = (patch: Partial<CapabilitySpec>) => onChange?.({ ...current, ...patch, version: 1, capability });
const reset = () => onChange?.(source);
if (!hasSource) return null;
return (
<div className="space-y-5">
<div className="flex justify-end">
<Button size="small" type="text" icon={<RotateCcw className="size-3.5" />} onClick={reset}>
恢复全部可用范围
</Button>
</div>
{sourceError ? <Callout tone="warning" title="当前选择需要调整">{`${sourceError}。可重新选择,或采用全部可用范围。`}</Callout> : null}
{source.operations?.length ? (
<CapabilityBlock title="生成方式">
<Select
className="w-full"
mode="multiple"
value={current.operations || []}
options={source.operations.map((operation) => ({ value: operation, label: operationLabel(operation) }))}
placeholder="选择创作端可用的生成方式"
onChange={(operations) => update({ operations })}
/>
</CapabilityBlock>
) : null}
{sourceInputs.length ? (
<CapabilityBlock title="参考素材">
<div className="divide-y divide-border">
{sourceInputs.map((definition) => {
const limit = source.inputs![definition.name];
const selected = current.inputs?.[definition.name];
return (
<div key={definition.name} className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
<div className="col-span-12 sm:col-span-5">
<div className="text-sm font-medium">{definition.label}</div>
<div className="text-xs text-foreground/45">
线路支持 {limit.min}-{limit.max} {definition.unit}
</div>
</div>
<div className="col-span-12 sm:col-span-1">
<Switch size="sm" checked={Boolean(selected)} onChange={(enabled) => updateInput(current, definition.name, enabled ? limit : undefined, update)} />
</div>
<div className="col-span-6 sm:col-span-3">
<NumberInput
label="最少"
unit={definition.unit}
value={selected?.min}
min={limit.min}
max={selected?.max ?? limit.max}
disabled={!selected}
onChange={(next) => updateInput(current, definition.name, { min: next ?? limit.min, max: selected?.max ?? limit.max }, update)}
/>
</div>
<div className="col-span-6 sm:col-span-3">
<NumberInput
label="最多"
unit={definition.unit}
value={selected?.max}
min={selected?.min ?? limit.min}
max={limit.max}
disabled={!selected}
onChange={(next) => updateInput(current, definition.name, { min: selected?.min ?? limit.min, max: next ?? limit.max }, update)}
/>
</div>
</div>
);
})}
</div>
</CapabilityBlock>
) : null}
{sourceOptions.length ? (
<CapabilityBlock title="生成参数">
<div className="divide-y divide-border">
{sourceOptions.map((definition) => (
<OptionRuleEditor
key={definition.name}
definition={definition}
source={source.options![definition.name]}
value={current.options?.[definition.name]}
onChange={(constraint) => updateOption(current, definition.name, constraint, update)}
/>
))}
</div>
</CapabilityBlock>
) : null}
{!sourceInputs.length && !sourceOptions.length && !source.operations?.length ? (
<Callout tone="info" title="该类型暂无额外能力参数">
线路仍可按优先级和权重参与路由。
</Callout>
) : null}
</div>
);
}
export function DefaultOptionsEditor({ spec, value, onChange }: { spec?: CapabilitySpec; value?: Record<string, unknown>; onChange?: (value: Record<string, Scalar>) => void }) {
const options = optionDefinitions[spec?.capability || "text"].filter((item) => spec?.options?.[item.name]);
if (!options.length) return <div className="rounded-md bg-muted/20 px-3 py-2 text-xs text-foreground/50">当前能力没有需要设置的默认参数。</div>;
return (
<div className="divide-y divide-border">
{options.map((definition) => {
const constraint = spec!.options![definition.name];
const selected = value?.[definition.name] as Scalar | undefined;
return (
<div key={definition.name} className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
<div className="col-span-12 sm:col-span-5">
<div className="text-sm font-medium">{definition.label}</div>
<div className="text-xs text-foreground/45">留空时由创作端或线路默认值决定</div>
</div>
<div className="col-span-12 sm:col-span-7">
<ConstraintValueInput
constraint={constraint}
value={selected}
allowClear
onChange={(next) => {
const defaults = { ...(value || {}) } as Record<string, Scalar>;
if (next === undefined) delete defaults[definition.name];
else defaults[definition.name] = next;
onChange?.(defaults);
}}
/>
</div>
</div>
);
})}
</div>
);
}
export function CapabilityRequestEditor({
spec,
inputs,
options,
onInputsChange,
onOptionsChange,
}: {
spec: CapabilitySpec;
inputs: Record<string, number>;
options: Record<string, unknown>;
onInputsChange: (value: Record<string, number>) => void;
onOptionsChange: (value: Record<string, unknown>) => void;
}) {
const inputItems = inputDefinitions[spec.capability].filter((item) => spec.inputs?.[item.name]);
const optionItems = optionDefinitions[spec.capability].filter((item) => spec.options?.[item.name]);
return (
<div className="space-y-4">
{inputItems.length ? (
<CapabilityBlock title="实际输入数量">
<div className="grid gap-2 sm:grid-cols-2">
{inputItems.map((definition) => {
const constraint = spec.inputs![definition.name];
return (
<NumberInput
key={definition.name}
label={definition.label}
unit={definition.unit}
value={inputs[definition.name] ?? constraint.min}
min={constraint.min}
max={constraint.max}
onChange={(next) => onInputsChange({ ...inputs, [definition.name]: next ?? constraint.min })}
/>
);
})}
</div>
</CapabilityBlock>
) : null}
{optionItems.length ? (
<CapabilityBlock title="请求参数">
<div className="grid gap-2 sm:grid-cols-2">
{optionItems.map((definition) => {
const constraint = spec.options![definition.name];
return (
<label key={definition.name} className="min-w-0">
<span className="mb-1 block text-xs text-foreground/45">
{definition.label}
{definition.unit ? ` (${definition.unit})` : ""}
</span>
<ConstraintValueInput
constraint={constraint}
value={options[definition.name] as Scalar | undefined}
allowClear
onChange={(next) => {
const result = { ...options };
if (next === undefined) delete result[definition.name];
else result[definition.name] = next;
onOptionsChange(result);
}}
/>
</label>
);
})}
</div>
</CapabilityBlock>
) : null}
{!inputItems.length && !optionItems.length ? <div className="rounded-md bg-muted/20 px-3 py-2 text-xs text-foreground/50">该模型没有额外请求参数。</div> : null}
</div>
);
}
export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
const labels: string[] = [];
for (const definition of inputDefinitions[spec.capability]) {
const input = spec.inputs?.[definition.name];
if (input) {
const minimum = Number.isFinite(input.min) ? input.min : 0;
const maximum = Number.isFinite(input.max) ? input.max : minimum;
labels.push(`${definition.label} ${minimum}-${maximum}${definition.unit}`);
}
}
for (const definition of optionDefinitions[spec.capability]) {
const option = spec.options?.[definition.name];
if (!option) continue;
labels.push(option.values ? `${definition.label} ${option.values.map(scalarLabel).join("/")}` : `${definition.label} ${option.min}-${option.max}${definition.unit || ""}`);
}
if (spec.operations?.length) labels.unshift(spec.operations.map(operationLabel).join("/"));
if (!labels.length) return <span className="text-xs text-foreground/45">基础能力</span>;
return (
<div className="flex min-w-0 max-w-full flex-wrap gap-1">
{labels.slice(0, 4).map((label) => (
<Tag key={label} className="admin-capability-summary-tag max-w-full whitespace-normal break-all text-left leading-5">
{label}
</Tag>
))}
{labels.length > 4 ? <Tag className="admin-capability-summary-tag shrink-0">+{labels.length - 4}</Tag> : null}
</div>
);
}
export function sanitizeDefaults(spec: CapabilitySpec, defaults: Record<string, unknown> | undefined) {
const result: Record<string, Scalar> = {};
for (const [name, rawValue] of Object.entries(defaults || {})) {
const constraint = spec.options?.[name];
if (!constraint || !constraintIncludes(constraint, rawValue)) continue;
// `*` 是能力匹配用的通配符,不是可发送给模型的默认参数。
if (scalarKey(rawValue) === "*") {
const concrete = constraint.values?.find((item) => scalarKey(item) !== "*");
if (concrete !== undefined) result[name] = normalizeScalar(concrete) as Scalar;
continue;
}
result[name] = normalizeScalar(rawValue) as Scalar;
}
return result;
}
function CapabilityBlock({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-2">
<div className="text-xs font-semibold text-foreground/65">{title}</div>
{children}
</section>
);
}
function OptionRuleEditor({ definition, source, value, onChange }: { definition: { name: string; label: string; unit?: string }; source: OptionConstraint; value?: OptionConstraint; onChange: (value?: OptionConstraint) => void }) {
return (
<div className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
<div className="col-span-12 sm:col-span-5">
<div className="text-sm font-medium">{definition.label}</div>
<div className="text-xs text-foreground/45">{constraintSummary(source, definition.unit)}</div>
</div>
<div className="col-span-12 sm:col-span-1">
<Switch size="sm" checked={Boolean(value)} onChange={(enabled) => onChange(enabled ? source : undefined)} />
</div>
<div className="col-span-12 sm:col-span-6">
{source.values ? (
<Select
className="w-full"
mode="multiple"
disabled={!value}
value={(value?.values || []).map(scalarKey)}
options={source.values.map((item) => ({ value: scalarKey(item), label: scalarLabel(item) }))}
onChange={(keys) => onChange({ values: keys.map((key) => normalizeScalar(source.values!.find((item) => scalarKey(item) === key)) as Scalar) })}
/>
) : (
<div className="grid grid-cols-3 gap-2">
<label>
<span className="mb-1 block text-xs text-foreground/45">最小值</span>
<InputNumber
className="w-full"
disabled={!value}
min={source.min}
max={value?.max ?? source.max}
value={value?.min}
onChange={(next) => onChange({ ...value, min: next ?? source.min, max: value?.max ?? source.max, step: value?.step ?? source.step })}
/>
</label>
<label>
<span className="mb-1 block text-xs text-foreground/45">最大值</span>
<InputNumber
className="w-full"
disabled={!value}
min={value?.min ?? source.min}
max={source.max}
value={value?.max}
onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: next ?? source.max, step: value?.step ?? source.step })}
/>
</label>
<label>
<span className="mb-1 block text-xs text-foreground/45">调整步长</span>
<InputNumber
className="w-full"
disabled={!value}
min={source.step || 0.000001}
max={source.max}
value={value?.step}
onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: value?.max ?? source.max, step: next ?? source.step })}
/>
</label>
</div>
)}
</div>
</div>
);
}
function ConstraintValueInput({ constraint, value, allowClear, onChange }: { constraint: OptionConstraint; value?: Scalar; allowClear?: boolean; onChange: (value?: Scalar) => void }) {
if (constraint.values) {
return (
<Select
className="w-full"
allowClear={allowClear}
value={value === undefined ? undefined : scalarKey(value)}
options={constraint.values.map((item) => ({ value: scalarKey(item), label: scalarLabel(item) }))}
onChange={(key) => onChange(key === undefined ? undefined : (normalizeScalar(constraint.values!.find((item) => scalarKey(item) === key)) as Scalar))}
/>
);
}
return <InputNumber className="w-full" min={constraint.min} max={constraint.max} step={constraint.step} value={typeof value === "number" ? value : undefined} onChange={(next) => onChange(next ?? undefined)} />;
}
function NumberInput({ label, unit, value, min, max, disabled, onChange }: { label: string; unit: string; value?: number; min?: number; max?: number; disabled?: boolean; onChange: (value: number | null) => void }) {
return (
<label className="min-w-0">
<span className="mb-1 block text-xs text-foreground/45">
{label} ({unit})
</span>
<InputNumber className="w-full" precision={0} disabled={disabled} value={value} min={min} max={max} onChange={onChange} />
</label>
);
}
function updateInput(current: CapabilitySpec, name: string, constraint: { min: number; max: number } | undefined, update: (patch: Partial<CapabilitySpec>) => void) {
const inputs = { ...(current.inputs || {}) };
if (constraint) inputs[name] = constraint;
else delete inputs[name];
update({ inputs });
}
function updateOption(current: CapabilitySpec, name: string, constraint: OptionConstraint | undefined, update: (patch: Partial<CapabilitySpec>) => void) {
const options = { ...(current.options || {}) };
if (constraint) options[name] = constraint;
else delete options[name];
update({ options });
}
function compactInputs(values: Record<string, { min: number; max: number }>) {
return Object.fromEntries(Object.entries(values).filter(([, constraint]) => constraint.max > 0 || constraint.min > 0));
}
function compactOptions(values: Record<string, OptionConstraint | undefined>) {
return Object.fromEntries(Object.entries(values).filter((entry): entry is [string, OptionConstraint] => Boolean(entry[1])));
}
function uniqueScalars(values: unknown[]) {
const byKey = new Map(values.map((value) => [scalarKey(value), value]));
return Array.from(byKey.values());
}
function widestCoveredInputRange(constraints: Array<{ min: number; max: number }>) {
const sorted = [...constraints].sort((left, right) => left.min - right.min || left.max - right.max);
const merged: Array<{ min: number; max: number }> = [];
for (const constraint of sorted) {
const previous = merged.at(-1);
if (previous && constraint.min <= previous.max + 1) previous.max = Math.max(previous.max, constraint.max);
else merged.push({ ...constraint });
}
return merged.reduce((best, current) => {
const bestSize = best.max - best.min;
const currentSize = current.max - current.min;
return currentSize > bestSize || (currentSize === bestSize && current.min < best.min) ? current : best;
});
}
function preferredSourceConstraint(constraints: OptionConstraint[]) {
return constraints.reduce((best, current) => (constraintBreadth(current) > constraintBreadth(best) ? current : best));
}
function constraintBreadth(constraint: OptionConstraint) {
if (constraint.values) return constraint.values.length;
if (constraint.min === undefined || constraint.max === undefined) return 0;
if (!constraint.step) return constraint.max - constraint.min + 1;
return Math.floor((constraint.max - constraint.min) / constraint.step + 1e-9) + 1;
}
function scalarKey(value: unknown) {
const normalized = normalizeScalar(value);
return `${typeof normalized}:${String(normalized)}`;
}
function scalarLabel(value: unknown) {
const normalized = normalizeScalar(value);
if (normalized === true) return "支持";
if (normalized === false) return "不启用";
if (normalized === "*") return "自定义";
return String(normalized);
}
function normalizeScalar(value: unknown) {
if (typeof value === "string" && value.startsWith("string:")) return value.slice("string:".length);
return value;
}
export function operationLabel(value: string) {
return (
{
text_to_video: "文生视频",
image_to_video: "图生视频",
reference_to_video: "全模态参考",
extend: "视频续写",
inpaint: "局部修改",
replace_element: "元素替换",
camera_motion: "运镜调整",
style_transfer: "风格迁移",
audio_to_video: "音频生视频",
}[value] || value
);
}
function inputLabel(capability: CapabilityKind, name: string) {
return inputDefinitions[capability].find((item) => item.name === name)?.label || name;
}
function optionLabel(capability: CapabilityKind, name: string) {
return optionDefinitions[capability].find((item) => item.name === name)?.label || name;
}
function constraintSummary(constraint: OptionConstraint, unit = "") {
if (constraint.values) return `线路支持 ${constraint.values.map(scalarLabel).join("、")}`;
return `线路支持 ${constraint.min}-${constraint.max}${unit}${constraint.step ? `,步进 ${constraint.step}` : ""}`;
}
function constraintIncludes(constraint: OptionConstraint, value: unknown) {
if (isWildcardConstraint(constraint)) return true;
if (constraint.values) return constraint.values.some((item) => scalarKey(item) === scalarKey(value));
if (typeof value !== "number") return false;
if ((constraint.min !== undefined && value < constraint.min - 1e-9) || (constraint.max !== undefined && value > constraint.max + 1e-9)) return false;
if (!constraint.step || constraint.min === undefined) return true;
return approximatelyInteger((value - constraint.min) / constraint.step);
}
function inputConstraintCovered(candidate: { min: number; max: number }, name: string, sourceSpecs: CapabilitySpec[]) {
let next = candidate.min;
while (next <= candidate.max) {
let coveredUntil = next - 1;
for (const spec of sourceSpecs) {
const constraint = spec.inputs?.[name] || { min: 0, max: 0 };
if (constraint.min <= next && constraint.max >= next && constraint.max > coveredUntil) coveredUntil = constraint.max;
}
if (coveredUntil < next) return false;
next = coveredUntil + 1;
}
return true;
}
function optionConstraintCovered(candidate: OptionConstraint, name: string, sourceSpecs: CapabilitySpec[]) {
const constraints = sourceSpecs.map((item) => item.options?.[name]).filter((item): item is OptionConstraint => Boolean(item));
if (!constraints.length) return false;
if (constraints.some(isWildcardConstraint)) return true;
if (candidate.values) return candidate.values.every((value) => optionValueSupported(value, constraints));
if (candidate.min === undefined || candidate.max === undefined) return false;
if (Math.abs(candidate.max - candidate.min) < 1e-9) return optionValueSupported(candidate.min, constraints);
if (candidate.step === undefined) return continuousOptionRangeCovered(candidate.min, candidate.max, constraints);
if (candidate.step <= 0) return false;
const count = Math.floor((candidate.max - candidate.min) / candidate.step + 1e-9) + 1;
if (count <= 10_000) {
for (let index = 0; index < count; index += 1) {
if (!optionValueSupported(candidate.min + index * candidate.step, constraints)) return false;
}
return true;
}
const candidateMin = candidate.min;
const candidateMax = candidate.max;
const candidateStep = candidate.step;
return constraints.some((constraint) => {
if (constraint.min === undefined || constraint.max === undefined || constraint.min > candidateMin || constraint.max < candidateMax) return false;
if (!constraint.step) return true;
return approximatelyInteger((candidateMin - constraint.min) / constraint.step) && approximatelyInteger(candidateStep / constraint.step);
});
}
function optionValueSupported(value: unknown, constraints: OptionConstraint[]) {
return constraints.some((constraint) => constraintIncludes(constraint, value));
}
function continuousOptionRangeCovered(minimum: number, maximum: number, constraints: OptionConstraint[]) {
const ranges = constraints
.filter((constraint) => constraint.min !== undefined && constraint.max !== undefined && constraint.step === undefined)
.map((constraint) => ({ min: constraint.min!, max: constraint.max! }))
.sort((left, right) => left.min - right.min);
let coveredUntil = minimum;
let started = false;
for (const range of ranges) {
if (range.max < minimum - 1e-9) continue;
if (!started) {
if (range.min > minimum + 1e-9) return false;
coveredUntil = Math.max(coveredUntil, range.max);
started = true;
} else if (range.min <= coveredUntil + 1e-9) {
coveredUntil = Math.max(coveredUntil, range.max);
} else {
break;
}
if (coveredUntil >= maximum - 1e-9) return true;
}
return false;
}
function isWildcardConstraint(constraint: OptionConstraint) {
return constraint.values?.some((value) => normalizeScalar(value) === "*") || false;
}
function approximatelyInteger(value: number) {
return Math.abs(value - Math.round(value)) < 1e-9;
}