feat(platform): 模型管理与后台能力 - 新增逻辑模型路由和响应拦截
This commit is contained in:
661
web/src/pages/admin/logical-models/logical-models-page.tsx
Normal file
661
web/src/pages/admin/logical-models/logical-models-page.tsx
Normal 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(";") || "-" },
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user