Files
Jyotisha/frontend/src/components/admin/model-management.tsx
T
Jesse_Chen fc47939ba9
Staging Backend Quality Gate / validate (push) Successful in 14m16s
Staging Backend Quality Gate / publish (push) Successful in 1h2m51s
fix(admin): repair model discovery and simplify model setup
2026-08-08 17:29:17 +08:00

434 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { PlusOutlined } from "@ant-design/icons";
import { useGetIdentity } from "@refinedev/core";
import { List } from "@refinedev/antd";
import {
App,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Switch,
Table,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import { useCallback, useEffect, useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { createDiscoveredModelId } from "./model-id";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
type ProviderType = "openai" | "openai-compatible" | "anthropic";
type Provider = {
id: string;
code: string;
name: string;
providerType: ProviderType;
baseUrl: string | null;
secretConfigured: boolean;
enabled: boolean;
updatedAt: string;
};
type ModelVersion = {
id: string;
configId: string;
modelId: string;
version: number;
providerId: string;
providerCode: string;
label: string;
description: string;
providerModel: string;
modelTier: "standard" | "premium" | "internal";
creditCost: number;
contextWindow: number | null;
inputCostMicrousdPerMillion: number;
outputCostMicrousdPerMillion: number;
enabled: boolean;
isDefault: boolean;
fallbackModelId: string | null;
status: string;
settings: Record<string, unknown>;
createdAt: string;
publishedAt: string | null;
};
type ProviderForm = {
name: string;
providerType: ProviderType;
baseUrl: string | null;
apiKey?: string;
enabled: boolean;
};
type ModelForm = Pick<ModelVersion, "providerId" | "providerModel" | "creditCost" | "enabled" | "isDefault">;
type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[] };
type DiscoveredModel = { id: string; label?: string };
type DiscoveredModelsPayload = { data: DiscoveredModel[] };
type ModelFilters = { q?: string; status?: string };
type VersionAction = { action: "publish" | "rollback"; model: ModelVersion };
const providerTypeLabels: Record<ProviderType, string> = {
openai: "OpenAI 官方",
anthropic: "Anthropic 官方",
"openai-compatible": "OpenAI 兼容接口",
};
const modelTierLabels: Record<ModelVersion["modelTier"], string> = {
standard: "标准",
premium: "高级",
internal: "内部",
};
const modelStatusLabels: Record<string, string> = {
draft: "草稿",
published: "已发布",
retired: "已下线",
};
export default function ModelManagement() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const [providerForm] = Form.useForm<ProviderForm>();
const [modelForm] = Form.useForm<ModelForm>();
const [filterForm] = Form.useForm<ModelFilters>();
const selectedProviderId = Form.useWatch("providerId", modelForm);
const [models, setModels] = useState<ModelVersion[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [loading, setLoading] = useState(true);
const [providerOpen, setProviderOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [editingModel, setEditingModel] = useState<ModelVersion | null>(null);
const [saving, setSaving] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
const [actingId, setActingId] = useState<string | null>(null);
const [versionAction, setVersionAction] = useState<VersionAction | null>(null);
const [filters, setFilters] = useState<ModelFilters>({});
const canWrite = Boolean(identity?.permissions.includes("models.write"));
const canTest = Boolean(identity?.permissions.includes("models.test"));
const canPublish = Boolean(identity?.permissions.includes("models.publish"));
const canRollback = Boolean(identity?.permissions.includes("models.rollback"));
const load = useCallback(async () => {
setLoading(true);
try {
const search = new URLSearchParams({ page: "1", pageSize: "100" });
if (filters.q) search.set("q", filters.q);
if (filters.status) search.set("status", filters.status);
const payload = await adminRequestJson<ModelsPayload>(`/api/admin/models?${search}`);
setModels(payload.data);
setProviders(payload.providers);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型配置失败");
} finally {
setLoading(false);
}
}, [filters.q, filters.status, message]);
useEffect(() => {
const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer);
}, [load]);
function openProvider(provider?: Provider) {
setEditingProvider(provider ?? null);
providerForm.resetFields();
providerForm.setFieldsValue(provider ? {
name: provider.name,
providerType: provider.providerType,
baseUrl: provider.baseUrl,
apiKey: "",
enabled: provider.enabled,
} : {
name: "",
providerType: "openai-compatible",
baseUrl: "https://",
apiKey: "",
enabled: false,
});
setProviderOpen(true);
}
function openModel(model?: ModelVersion, providerId?: string) {
setEditingModel(model ?? null);
setDiscoveredModels(model ? [{ id: model.providerModel, label: model.label }] : []);
modelForm.setFieldsValue(model ? {
providerId: model.providerId,
providerModel: model.providerModel,
creditCost: model.creditCost,
enabled: model.enabled,
isDefault: model.isDefault,
} : {
providerId: providerId ?? providers[0]?.id,
providerModel: "",
creditCost: 1,
enabled: false,
isDefault: false,
});
setModelOpen(true);
}
async function saveProvider(values: ProviderForm) {
const apiKey = values.apiKey?.trim();
setSaving(true);
try {
await adminRequestJson("/api/admin/models", {
method: "POST",
body: JSON.stringify({
action: "saveProvider",
id: editingProvider?.id ?? null,
name: values.name.trim(),
providerType: values.providerType,
baseUrl: values.providerType === "openai-compatible" ? values.baseUrl?.trim() : null,
...(apiKey ? { apiKey } : {}),
enabled: values.enabled,
}),
});
message.success("供应商配置已保存");
setProviderOpen(false);
providerForm.resetFields();
await load();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
async function discoverModels(providerId?: string) {
if (!providerId) return;
setDiscovering(true);
try {
const payload = await adminRequestJson<DiscoveredModelsPayload>("/api/admin/models/discover", {
method: "POST",
body: JSON.stringify({ providerId }),
});
setDiscoveredModels(payload.data);
if (!payload.data.length) message.info("未发现可用模型,请检查供应商配置后重试");
} catch (error) {
message.error(error instanceof Error ? error.message : "获取模型列表失败");
} finally {
setDiscovering(false);
}
}
async function saveModel(values: ModelForm) {
setSaving(true);
try {
const discovered = discoveredModels.find((item) => item.id === values.providerModel);
const modelId = editingModel?.modelId ?? await createDiscoveredModelId(values.providerId, values.providerModel);
await adminRequestJson("/api/admin/models", {
method: "POST",
body: JSON.stringify({
action: "saveDraft",
modelId,
versionId: editingModel?.id ?? null,
providerId: values.providerId,
label: editingModel?.label ?? (discovered?.label?.trim() || values.providerModel).slice(0, 60),
providerModel: values.providerModel.trim(),
creditCost: values.creditCost,
enabled: values.enabled,
description: editingModel?.description ?? "",
modelTier: editingModel?.modelTier ?? "standard",
contextWindow: editingModel?.contextWindow ?? null,
inputCostMicrousdPerMillion: editingModel?.inputCostMicrousdPerMillion ?? 0,
outputCostMicrousdPerMillion: editingModel?.outputCostMicrousdPerMillion ?? 0,
isDefault: values.isDefault,
fallbackModelId: editingModel?.fallbackModelId ?? null,
settings: editingModel?.settings ?? {},
}),
});
message.success("模型草稿已保存");
setModelOpen(false);
await load();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
async function act(body: Record<string, unknown>, success: string, id: string) {
setActingId(id);
try {
await adminRequestJson("/api/admin/models", { method: "POST", body: JSON.stringify(body) });
message.success(success);
await load();
} finally {
setActingId(null);
}
}
async function submitVersionAction() {
if (!versionAction) return;
const { action, model } = versionAction;
try {
await act(action === "publish"
? { action, versionId: model.id }
: { action, configId: model.configId, targetVersion: model.version },
action === "publish" ? "模型已发布" : "模型已回滚", model.id);
setVersionAction(null);
} catch (error) {
message.error(error instanceof Error ? error.message : action === "publish" ? "发布失败" : "回滚失败");
}
}
async function testVersion(item: ModelVersion) {
try {
await act({ action: "test", versionId: item.id }, "连接测试通过,可在短有效期内发布", item.id);
} catch (error) {
message.error(error instanceof Error ? error.message : "连接测试失败");
}
}
const providerColumns: TableColumnsType<Provider> = [
{ title: "供应商", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code}</Text></Space> },
{ title: "类型", dataIndex: "providerType", render: (value: ProviderType) => providerTypeLabels[value] },
{ title: "地址", dataIndex: "baseUrl", render: (value, item) => value ?? (item.providerType === "anthropic" ? "Anthropic 官方" : "OpenAI 官方") },
{ title: "密钥状态", render: (_, item) => <Tag color={item.secretConfigured ? "green" : "red"}>{item.secretConfigured ? "已配置" : "未配置"}</Tag> },
{ title: "状态", dataIndex: "enabled", render: (value) => value ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
{ title: "操作", render: (_, item) => canWrite ? <Space><Button type="link" onClick={() => openProvider(item)}></Button><Button type="link" onClick={() => openModel(undefined, item.id)}></Button></Space> : null },
];
const modelColumns: TableColumnsType<ModelVersion> = [
{ title: "模型", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.label}</Text><Text type="secondary">{item.modelId} · v{item.version}</Text></Space> },
{ title: "供应商模型", render: (_, item) => `${item.providerCode} / ${item.providerModel}` },
{ title: "档位", dataIndex: "modelTier", render: (value: ModelVersion["modelTier"]) => <Tag>{modelTierLabels[value]}</Tag> },
{ title: "点数", dataIndex: "creditCost" },
{ title: "成本/百万 Token", render: (_, item) => `$${(item.inputCostMicrousdPerMillion / 1_000_000).toFixed(4)} / $${(item.outputCostMicrousdPerMillion / 1_000_000).toFixed(4)}` },
{ title: "路由", render: (_, item) => <Space direction="vertical" size={0}>{item.isDefault && <Tag color="blue"></Tag>}<Text type="secondary">退{item.fallbackModelId ?? "—"}</Text></Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{modelStatusLabels[item.status] ?? item.status}</Tag>{item.enabled ? <Tag color="blue"></Tag> : <Tag></Tag>}</Space> },
{ title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openModel(item)}>稿</Button>}
{canTest && <Button type="link" loading={actingId === item.id} onClick={() => void testVersion(item)}></Button>}
{canPublish && item.status !== "published" && <Button type="link" disabled={item.isDefault && !item.enabled} title={item.isDefault && !item.enabled ? "默认模型必须先启用" : "发布前必须先通过此版本的短期连接测试"} loading={actingId === item.id} onClick={() => setVersionAction({ action: "publish", model: item })}></Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => setVersionAction({ action: "rollback", model: item })}></Button>}
</Space>,
},
];
return <List title="模型配置中心">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Card title="供应商" extra={canWrite ? <Button icon={<PlusOutlined />} onClick={() => openProvider()}></Button> : null}>
<Table rowKey="id" columns={providerColumns} dataSource={providers} loading={loading} pagination={false} scroll={{ x: "max-content" }} />
</Card>
<Card title="模型版本" extra={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openModel()} disabled={!providers.length} title={providers.length ? undefined : "请先新增供应商"}></Button> : null}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form<ModelFilters>
form={filterForm}
layout="inline"
style={{ rowGap: 8 }}
onFinish={(values) => setFilters({
q: values.q?.trim() || undefined,
status: values.status || undefined,
})}
>
<Form.Item name="q" label="搜索">
<Input allowClear placeholder="模型 ID、名称或供应商" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
placeholder="全部状态"
style={{ minWidth: 140 }}
options={Object.entries(modelStatusLabels).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => {
filterForm.resetFields();
setFilters({});
}}></Button>
</Space>
</Form.Item>
</Form>
<Table rowKey="id" columns={modelColumns} dataSource={models} loading={loading} pagination={{ pageSize: 20 }} scroll={{ x: "max-content" }} />
</Space>
</Card>
</Space>
<Modal title={editingProvider ? "编辑供应商" : "新增供应商"} open={providerOpen} okText="保存" cancelText="取消" confirmLoading={saving} onOk={() => providerForm.submit()} onCancel={() => { setProviderOpen(false); providerForm.resetFields(); }} destroyOnHidden>
<Form<ProviderForm> form={providerForm} layout="vertical" onFinish={saveProvider}>
<Row gutter={16}><Col xs={24} md={12}><Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item label="代码预览(服务端生成)"><Input readOnly value={editingProvider?.code ?? "保存后由服务端自动生成"} /></Form.Item></Col></Row>
<Form.Item name="providerType" label="类型" rules={[{ required: true }]}><Select options={Object.entries(providerTypeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item>
<Form.Item noStyle shouldUpdate={(before, after) => before.providerType !== after.providerType}>{({ getFieldValue }) => getFieldValue("providerType") === "openai-compatible" ? <Form.Item name="baseUrl" label="Base URL" rules={[{ required: true }, { type: "url" }]}><Input /></Form.Item> : null}</Form.Item>
<Form.Item name="apiKey" label="API Key" rules={[{ required: !editingProvider, whitespace: true, message: "请输入 API Key" }]} extra={editingProvider ? "留空表示保留现有 API Key;系统不会回显已保存的密钥。" : "密钥只会提交给服务端,不会在后台回显。"}><Input.Password autoComplete="new-password" placeholder={editingProvider ? "留空保留现有密钥" : "请输入 API Key"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
<Modal title={editingModel ? `编辑 ${editingModel.modelId}` : "新增模型草稿"} open={modelOpen} width={720} okText="保存草稿" cancelText="取消" confirmLoading={saving} onOk={() => modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden>
<Form<ModelForm> form={modelForm} layout="vertical" onFinish={saveModel}>
<Form.Item label="供应商" required>
<Space.Compact block>
<Form.Item name="providerId" noStyle rules={[{ required: true, message: "请选择供应商" }]}>
<Select
onChange={() => {
setDiscoveredModels([]);
modelForm.setFieldValue("providerModel", "");
}}
options={providers.map((item) => ({ value: item.id, label: `${item.name} (${item.code})` }))}
/>
</Form.Item>
<Button loading={discovering} disabled={!selectedProviderId} onClick={() => void discoverModels(selectedProviderId)}></Button>
</Space.Compact>
</Form.Item>
<Form.Item name="providerModel" label="模型" rules={[{ required: true, message: "请从供应商模型列表中选择" }]} extra="获取列表后选择供应商提供的模型。">
<Select
showSearch
optionFilterProp="label"
placeholder={selectedProviderId ? "获取并选择模型" : "请先选择供应商"}
disabled={!selectedProviderId || !discoveredModels.length}
options={discoveredModels.map((item) => ({ value: item.id, label: item.label ? `${item.label} (${item.id})` : item.id }))}
/>
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}><Form.Item name="creditCost" label="单次点数" rules={[{ required: true }]}><InputNumber min={1} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col xs={24} md={12}>
<Space size="large">
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="isDefault" label="默认模型" valuePropName="checked" dependencies={["enabled"]} rules={[({ getFieldValue }) => ({ validator(_, value) { return value && !getFieldValue("enabled") ? Promise.reject(new Error("默认模型必须启用")) : Promise.resolve(); } })]}><Switch /></Form.Item>
</Space>
</Col>
</Row>
</Form>
</Modal>
<Modal
open={Boolean(versionAction)}
title={versionAction?.action === "rollback" ? `回滚到 v${versionAction.model.version}` : "发布模型版本"}
okText={versionAction?.action === "rollback" ? "确认回滚" : "确认发布"}
okButtonProps={{ danger: versionAction?.action === "rollback" }}
confirmLoading={Boolean(actingId)}
onCancel={() => setVersionAction(null)}
onOk={() => void submitVersionAction()}
destroyOnHidden
>
<Text>
{versionAction?.action === "rollback"
? "确认将此历史版本恢复为新的已发布版本?"
: "确认发布此模型版本?"}
</Text>
</Modal>
</List>;
}