feat(platform): 模型管理与后台能力 - 新增逻辑模型路由和响应拦截

This commit is contained in:
ddCat
2026-08-18 20:42:25 +08:00
parent 0002cdd2cb
commit 8c5f56a551
89 changed files with 21451 additions and 1170 deletions

View File

@@ -0,0 +1,661 @@
import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd";
import type { FormInstance } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react";
import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
import { formatCredits } from "@/constant/credits";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui";
import { listAdminChannels } from "@/services/api/auth";
import {
createAdminLogicalModel,
listAdminLogicalModels,
listAdminPhysicalVariants,
simulateAdminLogicalModel,
updateAdminLogicalModel,
type AdminLogicalModel,
type AdminPhysicalVariant,
type CapabilitySpec,
type LogicalModelMutation,
type ModelRequestIntent,
type RouteSimulationResult,
} from "@/services/api/logical-models";
import {
CapabilityRequestEditor,
CapabilityScopeEditor,
CapabilitySummary,
DefaultOptionsEditor,
capabilityLabel,
capabilitySourceError,
emptyCapabilitySpec,
mergeCapabilitySpecs,
operationLabel,
sanitizeDefaults,
type CapabilityKind,
} from "./model-routing-capabilities";
type RouteRuleRow = { physicalVariantId: string; enabled: boolean; priority: number; weight: number };
type LogicalModelFormValues = {
code: string;
name: string;
icon: string;
description: string;
capability: CapabilityKind;
enabled: boolean;
sortOrder: number;
pricePolicy: LogicalModelMutation["pricePolicy"];
billingMode: LogicalModelMutation["billingMode"];
unitPriceMicrocredits: number;
inputPriceMicrocredits: number;
outputPriceMicrocredits: number;
cachedPriceMicrocredits: number;
capabilitySpec: CapabilitySpec;
defaultOptions: Record<string, unknown>;
routes: RouteRuleRow[];
};
const capabilityOptions = [
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
];
export default function LogicalModelsPage() {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
const [loading, setLoading] = useState(true);
const [models, setModels] = useState<AdminLogicalModel[]>([]);
const [variants, setVariants] = useState<AdminPhysicalVariant[]>([]);
const [channelNames, setChannelNames] = useState<Record<string, string>>({});
const [channelEnabled, setChannelEnabled] = useState<Record<string, boolean>>({});
const [editingModel, setEditingModel] = useState<AdminLogicalModel | null | undefined>();
const [saving, setSaving] = useState(false);
const [simulatingModel, setSimulatingModel] = useState<AdminLogicalModel>();
const [simulationIntent, setSimulationIntent] = useState<ModelRequestIntent>();
const [simulationResult, setSimulationResult] = useState<RouteSimulationResult>();
const [simulating, setSimulating] = useState(false);
const [modelForm] = Form.useForm<LogicalModelFormValues>();
const modelCapability = Form.useWatch("capability", modelForm) || "image";
const modelRoutes = Form.useWatch("routes", modelForm) || [];
const modelCapabilitySpec = Form.useWatch("capabilitySpec", modelForm);
const reload = async () => {
setLoading(true);
try {
const [modelResult, variantResult, channelResult] = await Promise.all([listAdminLogicalModels(), listAdminPhysicalVariants(), listAdminChannels({ page: 1, limit: 100 })]);
setModels(modelResult.models);
setVariants(variantResult.variants);
setChannelNames(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.name])));
setChannelEnabled(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.enabled !== false])));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取前台模型配置失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
void reload();
}, []);
const filteredModels = useMemo(() => models.filter((item) => !deferredKeyword || [item.name, item.code, item.capability].some((value) => value.toLowerCase().includes(deferredKeyword))), [models, deferredKeyword]);
const paginatedModels = useMemo(() => filteredModels.slice((page - 1) * pageSize, page * pageSize), [filteredModels, page, pageSize]);
const modelVariants = useMemo(() => variants.filter((item) => item.capability === modelCapability), [variants, modelCapability]);
const modelSourceSpecs = useMemo(
() =>
modelRoutes
.filter((route) => route.enabled && route.weight > 0)
.map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec)
.filter((item): item is CapabilitySpec => Boolean(item)),
[channelEnabled, modelRoutes, variants],
);
const openModel = (item?: AdminLogicalModel) => {
const capability = item?.capability || "image";
modelForm.resetFields();
modelForm.setFieldsValue(
item
? logicalModelToForm(item)
: {
code: "",
name: "",
icon: "",
description: "",
capability,
enabled: true,
sortOrder: models.length,
pricePolicy: "unified",
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
inputPriceMicrocredits: 0,
outputPriceMicrocredits: 0,
cachedPriceMicrocredits: 0,
capabilitySpec: emptyCapabilitySpec(capability),
defaultOptions: {},
routes: [],
},
);
setEditingModel(item || null);
};
const saveModel = async () => {
const values = await modelForm.validateFields();
if (values.enabled && !values.routes.length) {
message.error("请至少添加一条供应配置");
return;
}
const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec);
if (values.enabled && sourceError) {
message.error(sourceError);
return;
}
setSaving(true);
try {
const payload = logicalModelPayload(values);
await (editingModel ? updateAdminLogicalModel(editingModel.id, payload) : createAdminLogicalModel(payload));
setEditingModel(undefined);
await reload();
message.success(editingModel ? "前台模型已更新" : "前台模型已创建");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存前台模型失败");
} finally {
setSaving(false);
}
};
const toggleModel = async (item: AdminLogicalModel) => {
try {
await updateAdminLogicalModel(item.id, logicalModelPayload({ ...logicalModelToForm(item), enabled: !item.enabled }));
await reload();
message.success(item.enabled ? "前台模型已停用" : "前台模型已启用");
} catch (error) {
message.error(error instanceof Error ? error.message : "更新模型状态失败");
}
};
const openSimulation = (item: AdminLogicalModel) => {
setSimulationIntent({
capability: item.capability,
operation: item.capabilitySpec.operations?.[0],
inputs: Object.fromEntries(Object.entries(item.capabilitySpec.inputs || {}).map(([name, value]) => [name, value.min])),
options: { ...item.defaultOptions },
});
setSimulationResult(undefined);
setSimulatingModel(item);
};
const runSimulation = async () => {
if (!simulatingModel || !simulationIntent) return;
setSimulating(true);
try {
setSimulationResult(await simulateAdminLogicalModel(simulatingModel.id, simulationIntent));
} catch (error) {
message.error(error instanceof Error ? error.message : "路由模拟失败");
} finally {
setSimulating(false);
}
};
const modelColumns: ColumnsType<AdminLogicalModel> = [
{
title: "前台模型",
dataIndex: "name",
render: (_, item) => (
<div className="flex min-w-0 items-center gap-2">
<ModelLogo icon={item.icon} size={20} />
<div className="min-w-0">
<div className="truncate font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-xs text-foreground/45">{item.code}</div>
</div>
</div>
),
},
{ title: "类型", dataIndex: "capability", width: 90, render: (value: CapabilityKind) => capabilityLabel(value) },
{ title: "创作端能力", render: (_, item) => <CapabilitySummary spec={item.capabilitySpec} /> },
{
title: "供应配置",
width: 110,
render: (_, item) => (
<div className="text-xs">
<div>{item.routes.filter((route) => route.enabled && route.available).length} 条可用</div>
<div className="text-foreground/45">共 {item.routes.length} 条</div>
</div>
),
},
{ title: "用户价格", width: 160, render: (_, item) => logicalPriceLabel(item) },
{ title: "状态", width: 130, render: (_, item) => logicalModelStatusTag(item) },
{
title: "操作",
width: 230,
align: "right",
render: (_, item) => (
<AdminRowActions
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openModel(item) }}
actions={[
{ key: "simulate", label: "模拟供应配置匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
{ key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) },
]}
/>
),
},
];
return (
<AdminPageFrame
title="模型目录"
actions={
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openModel()}>
新增模型
</Button>
}
>
<AdminDataTable
toolbar={<Input prefix={<Search className="size-4 text-foreground/40" />} allowClear value={keyword} onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索模型名称、代码或能力" className="app-list-search" />}
toolbarActiveFilters={keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => { setKeyword(""); setPage(1); }} /> : null}
toolbarActive={Boolean(keyword)}
onReset={() => { setKeyword(""); setPage(1); }}
table={{ rowKey: "id", size: "small", loading, pagination: false, columns: modelColumns, dataSource: paginatedModels, scroll: { x: 980 } }}
empty={<AdminTableEmpty filtered={Boolean(deferredKeyword)} title="暂无模型" />}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={filteredModels.length} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
/>
<Drawer
title={editingModel ? "编辑前台模型" : "新增前台模型"}
open={editingModel !== undefined}
size="min(1120px, 100vw)"
destroyOnHidden
maskClosable={!saving}
onClose={() => !saving && setEditingModel(undefined)}
rootClassName="admin-drawer"
footer={<div className="flex justify-end gap-2"><Button disabled={saving} onClick={() => setEditingModel(undefined)}>取消</Button><Button type="primary" loading={saving} onClick={() => void saveModel()}>保存</Button></div>}
>
<Form form={modelForm} layout="vertical" requiredMark={false} className="space-y-3">
{editingModel?.configurationError || editingModel?.availabilityError ? (
<Alert
className="mb-4"
type="warning"
showIcon
message={editingModel.configurationError ? "当前供应配置无法覆盖全部创作端能力" : "当前供应配置暂不可结算"}
description={editingModel.configurationError || editingModel.availabilityError}
/>
) : null}
<DrawerSection icon={<Layers3 className="size-4" />} title="前台展示">
<div className="grid gap-3 sm:grid-cols-3">
<Form.Item name="name" label="显示名称" rules={[{ required: true, message: "请填写显示名称" }]}>
<Input placeholder="例如:Seedance 视频" />
</Form.Item>
<Form.Item name="code" label="模型代码" rules={[{ required: true, message: "请填写模型代码" }]}>
<Input placeholder="例如:seedance-video" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />
</Form.Item>
</div>
<Form.Item name="description" label="简短说明">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明适合的创作场景,不描述供应渠道。" />
</Form.Item>
<div className="grid gap-3 sm:grid-cols-3">
<Form.Item name="capability" label="类型">
<Select
options={capabilityOptions}
onChange={(capability: CapabilityKind) =>
modelForm.setFieldsValue({ routes: [], capabilitySpec: emptyCapabilitySpec(capability), defaultOptions: {}, billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request" })
}
/>
</Form.Item>
<Form.Item name="sortOrder" label="前台排序">
<InputNumber className="w-full" precision={0} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</div>
</DrawerSection>
<DrawerSection icon={<GitBranch className="size-4" />} title="供应配置">
<RouteFields variants={modelVariants} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
</DrawerSection>
<DrawerSection title="创作端可选能力">
<Form.Item name="capabilitySpec" noStyle>
<CapabilityScopeEditor capability={modelCapability} sourceSpecs={modelSourceSpecs} mode="front" />
</Form.Item>
</DrawerSection>
<DrawerSection title="默认参数">
<Form.Item name="defaultOptions" noStyle>
<DefaultOptionsEditor spec={modelCapabilitySpec} />
</Form.Item>
</DrawerSection>
<DrawerSection title="用户价格">
<PricingFields />
</DrawerSection>
</Form>
</Drawer>
<Modal
title={simulatingModel ? `供应配置匹配模拟 - ${simulatingModel.name}` : "供应配置匹配模拟"}
open={Boolean(simulatingModel)}
className="workspace-modal workspace-modal-wide admin-simulation-modal"
rootClassName="admin-modal-root"
centered
destroyOnHidden
onCancel={() => setSimulatingModel(undefined)}
styles={{ body: { maxHeight: "min(72vh, 720px)", overflowY: "auto" } }}
footer={[
<Button key="cancel" onClick={() => setSimulatingModel(undefined)}>关闭</Button>,
<Button key="submit" type="primary" icon={<FlaskConical className="size-4" />} loading={simulating} onClick={() => void runSimulation()}>
模拟匹配
</Button>,
]}
>
{simulatingModel && simulationIntent ? (
<div className="space-y-5">
{simulatingModel.capabilitySpec.operations?.length ? (
<label className="block">
<span className="mb-1 block text-xs text-foreground/55">生成方式</span>
<Select
className="w-full"
value={simulationIntent.operation}
options={simulatingModel.capabilitySpec.operations.map((value) => ({ value, label: operationLabel(value) }))}
onChange={(operation) => setSimulationIntent({ ...simulationIntent, operation })}
/>
</label>
) : null}
<CapabilityRequestEditor
spec={simulatingModel.capabilitySpec}
inputs={simulationIntent.inputs || {}}
options={simulationIntent.options || {}}
onInputsChange={(inputs) => setSimulationIntent({ ...simulationIntent, inputs })}
onOptionsChange={(options) => setSimulationIntent({ ...simulationIntent, options })}
/>
{simulationResult ? (
<section className="pt-1">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold">匹配结果</h2>
<Tag variant="filled" color={simulationResult.productMatch.matched ? "success" : "error"}>
{simulationResult.productMatch.matched ? "请求能力通过" : "请求能力不匹配"}
</Tag>
</div>
{simulationResult.productMatch.reasons?.length ? <p className="mb-4 text-sm text-error">{simulationResult.productMatch.reasons.join(";")}</p> : null}
<Table size="small" pagination={false} rowKey="routeId" dataSource={simulationResult.candidates} columns={simulationColumns(simulatingModel)} />
</section>
) : null}
</div>
) : null}
</Modal>
</AdminPageFrame>
);
}
function DrawerSection({ icon, title, description, children }: { icon?: ReactNode; title: string; description?: string; children: ReactNode }) {
return (
<section className="rounded-lg bg-muted/20 p-4">
<div className="mb-4 flex items-center gap-2.5">
{icon ? <span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/50 text-foreground/55">{icon}</span> : null}
<div>
<h2 className="text-[var(--fs-body)] font-semibold">{title}</h2>
{description ? <p className="mt-1 text-xs leading-5 text-foreground/50">{description}</p> : null}
</div>
</div>
{children}
</section>
);
}
function RouteFields({
variants,
channelNames,
channelEnabled,
form,
capability,
}: {
variants: AdminPhysicalVariant[];
channelNames: Record<string, string>;
channelEnabled: Record<string, boolean>;
form: FormInstance<LogicalModelFormValues>;
capability: CapabilityKind;
}) {
const selectOptions = variants.map((item) => {
const unavailableReason = channelEnabled[item.channelId] === false ? "渠道已停用" : !item.modelEnabled ? "渠道模型已停用" : !item.enabled ? "规格已停用" : "";
return {
value: item.id,
label: `${item.name} · ${channelNames[item.channelId] || item.channelId} / ${item.modelName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
disabled: Boolean(unavailableReason),
};
});
const availableVariantCount = selectOptions.filter((item) => !item.disabled).length;
return (
<Form.List name="routes">
{(fields, { add, remove }) => {
const currentRoutes = (form.getFieldValue("routes") || []) as RouteRuleRow[];
const selectedVariantCount = new Set(currentRoutes.map((route) => route?.physicalVariantId).filter(Boolean)).size;
const canAdd = selectedVariantCount < availableVariantCount;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-foreground/50">共 {fields.length} 条供应配置</span>
<Button size="small" icon={<Plus className="size-3.5" />} disabled={!canAdd} onClick={() => add({ physicalVariantId: "", enabled: true, priority: 100, weight: 100 })}>
添加供应配置
</Button>
</div>
{fields.length ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{fields.map((field) => {
const routes = (form.getFieldValue("routes") || []) as RouteRuleRow[];
const selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.physicalVariantId)).filter(Boolean));
const options = selectOptions.map((option) => ({ ...option, disabled: option.disabled || selectedByOthers.has(option.value) }));
const selected = variants.find((item) => item.id === routes[field.name]?.physicalVariantId);
return (
<div key={field.key} className="rounded-lg border border-border bg-muted/5 p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-semibold">供应配置 {fields.indexOf(field) + 1}</div>
<div className="mt-0.5 truncate text-xs text-foreground/50">
{selected ? `${selected.name} · ${channelNames[selected.channelId] || selected.channelId} / ${selected.modelName || selected.modelKey}` : "选择一个可承接请求的配置"}
</div>
</div>
<Button type="text" size="small" danger onClick={() => remove(field.name)}>
移除
</Button>
</div>
<Form.Item name={[field.name, "physicalVariantId"]} rules={[{ required: true, message: "请选择供应配置" }]} className="mb-3">
<Select
aria-label={`供应配置 ${fields.indexOf(field) + 1}`}
showSearch
optionFilterProp="label"
placeholder="选择供应配置"
options={options}
onChange={(physicalVariantId) => {
const nextRoutes = [...(form.getFieldValue("routes") || [])];
nextRoutes[field.name] = { ...nextRoutes[field.name], physicalVariantId };
const specs = nextRoutes
.filter((route) => route.enabled && route.weight > 0)
.map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec)
.filter((item): item is CapabilitySpec => Boolean(item));
form.setFieldValue("routes", nextRoutes);
if (!hasCapabilityRules(form.getFieldValue("capabilitySpec"))) form.setFieldValue("capabilitySpec", mergeCapabilitySpecs(capability, specs));
}}
/>
</Form.Item>
<div className="flex items-end gap-2">
<Form.Item name={[field.name, "priority"]} label="优先级" className="mb-0 min-w-0 flex-1">
<InputNumber className="w-full" precision={0} />
</Form.Item>
<Form.Item name={[field.name, "weight"]} label="权重" className="mb-0 min-w-0 flex-1">
<InputNumber className="w-full" min={0} precision={0} />
</Form.Item>
<Form.Item name={[field.name, "enabled"]} label="启用" valuePropName="checked" className="mb-0 shrink-0">
<Switch />
</Form.Item>
</div>
</div>
);
})}
</div>
) : null}
{!fields.length ? <div className="rounded-md bg-muted/20 px-3 py-4 text-center text-xs text-foreground/50">尚未添加供应配置</div> : null}
</div>
);
}}
</Form.List>
);
}
function logicalModelStatusTag(item: AdminLogicalModel) {
if (!item.enabled) return <AdminStatusBadge label="已停用" tone="neutral" />;
if (item.configurationError) return <AdminStatusBadge label="能力配置需调整" tone="warning" title={item.configurationError} />;
if (item.availabilityError) return <AdminStatusBadge label="线路价格需调整" tone="warning" title={item.availabilityError} />;
if (!item.available) return <AdminStatusBadge label="暂无可用线路" tone="warning" />;
return <AdminStatusBadge label="可用" tone="success" />;
}
function PricingFields() {
const form = Form.useFormInstance<LogicalModelFormValues>();
const pricePolicy = Form.useWatch("pricePolicy") as LogicalModelMutation["pricePolicy"] | undefined;
const billingMode = Form.useWatch("billingMode") as LogicalModelMutation["billingMode"] | undefined;
const capability = Form.useWatch("capability") as CapabilityKind | undefined;
const modes = [{ label: "按次", value: "fixed_request" }];
if (capability === "video") modes.push({ label: "按秒", value: "per_second" });
if (capability === "text") modes.push({ label: "Token", value: "token" });
const changePolicy = (value: string | number) => {
const nextPolicy = value as LogicalModelMutation["pricePolicy"];
if (nextPolicy !== "unified") return;
const supportedModes = modes.map((item) => item.value);
if (!billingMode || !supportedModes.includes(billingMode)) {
form.setFieldValue("billingMode", capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request");
}
};
return (
<>
<Form.Item name="pricePolicy" label="定价策略">
<Segmented
block
options={[
{ label: "跟随供应价格", value: "channel" },
{ label: "统一定价", value: "unified" },
]}
onChange={changePolicy}
/>
</Form.Item>
{pricePolicy === "unified" ? (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="billingMode" label="计费方式">
<Segmented block options={modes} />
</Form.Item>
{billingMode !== "token" ? (
<Form.Item name="unitPriceMicrocredits" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"}>
<CreditsInput />
</Form.Item>
) : null}
</div>
{billingMode === "token" ? (
<div className="grid gap-3 sm:grid-cols-3">
<Form.Item name="inputPriceMicrocredits" label="输入 / 百万 Token">
<CreditsInput />
</Form.Item>
<Form.Item name="outputPriceMicrocredits" label="输出 / 百万 Token">
<CreditsInput />
</Form.Item>
<Form.Item name="cachedPriceMicrocredits" label="缓存 / 百万 Token">
<CreditsInput />
</Form.Item>
</div>
) : null}
</>
) : (
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应配置价格计算。故障切换到更高价格配置时会重新校验余额。</div>
)}
</>
);
}
function CreditsInput({ value = 0, onChange }: { value?: number; onChange?: (value: number) => void }) {
return <InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} value={value / 1_000_000} onChange={(next) => onChange?.(Math.round((next || 0) * 1_000_000))} />;
}
function logicalPriceLabel(item: AdminLogicalModel) {
if (item.pricePolicy === "channel") return <span className="text-xs">跟随供应价格</span>;
if (item.billingMode === "token")
return (
<div className="text-xs">
<div>输入 {formatCredits(item.inputPriceMicrocredits)} / 百万</div>
<div>输出 {formatCredits(item.outputPriceMicrocredits)} / 百万</div>
<div className="text-foreground/45">缓存 {formatCredits(item.cachedPriceMicrocredits)} / 百万</div>
</div>
);
return (
<span className="text-xs">
{formatCredits(item.unitPriceMicrocredits)} / {item.billingMode === "per_second" ? "秒" : "次"}
</span>
);
}
function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
return {
code: item.code,
name: item.name,
icon: item.icon || "",
description: item.description,
capability: item.capability,
enabled: item.enabled,
sortOrder: item.sortOrder,
pricePolicy: item.pricePolicy,
billingMode: item.billingMode,
unitPriceMicrocredits: item.unitPriceMicrocredits,
inputPriceMicrocredits: item.inputPriceMicrocredits,
outputPriceMicrocredits: item.outputPriceMicrocredits,
cachedPriceMicrocredits: item.cachedPriceMicrocredits,
capabilitySpec: item.capabilitySpec,
defaultOptions: item.defaultOptions,
routes: item.routes.map((route) => ({ physicalVariantId: route.physicalVariantId, enabled: route.enabled, priority: route.priority, weight: route.weight })),
};
}
function logicalModelPayload(values: LogicalModelFormValues): LogicalModelMutation {
const capabilitySpec = { ...values.capabilitySpec, capability: values.capability, version: 1 as const };
return {
code: values.code.trim(),
name: values.name.trim(),
icon: values.icon.trim(),
description: values.description?.trim() || "",
capability: values.capability,
enabled: values.enabled,
sortOrder: values.sortOrder || 0,
pricePolicy: values.pricePolicy,
billingMode: values.billingMode,
unitPriceMicrocredits: values.unitPriceMicrocredits || 0,
inputPriceMicrocredits: values.inputPriceMicrocredits || 0,
outputPriceMicrocredits: values.outputPriceMicrocredits || 0,
cachedPriceMicrocredits: values.cachedPriceMicrocredits || 0,
capabilitySpec,
defaultOptions: sanitizeDefaults(capabilitySpec, values.defaultOptions),
routes: values.routes.map((route) => ({ ...route, priority: route.priority || 0, weight: route.weight || 0 })),
};
}
function hasCapabilityRules(spec?: CapabilitySpec) {
return Boolean(spec && ((spec.operations?.length || 0) > 0 || Object.keys(spec.inputs || {}).length > 0 || Object.keys(spec.options || {}).length > 0));
}
function simulationColumns(model: AdminLogicalModel): ColumnsType<RouteSimulationResult["candidates"][number]> {
return [
{ title: "供应配置", render: (_, candidate) => model.routes.find((route) => route.id === candidate.routeId)?.physicalVariantName || "未知配置" },
{ title: "优先级", dataIndex: "priority", width: 80 },
{ title: "权重", dataIndex: "weight", width: 70 },
{
title: "结果",
width: 110,
render: (_, candidate) => (
<Tag variant="filled" color={candidate.inPool ? "success" : candidate.blocked ? "warning" : "default"}>
{candidate.inPool ? "进入候选池" : candidate.blocked ? "冷却中" : candidate.matched ? "低优先级" : "不匹配"}
</Tag>
),
},
{ title: "原因", render: (_, candidate) => candidate.reasons?.join(";") || "-" },
];
}

View File

@@ -0,0 +1,593 @@
import { Alert, Button, InputNumber, Select, Switch, Tag } from "antd";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import type { CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
import type { ChannelModel } from "@/services/api/wallet";
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: {} };
}
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
: image.size.allowCustom
? { values: ["*"] }
: { values: image.size.values },
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 },
}),
};
}
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)) {
result.options![definition.name] = { values: ["*"] };
continue;
}
if (constraints.every((item) => item.values)) {
result.options![definition.name] = { values: uniqueScalars(constraints.flatMap((item) => item.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);
}
return result;
}
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, mode }: CapabilityScopeEditorProps) {
const source = mergeCapabilitySpecs(capability, sourceSpecs);
const current = value?.capability === capability ? value : 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 <Alert type="warning" showIcon message="没有可用的能力来源" description={mode === "route" ? "请先到渠道模型补充能力配置。" : "请先选择至少一条已配置的供应线路。"} />;
}
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 ? <Alert type="warning" showIcon message="当前选择需要调整" description={`${sourceError}。可重新选择,或采用全部可用范围。`} /> : 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="small" 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 ? <Alert type="info" showIcon message="该类型暂无额外能力参数" description="线路仍可按优先级和权重参与路由。" /> : 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 max-w-lg flex-wrap gap-1">{labels.slice(0, 4).map((label) => <Tag key={label}>{label}</Tag>)}{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}</div>;
}
export function sanitizeDefaults(spec: CapabilitySpec, defaults: Record<string, unknown> | undefined) {
return Object.fromEntries(Object.entries(defaults || {}).filter(([name, value]) => spec.options?.[name] && constraintIncludes(spec.options[name], value)));
}
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="small" 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 "不启用";
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: "图生视频",
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 (constraint.values?.length === 1 && constraint.values[0] === "*") 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?.length === 1 && constraint.values[0] === "*";
}
function approximatelyInteger(value: number) {
return Math.abs(value - Math.round(value)) < 1e-9;
}