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

@@ -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>
);
}