refactor(core): 模型路由与画布 - 收敛渠道模型映射并优化交互

This commit is contained in:
ddCat
2026-08-19 20:27:26 +08:00
parent 52424543ea
commit d47975f9bc
34 changed files with 601 additions and 901 deletions

View File

@@ -29,13 +29,13 @@ export function CreditOperationsPage() {
}
export function AccessSettingsPage() {
return <AdminPageFrame title="登录与注册" description="注册策略与 Linux.do"><Suspense fallback={<PageFallback label="登录与注册配置" />}><AccessSettingsPanel /></Suspense></AdminPageFrame>;
return <AdminPageFrame title="登录与注册" description="注册策略与 Linux.do" scroll><Suspense fallback={<PageFallback label="登录与注册配置" />}><AccessSettingsPanel /></Suspense></AdminPageFrame>;
}
export function EmailSettingsPage() {
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP"><div className="pt-4"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP" scroll><div className="pt-4"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
}
export function FeatureAvailabilityPage() {
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式"><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式" scroll><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
}

View File

@@ -136,10 +136,10 @@ export function AdminShell() {
);
}
export function AdminPageFrame({ title, actions, back, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; children: ReactNode }) {
export function AdminPageFrame({ title, actions, back, scroll = false, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; scroll?: boolean; children: ReactNode }) {
return (
<WorkspacePage scroll={false} fluid className="admin-page-root">
<div className="admin-page-frame">
<WorkspacePage scroll={scroll} fluid className={cn("admin-page-root", scroll && "admin-page-root-scrollable")}>
<div className={cn("admin-page-frame", scroll && "admin-page-frame-scrollable")}>
<header className="admin-page-header flex min-h-12 shrink-0 flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2.5">
{back ? (

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
@@ -7,12 +7,10 @@ import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIcon } from "@/components/model-picker";
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
import { createAdminPhysicalVariant, listAdminPhysicalVariants, updateAdminPhysicalVariant, type AdminPhysicalVariant } from "@/services/api/logical-models";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { CapabilitySummary, capabilitySpecFromChannelModel } from "../logical-models/model-routing-capabilities";
import { AdminPageFrame } from "./admin-shell";
import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
@@ -32,11 +30,6 @@ type FormValues = {
capabilityConfig?: ModelCapabilityConfig;
};
type ConfigFormValues = {
name: string;
enabled: boolean;
};
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
const { message } = App.useApp();
const [items, setItems] = useState<ChannelModel[]>([]);
@@ -46,24 +39,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [config, setConfig] = useState<AdminPhysicalVariant | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [keyword, setKeyword] = useState("");
const [capability, setCapability] = useState<ChannelModel["capability"] | "all">("all");
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [form] = Form.useForm<FormValues>();
const [configForm] = Form.useForm<ConfigFormValues>();
const configRequestRef = useRef(0);
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
const modelCapability = Form.useWatch("capability", form);
const modelProtocol = Form.useWatch("protocol", form);
const modelKey = Form.useWatch("modelKey", form) || "";
const editingCapabilitySpec = capabilitySpecFromChannelModel(editing || undefined);
const currentConfig = editing && config?.channelModelId === editing.id ? config : null;
const reload = async () => {
if (!channel) return;
@@ -77,69 +62,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
};
const reloadConfig = async (channelModelID: string, defaultName = "") => {
const requestID = ++configRequestRef.current;
setConfigLoading(true);
try {
const variants = (await listAdminPhysicalVariants(channelModelID)).variants;
if (requestID !== configRequestRef.current) return;
const nextConfig = variants.find((item) => item.channelModelId === channelModelID) || null;
setConfig(nextConfig);
if (nextConfig) {
configForm.setFieldsValue({ name: nextConfig.name, enabled: nextConfig.enabled });
} else {
configForm.setFieldsValue({ name: defaultName, enabled: true });
}
} catch (error) {
if (requestID === configRequestRef.current) message.error(error instanceof Error ? error.message : "读取可用配置失败");
} finally {
if (requestID === configRequestRef.current) setConfigLoading(false);
}
};
const saveConfig = async () => {
if (!editing) {
message.info("请先保存渠道模型,再配置唯一可用配置");
return;
}
const values = await configForm.validateFields();
if (values.enabled && editing.capability !== "audio" && !editingCapabilitySpec) {
message.error("请先完成渠道模型的供应能力配置");
return;
}
if (values.enabled && !editing.enabled) {
message.error("渠道模型已停用,不能启用可用配置");
return;
}
if (config && !currentConfig) {
message.error("可用配置与当前渠道模型不匹配,请重新打开后再试");
return;
}
setConfigSaving(true);
try {
// 能力范围只维护在渠道模型能力参数中;可用配置只是路由开关和显示名称。
// 后端会从渠道模型能力生成 variant 快照,避免同一尺寸/比例被填写两次。
const payload = { channelModelId: editing.id, name: values.name.trim(), enabled: values.enabled };
if (currentConfig) await updateAdminPhysicalVariant(currentConfig.id, payload);
else await createAdminPhysicalVariant(payload);
await reloadConfig(editing.id, editing.displayName || editing.modelKey);
await onChanged();
message.success(currentConfig ? "可用配置已更新" : "已创建唯一可用配置");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存可用配置失败");
} finally {
setConfigSaving(false);
}
};
useEffect(() => {
configRequestRef.current += 1;
void reload();
setEditing(null);
setConfig(null);
setConfigLoading(false);
setConfigDrawerOpen(false);
configForm.resetFields();
setEditorOpen(false);
setKeyword("");
setCapability("all");
@@ -165,12 +90,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
};
const startCreate = () => {
configRequestRef.current += 1;
setEditing(null);
setConfig(null);
setConfigLoading(false);
setConfigDrawerOpen(false);
configForm.resetFields();
form.setFieldsValue({
modelKey: "",
displayName: "",
@@ -189,10 +109,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const startEdit = (item: ChannelModel) => {
setEditing(item);
setConfig(null);
setConfigDrawerOpen(false);
configForm.setFieldsValue({ name: item.displayName || item.modelKey, enabled: true });
void reloadConfig(item.id, item.displayName || item.modelKey);
form.setFieldsValue({
modelKey: item.modelKey,
displayName: item.displayName,
@@ -204,7 +120,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000,
cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
enabled: item.enabled,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video" ? item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.modelKey) : undefined,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video" ? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.modelKey)) : undefined,
});
setEditorOpen(true);
};
@@ -225,7 +141,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: true,
enabled: values.enabled !== false,
capabilityConfig: values.capability === "text" || values.capability === "image" || values.capability === "video" ? values.capabilityConfig : undefined,
capabilityConfig: values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig!) : undefined,
};
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
else await createAdminChannelModel(channel.id, payload);
@@ -249,7 +165,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
modelKey: values.modelKey.trim(),
capability: values.capability,
protocol: values.protocol,
capabilityConfig: values.capabilityConfig,
capabilityConfig: values.capabilityConfig ? normalizeModelCapabilityConfig(values.capabilityConfig) : undefined,
});
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
} catch (error) {
@@ -330,7 +246,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Button size="small" onClick={() => startEdit(item)}>
编辑
</Button>
<Popconfirm title="删除模型" description="删除后模型不再显示,历史账单仍会保留。该操作不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
<Popconfirm title="删除模型" description="已被前台供应线路或进行中任务使用的模型不能删除;删除后模型不再显示,且不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
<Button size="small" danger title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
</Popconfirm>
</Space>
@@ -421,7 +337,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
title={editing ? `编辑模型 / ${editing.displayName || editing.modelKey}` : "新增模型"}
open={editorOpen}
size="min(1080px, 100vw)"
onClose={() => !configSaving && setEditorOpen(false)}
onClose={() => !saving && setEditorOpen(false)}
rootClassName="admin-drawer"
footer={
<div className="flex items-center justify-between gap-3">
@@ -481,34 +397,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
) : null}
</section>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">可用配置</h2>
</div>
{!editing ? (
<div className="rounded-md bg-muted/20 px-4 py-4 text-sm text-foreground/60">请先保存模型基础信息,保存后即可配置唯一的路由入口。</div>
) : (
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/15 p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{currentConfig?.name || "尚未创建路由配置"}</span>
{currentConfig ? <AdminStatusBadge label={currentConfig.enabled ? "启用" : "停用"} tone={currentConfig.enabled ? "success" : "neutral"} /> : null}
</div>
<div className="mt-1 text-xs text-foreground/45">{currentConfig?.usageCount ? `已被 ${currentConfig.usageCount} 条前台供应配置引用` : "尚未被前台供应配置引用"}</div>
</div>
<Button
loading={configLoading}
onClick={() => {
setConfigDrawerOpen(true);
void reloadConfig(editing.id, editing.displayName || editing.modelKey);
}}
>
编辑配置
</Button>
</div>
)}
</section>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">计费</h2>
@@ -558,32 +446,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
</section>
</Form>
</Drawer>
<Drawer
title="可用配置"
open={configDrawerOpen}
size="min(560px, 100vw)"
onClose={() => !configSaving && setConfigDrawerOpen(false)}
rootClassName="admin-secondary-drawer"
footer={<div className="flex justify-end gap-2"><Button onClick={() => setConfigDrawerOpen(false)}>取消</Button><Button type="primary" loading={configSaving} onClick={() => void saveConfig()}>保存路由配置</Button></div>}
>
<Form form={configForm} layout="vertical" requiredMark={false}>
<div className="mb-5 rounded-lg bg-muted/15 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">唯一可用配置</span>
<AdminStatusBadge label={currentConfig ? (currentConfig.enabled ? "启用" : "停用") : "未创建"} tone={currentConfig?.enabled ? "success" : "neutral"} />
</div>
<span className="text-xs text-foreground/45">{currentConfig?.usageCount ? `引用 ${currentConfig.usageCount}` : "未引用"}</span>
</div>
</div>
<Form.Item name="name" label="配置名称" rules={[{ required: true, message: "请填写配置名称" }]}><Input placeholder="例如:标准能力、高清能力、多参考图" /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<div className="rounded-lg bg-muted/10 px-4 py-4">
<div className="mb-2 text-xs font-semibold text-foreground/65">继承的模型能力</div>
{editingCapabilitySpec ? <CapabilitySummary spec={editingCapabilitySpec} /> : <span className="text-xs text-foreground/45">请先在“能力与协议”中完成模型能力参数。</span>}
</div>
</Form>
</Drawer>
</AdminPageFrame>
);
}

