455 lines
18 KiB
TypeScript
455 lines
18 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import {
|
||
Button, Divider, Drawer, Form, Input, InputNumber, message, Popconfirm, Select, Space, Switch, Table, Tag,
|
||
Tooltip,
|
||
} from 'antd'
|
||
import {
|
||
ApiOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined,
|
||
} from '@ant-design/icons'
|
||
import {
|
||
modelConfigsApi, type ModelCapability, type ModelConfig, type ModelConfigPayload, type ModelModality,
|
||
type ModelProtocol,
|
||
} from '../api'
|
||
import PageWrapper from '../components/PageWrapper'
|
||
import { formatDateTime } from '../utils/date'
|
||
import { notifyModelConfigsChanged } from '../utils/modelConfigEvents'
|
||
|
||
const capabilityLabels: Record<ModelCapability, string> = {
|
||
chat: '对话',
|
||
embedding: '向量',
|
||
moderation: '内容审核',
|
||
}
|
||
|
||
const capabilityColors: Record<ModelCapability, string> = {
|
||
chat: 'blue',
|
||
embedding: 'green',
|
||
moderation: 'orange',
|
||
}
|
||
|
||
const modalityLabels: Record<ModelModality, string> = {
|
||
text: '文本',
|
||
image: '图像',
|
||
audio: '音频',
|
||
video: '视频',
|
||
}
|
||
|
||
const modalityColors: Record<ModelModality, string> = {
|
||
text: 'default',
|
||
image: 'cyan',
|
||
audio: 'magenta',
|
||
video: 'purple',
|
||
}
|
||
|
||
const modalityOptions = Object.entries(modalityLabels).map(([value, label]) => ({ value, label }))
|
||
|
||
const featureLabels: Array<[keyof Pick<
|
||
ModelConfig,
|
||
'supports_streaming' | 'supports_tool_calling' | 'supports_structured_output' | 'supports_reasoning'
|
||
>, string]> = [
|
||
['supports_streaming', '流式'],
|
||
['supports_tool_calling', '工具调用'],
|
||
['supports_structured_output', '结构化输出'],
|
||
['supports_reasoning', '推理'],
|
||
]
|
||
|
||
const formatTokens = (value: number | null) => value ? new Intl.NumberFormat('zh-CN').format(value) : '-'
|
||
|
||
const protocolOptions: Record<ModelProtocol, {
|
||
label: string
|
||
capabilities: ModelCapability[]
|
||
endpointPlaceholder: string
|
||
}> = {
|
||
openai_compatible: {
|
||
label: 'OpenAI 兼容',
|
||
capabilities: ['chat', 'embedding', 'moderation'],
|
||
endpointPlaceholder: 'https://api.example.com/v1/chat/completions',
|
||
},
|
||
anthropic: {
|
||
label: 'Anthropic Messages',
|
||
capabilities: ['chat'],
|
||
endpointPlaceholder: 'https://api.anthropic.com/v1/messages',
|
||
},
|
||
google_gemini: {
|
||
label: 'Google Gemini',
|
||
capabilities: ['chat'],
|
||
endpointPlaceholder: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent',
|
||
},
|
||
dashscope: {
|
||
label: 'DashScope 原生',
|
||
capabilities: ['chat'],
|
||
endpointPlaceholder: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation',
|
||
},
|
||
}
|
||
|
||
export default function ModelConfigsPage() {
|
||
const [configs, setConfigs] = useState<ModelConfig[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
const [editing, setEditing] = useState<ModelConfig | null>(null)
|
||
const [testingId, setTestingId] = useState<string | null>(null)
|
||
const [capability, setCapability] = useState<ModelCapability | undefined>()
|
||
const [enabled, setEnabled] = useState<boolean | undefined>()
|
||
const [form] = Form.useForm<ModelConfigPayload>()
|
||
const selectedProtocol = Form.useWatch('provider', form) || 'openai_compatible'
|
||
const selectedProtocolMeta = protocolOptions[selectedProtocol]
|
||
|
||
const load = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const response = await modelConfigsApi.list({ capability, enabled })
|
||
setConfigs(response.data)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => { load() }, [capability, enabled])
|
||
|
||
const openCreate = () => {
|
||
setEditing(null)
|
||
form.resetFields()
|
||
form.setFieldsValue({
|
||
provider: 'openai_compatible',
|
||
capability: 'chat',
|
||
vendor_name: '',
|
||
input_modalities: ['text'],
|
||
output_modalities: ['text'],
|
||
context_window: null,
|
||
max_output_tokens: null,
|
||
supports_streaming: false,
|
||
supports_tool_calling: false,
|
||
supports_structured_output: false,
|
||
supports_reasoning: false,
|
||
region: '',
|
||
documentation_url: null,
|
||
enabled: true,
|
||
is_default: false,
|
||
description: '',
|
||
})
|
||
setDrawerOpen(true)
|
||
}
|
||
|
||
const openEdit = (config: ModelConfig) => {
|
||
setEditing(config)
|
||
form.setFieldsValue({
|
||
name: config.name,
|
||
provider: config.provider,
|
||
capability: config.capability,
|
||
endpoint_url: config.endpoint_url,
|
||
model_name: config.model_name,
|
||
vendor_name: config.vendor_name,
|
||
input_modalities: config.input_modalities,
|
||
output_modalities: config.output_modalities,
|
||
context_window: config.context_window,
|
||
max_output_tokens: config.max_output_tokens,
|
||
supports_streaming: config.supports_streaming,
|
||
supports_tool_calling: config.supports_tool_calling,
|
||
supports_structured_output: config.supports_structured_output,
|
||
supports_reasoning: config.supports_reasoning,
|
||
region: config.region,
|
||
documentation_url: config.documentation_url,
|
||
api_key: null,
|
||
clear_api_key: false,
|
||
enabled: config.enabled,
|
||
is_default: config.is_default,
|
||
description: config.description,
|
||
})
|
||
setDrawerOpen(true)
|
||
}
|
||
|
||
const changeProtocol = (provider: ModelProtocol) => {
|
||
const supportedCapabilities = protocolOptions[provider].capabilities
|
||
if (!supportedCapabilities.includes(form.getFieldValue('capability'))) {
|
||
form.setFieldValue('capability', supportedCapabilities[0])
|
||
}
|
||
}
|
||
|
||
const submit = async () => {
|
||
const values = await form.validateFields()
|
||
const payload: ModelConfigPayload = {
|
||
...values,
|
||
api_key: values.api_key || null,
|
||
model_name: values.model_name || null,
|
||
vendor_name: values.vendor_name || '',
|
||
region: values.region || '',
|
||
documentation_url: values.documentation_url || null,
|
||
description: values.description || '',
|
||
}
|
||
if (editing) {
|
||
await modelConfigsApi.update(editing.id, payload)
|
||
message.success('模型配置已更新')
|
||
} else {
|
||
await modelConfigsApi.create(payload)
|
||
message.success('模型配置已创建')
|
||
}
|
||
notifyModelConfigsChanged()
|
||
setDrawerOpen(false)
|
||
load()
|
||
}
|
||
|
||
const testConnection = async (config: ModelConfig) => {
|
||
setTestingId(config.id)
|
||
try {
|
||
const response = await modelConfigsApi.test(config.id)
|
||
if (response.data.ok) message.success(`${config.name} 连接成功`)
|
||
else message.warning(response.data.message)
|
||
} finally {
|
||
setTestingId(null)
|
||
}
|
||
}
|
||
|
||
const remove = async (config: ModelConfig) => {
|
||
await modelConfigsApi.delete(config.id)
|
||
message.success('模型配置已删除')
|
||
notifyModelConfigsChanged()
|
||
load()
|
||
}
|
||
|
||
const columns = [
|
||
{
|
||
title: '配置名称', dataIndex: 'name', key: 'name', width: 220,
|
||
render: (value: string, record: ModelConfig) => (
|
||
<div style={{ minWidth: 0 }}>
|
||
<Space size={6}>
|
||
<Tooltip title={record.description || undefined}>
|
||
<span style={{ fontWeight: 500 }}>{value}</span>
|
||
</Tooltip>
|
||
{record.is_default && <Tag color="gold" style={{ margin: 0 }}>默认</Tag>}
|
||
</Space>
|
||
{(record.vendor_name || record.region) && (
|
||
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 3 }}>
|
||
{[record.vendor_name, record.region].filter(Boolean).join(' · ')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '协议', dataIndex: 'provider', key: 'provider', width: 150,
|
||
render: (value: ModelProtocol) => <Tag>{protocolOptions[value]?.label || value}</Tag>,
|
||
},
|
||
{
|
||
title: '能力', dataIndex: 'capability', key: 'capability', width: 110,
|
||
render: (value: ModelCapability) => <Tag color={capabilityColors[value]}>{capabilityLabels[value]}</Tag>,
|
||
},
|
||
{
|
||
title: '输入 / 输出', key: 'modalities', width: 230,
|
||
render: (_: unknown, record: ModelConfig) => (
|
||
<Space direction="vertical" size={3}>
|
||
<Space size={4} wrap>
|
||
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}>输入</span>
|
||
{record.input_modalities.map((value) => (
|
||
<Tag key={`input-${value}`} color={modalityColors[value]} style={{ margin: 0 }}>
|
||
{modalityLabels[value]}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
<Space size={4} wrap>
|
||
<span style={{ color: '#8c8c8c', fontSize: 12, width: 28 }}>输出</span>
|
||
{record.output_modalities.map((value) => (
|
||
<Tag key={`output-${value}`} color={modalityColors[value]} style={{ margin: 0 }}>
|
||
{modalityLabels[value]}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '模型', dataIndex: 'model_name', key: 'model_name', width: 180, ellipsis: true,
|
||
render: (value: string | null) => value || '-',
|
||
},
|
||
{
|
||
title: '规格', key: 'limits', width: 170,
|
||
render: (_: unknown, record: ModelConfig) => (
|
||
<div style={{ fontSize: 12, lineHeight: 1.7 }}>
|
||
<div><span style={{ color: '#8c8c8c' }}>上下文 </span>{formatTokens(record.context_window)}</div>
|
||
<div><span style={{ color: '#8c8c8c' }}>最大输出 </span>{formatTokens(record.max_output_tokens)}</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '特性', key: 'features', width: 210,
|
||
render: (_: unknown, record: ModelConfig) => {
|
||
const enabledFeatures = featureLabels.filter(([key]) => record[key])
|
||
return enabledFeatures.length ? (
|
||
<Space size={[4, 4]} wrap>
|
||
{enabledFeatures.map(([key, label]) => <Tag key={key} style={{ margin: 0 }}>{label}</Tag>)}
|
||
</Space>
|
||
) : '-'
|
||
},
|
||
},
|
||
{ title: 'Endpoint', dataIndex: 'endpoint_url', key: 'endpoint_url', width: 260, ellipsis: true },
|
||
{
|
||
title: '凭据', dataIndex: 'has_api_key', key: 'has_api_key', width: 90,
|
||
render: (value: boolean) => value ? <Tag color="success">已配置</Tag> : <Tag>无</Tag>,
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'enabled', key: 'enabled', width: 90,
|
||
render: (value: boolean) => value ? <Tag color="success">启用</Tag> : <Tag>停用</Tag>,
|
||
},
|
||
{
|
||
title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170,
|
||
render: (value: string | null) => formatDateTime(value),
|
||
},
|
||
{
|
||
title: '操作', key: 'actions', width: 145, fixed: 'right' as const,
|
||
render: (_: unknown, record: ModelConfig) => (
|
||
<Space size={2}>
|
||
<Tooltip title="连接测试">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<ApiOutlined />}
|
||
loading={testingId === record.id}
|
||
disabled={!record.enabled}
|
||
onClick={() => testConnection(record)}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="编辑">
|
||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||
</Tooltip>
|
||
<Popconfirm title="确认删除该模型配置?" onConfirm={() => remove(record)}>
|
||
<Tooltip title="删除">
|
||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||
</Tooltip>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
return (
|
||
<PageWrapper
|
||
title="模型配置"
|
||
description="统一管理评测工程使用的外部模型连接"
|
||
inline
|
||
fullHeight
|
||
extra={
|
||
<Space>
|
||
<Select
|
||
allowClear
|
||
placeholder="全部能力"
|
||
style={{ width: 120 }}
|
||
value={capability}
|
||
onChange={setCapability}
|
||
options={Object.entries(capabilityLabels).map(([value, label]) => ({ value, label }))}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
placeholder="全部状态"
|
||
style={{ width: 110 }}
|
||
value={enabled}
|
||
onChange={setEnabled}
|
||
options={[{ value: true, label: '启用' }, { value: false, label: '停用' }]}
|
||
/>
|
||
<Tooltip title="刷新"><Button icon={<ReloadOutlined />} onClick={load} /></Tooltip>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<div style={{ height: '100%', minHeight: 0, overflow: 'hidden' }}>
|
||
<Table
|
||
dataSource={configs}
|
||
columns={columns}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (total) => `共 ${total} 个` }}
|
||
scroll={{ x: 1800, y: 'calc(100vh - 200px)' }}
|
||
style={{ height: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<Drawer
|
||
title={editing ? '编辑模型配置' : '新增模型配置'}
|
||
width="min(680px, 100vw)"
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
extra={<Button type="primary" icon={<CheckCircleOutlined />} onClick={submit}>保存</Button>}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Divider orientation="left" plain style={{ marginTop: 0 }}>基础信息</Divider>
|
||
<Form.Item name="name" label="配置名称" rules={[{ required: true, message: '请输入配置名称' }]}>
|
||
<Input placeholder="例如:评估对话模型" />
|
||
</Form.Item>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<Form.Item name="vendor_name" label="厂商 / 平台">
|
||
<Input placeholder="例如:阿里云百炼" />
|
||
</Form.Item>
|
||
<Form.Item name="region" label="部署区域">
|
||
<Input placeholder="例如:华北 2(北京)" />
|
||
</Form.Item>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<Form.Item name="input_modalities" label="输入模态" rules={[{ required: true, message: '请选择输入模态' }]}>
|
||
<Select mode="multiple" options={modalityOptions} />
|
||
</Form.Item>
|
||
<Form.Item name="output_modalities" label="输出模态" rules={[{ required: true, message: '请选择输出模态' }]}>
|
||
<Select mode="multiple" options={modalityOptions} />
|
||
</Form.Item>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<Form.Item name="context_window" label="上下文窗口(Token)">
|
||
<InputNumber min={1} precision={0} style={{ width: '100%' }} placeholder="例如:128000" />
|
||
</Form.Item>
|
||
<Form.Item name="max_output_tokens" label="最大输出(Token)">
|
||
<InputNumber min={1} precision={0} style={{ width: '100%' }} placeholder="例如:8192" />
|
||
</Form.Item>
|
||
</div>
|
||
<Form.Item name="documentation_url" label="官方文档地址" rules={[{ type: 'url', message: '请输入有效 URL' }]}>
|
||
<Input placeholder="https://..." />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="说明">
|
||
<Input.TextArea rows={3} placeholder="该配置的使用范围或注意事项" />
|
||
</Form.Item>
|
||
|
||
<Divider orientation="left" plain>模型特性</Divider>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 20px' }}>
|
||
<Form.Item name="supports_streaming" label="流式输出" valuePropName="checked"><Switch /></Form.Item>
|
||
<Form.Item name="supports_tool_calling" label="工具调用" valuePropName="checked"><Switch /></Form.Item>
|
||
<Form.Item name="supports_structured_output" label="结构化输出" valuePropName="checked"><Switch /></Form.Item>
|
||
<Form.Item name="supports_reasoning" label="推理模型" valuePropName="checked"><Switch /></Form.Item>
|
||
</div>
|
||
|
||
<Divider orientation="left" plain>连接设置</Divider>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<Form.Item name="provider" label="协议" rules={[{ required: true }]}>
|
||
<Select
|
||
onChange={changeProtocol}
|
||
options={Object.entries(protocolOptions).map(([value, option]) => ({ value, label: option.label }))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="capability" label="能力" rules={[{ required: true }]}>
|
||
<Select
|
||
options={selectedProtocolMeta.capabilities.map((value) => ({ value, label: capabilityLabels[value] }))}
|
||
/>
|
||
</Form.Item>
|
||
</div>
|
||
<Form.Item name="endpoint_url" label="完整 Endpoint URL" rules={[{ required: true, message: '请输入 Endpoint URL' }, { type: 'url', message: '请输入有效 URL' }]}>
|
||
<Input placeholder={selectedProtocolMeta.endpointPlaceholder} />
|
||
</Form.Item>
|
||
<Form.Item shouldUpdate noStyle>
|
||
{() => form.getFieldValue('capability') !== 'moderation' && (
|
||
<Form.Item name="model_name" label="模型名称" rules={[{ required: true, message: '请输入模型名称' }]}>
|
||
<Input placeholder="例如:gpt-4o-mini" />
|
||
</Form.Item>
|
||
)}
|
||
</Form.Item>
|
||
<Form.Item name="api_key" label="API Key" extra={editing?.has_api_key ? '已配置;留空将保留现有凭据' : undefined}>
|
||
<Input.Password autoComplete="new-password" placeholder={editing?.has_api_key ? '留空保留现有凭据' : '可选'} />
|
||
</Form.Item>
|
||
{editing?.has_api_key && (
|
||
<Form.Item name="clear_api_key" label="清除现有凭据" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
)}
|
||
<Space size={24}>
|
||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||
<Form.Item name="is_default" label="设为该能力默认配置" valuePropName="checked"><Switch /></Form.Item>
|
||
</Space>
|
||
</Form>
|
||
</Drawer>
|
||
</PageWrapper>
|
||
)
|
||
}
|