434 lines
20 KiB
TypeScript
434 lines
20 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,
|
||
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>;
|
||
}
|