View File

@@ -6,18 +6,18 @@ import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
import { CapabilityCardPicker } from "@/components/model-protocol-picker";
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 { listAdminChannelModels, type ChannelModel } from "@/services/api/wallet";
import {
createAdminLogicalModel,
listAdminLogicalModels,
listAdminPhysicalVariants,
simulateAdminLogicalModel,
updateAdminLogicalModel,
type AdminLogicalModel,
type AdminPhysicalVariant,
type CapabilitySpec,
type LogicalModelMutation,
type ModelRequestIntent,
@@ -29,6 +29,7 @@ import {
CapabilitySummary,
DefaultOptionsEditor,
capabilityLabel,
capabilitySpecFromChannelModel,
capabilitySourceError,
emptyCapabilitySpec,
mergeCapabilitySpecs,
@@ -38,7 +39,7 @@ import {
type CapabilityKind,
} from "./model-routing-capabilities";
type RouteRuleRow = { physicalVariantId: string; enabled: boolean; priority: number; weight: number };
type RouteRuleRow = { channelModelId: string; enabled: boolean; priority: number; weight: number };
type LogicalModelFormValues = {
code: string;
name: string;
@@ -57,13 +58,6 @@ type LogicalModelFormValues = {
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("");
@@ -72,7 +66,7 @@ export default function LogicalModelsPage() {
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
const [loading, setLoading] = useState(true);
const [models, setModels] = useState<AdminLogicalModel[]>([]);
const [variants, setVariants] = useState<AdminPhysicalVariant[]>([]);
const [channelModels, setChannelModels] = useState<ChannelModel[]>([]);
const [channelNames, setChannelNames] = useState<Record<string, string>>({});
const [channelEnabled, setChannelEnabled] = useState<Record<string, boolean>>({});
const [editingModel, setEditingModel] = useState<AdminLogicalModel | null | undefined>();
@@ -89,11 +83,16 @@ export default function LogicalModelsPage() {
const reload = async () => {
setLoading(true);
try {
const [modelResult, variantResult, channelResult] = await Promise.all([listAdminLogicalModels(), listAdminPhysicalVariants(), listAdminChannels({ page: 1, limit: 100 })]);
const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, limit: 100 })]);
const remainingChannelPages = await Promise.all(
Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit })),
);
const channels = [firstChannelPage, ...remainingChannelPages].flatMap((result) => result.channels);
const channelModelResults = await Promise.all(channels.map((channel) => listAdminChannelModels(channel.id)));
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])));
setChannelModels(channelModelResults.flatMap((result) => result.models));
setChannelNames(Object.fromEntries(channels.map((channel) => [channel.id, channel.name])));
setChannelEnabled(Object.fromEntries(channels.map((channel) => [channel.id, channel.enabled !== false])));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取前台模型配置失败");
} finally {
@@ -107,14 +106,15 @@ export default function LogicalModelsPage() {
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 modelChannelModels = useMemo(() => channelModels.filter((item) => item.capability === modelCapability), [channelModels, 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)
.map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false))
.map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined))
.filter((item): item is CapabilitySpec => Boolean(item)),
[channelEnabled, modelRoutes, variants],
[channelEnabled, channelModels, modelRoutes],
);
const openModel = (item?: AdminLogicalModel) => {
@@ -148,7 +148,7 @@ export default function LogicalModelsPage() {
const saveModel = async () => {
const values = await modelForm.validateFields();
if (values.enabled && !values.routes.length) {
message.error("请至少添加一条供应配置");
message.error("请至少添加一条供应线路");
return;
}
const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec);
@@ -218,9 +218,9 @@ export default function LogicalModelsPage() {
),
},
{ title: "类型", dataIndex: "capability", width: 90, render: (value: CapabilityKind) => capabilityLabel(value) },
{ title: "创作端能力", render: (_, item) => <CapabilitySummary spec={item.capabilitySpec} /> },
{ title: "创作端能力", width: 360, render: (_, item) => <CapabilitySummary spec={item.capabilitySpec} /> },
{
title: "供应配置",
title: "供应线路",
width: 110,
render: (_, item) => (
<div className="text-xs">
@@ -239,7 +239,7 @@ export default function LogicalModelsPage() {
<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: "simulate", label: "模拟供应线路匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
{ key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) },
]}
/>
@@ -276,13 +276,28 @@ export default function LogicalModelsPage() {
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">
<Form
form={modelForm}
layout="vertical"
requiredMark={false}
className="space-y-3"
onValuesChange={(changedValues: Partial<LogicalModelFormValues>) => {
const capability = changedValues.capability;
if (!capability) return;
modelForm.setFieldsValue({
routes: [],
capabilitySpec: emptyCapabilitySpec(capability),
defaultOptions: {},
billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request",
});
}}
>
{editingModel?.configurationError || editingModel?.availabilityError ? (
<Alert
className="mb-4"
type="warning"
showIcon
message={editingModel.configurationError ? "当前供应配置无法覆盖全部创作端能力" : "当前供应配置暂不可结算"}
message={editingModel.configurationError ? "当前供应线路无法覆盖全部创作端能力" : "当前供应线路暂不可结算"}
description={editingModel.configurationError || editingModel.availabilityError}
/>
) : null}
@@ -301,15 +316,10 @@ export default function LogicalModelsPage() {
<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="capability" label="类型">
<CapabilityCardPicker density="compact" />
</Form.Item>
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="sortOrder" label="前台排序">
<InputNumber className="w-full" precision={0} />
</Form.Item>
@@ -318,8 +328,8 @@ export default function LogicalModelsPage() {
</Form.Item>
</div>
</DrawerSection>
<DrawerSection icon={<GitBranch className="size-4" />} title="供应配置">
<RouteFields variants={modelVariants} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
<DrawerSection icon={<GitBranch className="size-4" />} title="供应线路">
<RouteFields channelModels={modelChannelModels} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
</DrawerSection>
<DrawerSection title="创作端可选能力">
<Form.Item name="capabilitySpec" noStyle>
@@ -338,7 +348,7 @@ export default function LogicalModelsPage() {
</Drawer>
<Modal
title={simulatingModel ? `供应配置匹配模拟 - ${simulatingModel.name}` : "供应配置匹配模拟"}
title={simulatingModel ? `供应线路匹配模拟 - ${simulatingModel.name}` : "供应线路匹配模拟"}
open={Boolean(simulatingModel)}
className="workspace-modal workspace-modal-wide admin-simulation-modal"
rootClassName="admin-modal-root"
@@ -382,7 +392,7 @@ export default function LogicalModelsPage() {
</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)} />
<Table size="small" pagination={false} rowKey="routeId" dataSource={simulationResult.candidates} columns={simulationColumns()} />
</section>
) : null}
</div>
@@ -408,74 +418,75 @@ function DrawerSection({ icon, title, description, children }: { icon?: ReactNod
}
function RouteFields({
variants,
channelModels,
channelNames,
channelEnabled,
form,
capability,
}: {
variants: AdminPhysicalVariant[];
channelModels: ChannelModel[];
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 ? "规格已停用" : "";
const selectOptions = channelModels.map((item) => {
const unavailableReason = channelEnabled[item.channelId] === false ? "渠道已停用" : !item.enabled ? "渠道模型已停用" : "";
return {
value: item.id,
label: `${item.name} · ${channelNames[item.channelId] || item.channelId} / ${item.modelName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
label: `${channelNames[item.channelId]} / ${item.displayName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
disabled: Boolean(unavailableReason),
};
});
const availableVariantCount = selectOptions.filter((item) => !item.disabled).length;
const availableChannelModelCount = 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;
const selectedChannelModelCount = new Set(currentRoutes.map((route) => route?.channelModelId).filter(Boolean)).size;
const canAdd = selectedChannelModelCount < availableChannelModelCount;
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 })}>
添加供应配置
<span className="text-xs text-foreground/50">共 {fields.length} 条供应线路</span>
<Button size="small" icon={<Plus className="size-3.5" />} disabled={!canAdd} onClick={() => add({ channelModelId: "", 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 selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.channelModelId)).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);
const selected = channelModels.find((item) => item.id === routes[field.name]?.channelModelId);
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="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}` : "选择一个可承接请求的配置"}
{selected ? `${channelNames[selected.channelId]} / ${selected.displayName || 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">
<Form.Item name={[field.name, "channelModelId"]} rules={[{ required: true, message: "请选择渠道模型" }]} className="mb-3">
<Select
aria-label={`供应配置 ${fields.indexOf(field) + 1}`}
aria-label={`供应线路 ${fields.indexOf(field) + 1}`}
showSearch
optionFilterProp="label"
placeholder="选择供应配置"
placeholder="选择渠道模型"
options={options}
onChange={(physicalVariantId) => {
onChange={(channelModelId) => {
const nextRoutes = [...(form.getFieldValue("routes") || [])];
nextRoutes[field.name] = { ...nextRoutes[field.name], physicalVariantId };
nextRoutes[field.name] = { ...nextRoutes[field.name], channelModelId };
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)
.map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false))
.map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined))
.filter((item): item is CapabilitySpec => Boolean(item));
form.setFieldValue("routes", nextRoutes);
if (!hasCapabilityRules(form.getFieldValue("capabilitySpec"))) form.setFieldValue("capabilitySpec", mergeCapabilitySpecs(capability, specs));
@@ -498,7 +509,7 @@ function RouteFields({
})}
</div>
) : null}
{!fields.length ? <div className="rounded-md bg-muted/20 px-3 py-4 text-center text-xs text-foreground/50">尚未添加供应配置</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>
);
}}
@@ -569,7 +580,7 @@ function PricingFields() {
) : null}
</>
) : (
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应配置价格计算。故障切换到更高价格配置时会重新校验余额。</div>
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应线路价格计算。故障切换到更高价格线路时会重新校验余额。</div>
)}
</>
);
@@ -613,7 +624,7 @@ function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
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 })),
routes: item.routes.map((route) => ({ channelModelId: route.channelModelId, enabled: route.enabled, priority: route.priority, weight: route.weight })),
};
}
@@ -643,9 +654,12 @@ 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]> {
function simulationColumns(): ColumnsType<RouteSimulationResult["candidates"][number]> {
return [
{ title: "供应配置", render: (_, candidate) => model.routes.find((route) => route.id === candidate.routeId)?.physicalVariantName || "未知配置" },
{
title: "供应线路",
render: (_, candidate) => `${candidate.channelModelName}(${candidate.channelModelKey})`,
},
{ title: "优先级", dataIndex: "priority", width: 80 },
{ title: "权重", dataIndex: "weight", width: 70 },
{

View File

@@ -66,7 +66,7 @@ export function emptyCapabilitySpec(capability: CapabilityKind): CapabilitySpec
}
function imageSizeOptionValues(values: string[], allowCustom: boolean) {
const concreteValues = Array.from(new Set(values.map((value) => value.trim()).filter((value) => value && value !== "*")));
const concreteValues = Array.from(new Set(values.map((value) => String(normalizeScalar(value)).trim()).filter((value) => value && value !== "*")));
const result = concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
if (allowCustom) result.push("*");
return result;
@@ -437,11 +437,13 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
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">
<div className="flex min-w-0 max-w-full flex-wrap gap-1">
{labels.slice(0, 4).map((label) => (
<Tag key={label}>{label}</Tag>
<Tag key={label} className="max-w-full whitespace-normal break-all text-left leading-5">
{label}
</Tag>
))}
{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}
{labels.length > 4 ? <Tag className="shrink-0">+{labels.length - 4}</Tag> : null}
</div>
);
}

View File

@@ -54,7 +54,7 @@ export default function DrawingEngineSettingsPage() {
const dirty = engine !== current.defaultEngine || tldrawLicenseKey !== (current.tldrawLicenseKey || "");
return (
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器">
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器" scroll>
<div className="pt-4">
<SettingsSectionCard
icon={<Brush className="size-4" />}

View File

@@ -83,7 +83,7 @@ export default function LibTVSettingsPage() {
};
return (
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。">
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。" scroll>
<div className="pt-4">
<SettingsSectionCard
icon={<KeyRound className="size-4" />}

View File

@@ -59,7 +59,7 @@ export default function ResponseInterceptionSettingsPage() {
};
return (
<AdminPageFrame title="模型响应拦截">
<AdminPageFrame title="模型响应拦截" scroll>
<div className="pt-4">
<SettingsSectionCard
layout="stacked"

View File

@@ -162,6 +162,7 @@ export default function RuntimePolicySettingsPage() {
<AdminPageFrame
title="资源与策略"
description="账号配额、任务调度与请求安全策略"
scroll
actions={<div className="flex items-center gap-2"><Button icon={<RotateCcw className="size-4" />} disabled={loading || saving} onClick={reset}>重置</Button><Button icon={<InfinityIcon className="size-4" />} disabled={loading || saving} onClick={() => void useSelfMode()}>自用模式</Button></div>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={() => setDirty(true)}>

View File

@@ -83,7 +83,7 @@ export default function StorageSettingsPage() {
};
return (
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置">
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
<div className="space-y-4 pt-4">
<SettingsSectionCard
layout="stacked"