412 lines
19 KiB
TypeScript
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>;
|
|
}
|