AgentEvalTool/frontend/web/src/pages/ModelConfigs.tsx

455 lines
18 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.

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>
)
}