Files
Jyotisha/frontend/src/components/admin/model-management.tsx
T
Jesse_Chen 945d61fa1c
Independent Staging Quality Gate / validate (push) Failing after 12m38s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(admin): remove redundant confirmations and repair code access
2026-08-16 17:55:15 +08:00

412 lines
19 KiB
TypeScript

"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,
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 };
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 [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 runVersionAction(action: "publish" | "rollback", model: ModelVersion) {
try {
await act(action === "publish"
? { action, versionId: model.id }
: { action, configId: model.configId, targetVersion: model.version },
action === "publish" ? "模型已发布" : "模型已回滚", model.id);
} 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) => <Text type={item.secretConfigured ? undefined : "danger"}>{item.secretConfigured ? "已配置" : "未配置"}</Text> },
{ title: "状态", dataIndex: "enabled", render: (value) => <Text type={value ? undefined : "secondary"}>{value ? "启用" : "停用"}</Text> },
{ 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"]) => modelTierLabels[value] },
{ 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 && <Text>默认模型</Text>}<Text type="secondary">回退模型:{item.fallbackModelId ?? "—"}</Text></Space> },
{ title: "状态", render: (_, item) => <Text>{modelStatusLabels[item.status] ?? item.status} · {item.enabled ? "启用" : "停用"}</Text> },
{ 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={() => void runVersionAction("publish", item)}>发布</Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => void runVersionAction("rollback", 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>
</List>;
}