nex_docus/frontend/src/pages/System/ModelConfigs.jsx

699 lines
20 KiB
React
Raw Normal View History

2026-04-08 17:03:57 +00:00
import { useEffect, useState } from 'react'
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Tag,
} from 'antd'
import {
CloudServerOutlined,
ExperimentOutlined,
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons'
import {
createLLMModelConfig,
deleteLLMModelConfig,
getLLMModelConfigDetail,
getLLMModelConfigs,
getLLMProviderCatalog,
testLLMModelConfig,
updateLLMModelConfig,
updateLLMModelConfigStatus,
} from '@/api/llmModelConfigs'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import Toast from '@/components/Toast/Toast'
import './ModelConfigs.css'
import '@/pages/System/AdminPages.css'
const { Search, TextArea } = Input
function buildModelCode(provider, llmModelName) {
const providerPart = (provider || 'custom').trim().toLowerCase()
const modelPart = (llmModelName || '').trim().toLowerCase()
const sanitized = []
let previousSeparator = false
for (const char of modelPart) {
if (/[a-z0-9]/.test(char)) {
sanitized.push(char)
previousSeparator = false
} else if (!previousSeparator) {
sanitized.push('_')
previousSeparator = true
}
}
const suffix = sanitized.join('').replace(/^_+|_+$/g, '') || 'model'
return `llm_${providerPart}_${suffix}`
}
function buildModelName(providerMeta, provider, llmModelName) {
const label = providerMeta?.label || provider || '自定义模型'
const modelPart = (llmModelName || '').trim()
if (!modelPart) {
return label
}
return `${label} ${modelPart}`
}
function formatDateTime(value) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN')
}
function ModelConfigs() {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [testing, setTesting] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [providerFilter, setProviderFilter] = useState(undefined)
const [statusFilter, setStatusFilter] = useState(undefined)
const [configs, setConfigs] = useState([])
const [providerCatalog, setProviderCatalog] = useState([])
const [modalVisible, setModalVisible] = useState(false)
const [editingConfigId, setEditingConfigId] = useState(null)
const [autoFillFlags, setAutoFillFlags] = useState({
endpointUrl: true,
modelName: true,
modelCode: true,
})
const providerValue = Form.useWatch('provider', form)
const llmModelNameValue = Form.useWatch('llm_model_name', form)
useEffect(() => {
loadProviderCatalog()
}, [])
useEffect(() => {
loadConfigs()
}, [page, pageSize, keyword, providerFilter, statusFilter])
useEffect(() => {
if (!modalVisible || !providerValue) {
return
}
const providerMeta = providerCatalog.find((item) => item.value === providerValue)
if (!providerMeta) {
return
}
const currentValues = form.getFieldsValue()
const nextValues = {}
if (autoFillFlags.endpointUrl) {
const nextEndpointUrl = providerMeta.default_endpoint_url || ''
if (nextEndpointUrl !== currentValues.endpoint_url) {
nextValues.endpoint_url = nextEndpointUrl
}
}
if (llmModelNameValue) {
if (autoFillFlags.modelName) {
const nextModelName = buildModelName(providerMeta, providerValue, llmModelNameValue)
if (nextModelName !== currentValues.model_name) {
nextValues.model_name = nextModelName
}
}
if (autoFillFlags.modelCode) {
const nextModelCode = buildModelCode(providerValue, llmModelNameValue)
if (nextModelCode !== currentValues.model_code) {
nextValues.model_code = nextModelCode
}
}
}
if (Object.keys(nextValues).length > 0) {
form.setFieldsValue(nextValues)
}
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, form])
const loadProviderCatalog = async () => {
try {
const res = await getLLMProviderCatalog()
setProviderCatalog(res.data || [])
} catch (error) {
console.error('Load LLM provider catalog error:', error)
Toast.error('加载提供方列表失败')
}
}
const loadConfigs = async () => {
try {
setLoading(true)
const params = {
page,
page_size: pageSize,
}
if (keyword) params.keyword = keyword
if (providerFilter) params.provider = providerFilter
if (statusFilter !== undefined) params.is_active = statusFilter
const res = await getLLMModelConfigs(params)
setConfigs(res.data || [])
setTotal(res.total || 0)
} catch (error) {
console.error('Load llm model configs error:', error)
Toast.error('加载模型配置失败')
} finally {
setLoading(false)
}
}
const getProviderMeta = (provider) => providerCatalog.find((item) => item.value === provider)
const openCreateModal = () => {
const defaultProvider = providerCatalog[0]?.value || 'openai'
const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || ''
setEditingConfigId(null)
setAutoFillFlags({
endpointUrl: true,
modelName: true,
modelCode: true,
})
form.setFieldsValue({
provider: defaultProvider,
endpoint_url: defaultEndpointUrl,
llm_timeout: 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: 2048,
is_active: true,
is_default: false,
description: '',
llm_system_prompt: '',
model_name: '',
model_code: '',
llm_model_name: '',
api_key: '',
})
setModalVisible(true)
}
const openEditModal = async (record) => {
try {
const res = await getLLMModelConfigDetail(record.config_id)
const detail = res.data
const providerMeta = getProviderMeta(detail.provider)
setEditingConfigId(record.config_id)
setAutoFillFlags({
endpointUrl: !detail.endpoint_url || detail.endpoint_url === (providerMeta?.default_endpoint_url || ''),
modelName: !detail.model_name || detail.model_name === buildModelName(providerMeta, detail.provider, detail.llm_model_name),
modelCode: !detail.model_code || detail.model_code === buildModelCode(detail.provider, detail.llm_model_name),
})
form.setFieldsValue({
...detail,
api_key: detail.api_key || '',
})
setModalVisible(true)
} catch (error) {
console.error('Load llm model config detail error:', error)
Toast.error('加载模型配置详情失败')
}
}
const closeModal = () => {
setModalVisible(false)
setEditingConfigId(null)
form.resetFields()
}
const handleSubmit = async (values) => {
try {
setSubmitting(true)
if (editingConfigId) {
await updateLLMModelConfig(editingConfigId, values)
Toast.success('模型配置更新成功')
} else {
await createLLMModelConfig(values)
Toast.success('模型配置创建成功')
}
closeModal()
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '保存模型配置失败')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (record) => {
try {
await deleteLLMModelConfig(record.config_id)
Toast.success('模型配置删除成功')
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '删除模型配置失败')
}
}
const handleStatusChange = async (record, checked) => {
try {
await updateLLMModelConfigStatus(record.config_id, checked)
Toast.success(checked ? '模型已启用' : '模型已停用')
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '更新状态失败')
}
}
const showTestResult = (result) => {
Modal.info({
title: '模型测试成功',
width: 620,
content: (
<div className="model-config-test-result">
<div>提供方{getProviderMeta(result.provider)?.label || result.provider}</div>
<div>模型{result.llm_model_name}</div>
<div>延迟{result.latency_ms} ms</div>
<div>返回预览{result.preview || '模型已成功返回,但未提取到文本内容。'}</div>
</div>
),
})
}
const handleFormTest = async () => {
try {
const values = await form.validateFields()
setTesting(true)
const res = await testLLMModelConfig(values)
showTestResult(res.data)
} catch (error) {
if (error?.errorFields) {
return
}
Toast.error(error.response?.data?.detail || '模型测试失败')
} finally {
setTesting(false)
}
}
const columns = [
{
title: '模型名称',
dataIndex: 'model_name',
key: 'model_name',
width: 240,
render: (_, record) => (
<Space direction="vertical" size={2}>
<span>{record.model_name}</span>
<span className="model-config-code">{record.model_code}</span>
</Space>
),
},
{
title: '提供方',
dataIndex: 'provider',
key: 'provider',
width: 140,
render: (provider) => (
<Tag color="blue">{getProviderMeta(provider)?.label || provider || '-'}</Tag>
),
},
{
title: '模型标识',
dataIndex: 'llm_model_name',
key: 'llm_model_name',
width: 180,
},
{
title: 'Base URL',
dataIndex: 'endpoint_url',
key: 'endpoint_url',
ellipsis: true,
render: (value) => value || '-',
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 100,
render: (value, record) => (
<Switch
checked={value}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => handleStatusChange(record, checked)}
/>
),
},
{
title: '默认',
dataIndex: 'is_default',
key: 'is_default',
width: 90,
render: (value) => (
value ? <Tag color="gold">默认</Tag> : <Tag></Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 180,
render: (value) => formatDateTime(value),
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 170,
render: (_, record) => (
<Space size="small" className="model-config-actions">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
>
编辑
</Button>
<Popconfirm
title="确认删除该模型配置?"
description="删除后将无法恢复。"
okText="删除"
cancelText="取消"
onConfirm={() => handleDelete(record)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除
</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div className="admin-page">
<PageHeader
title="模型配置"
description="管理系统可用的大模型提供方、默认接口地址、参数模板与连通性测试。"
icon={<CloudServerOutlined />}
extra={(
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
>
新增模型
</Button>
)}
/>
<Card className="admin-card model-config-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Search
allowClear
placeholder="搜索模型名称、编码或模型标识"
value={keyword}
style={{ width: 320 }}
onChange={(event) => {
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
<Select
allowClear
placeholder="筛选提供方"
style={{ width: 180 }}
value={providerFilter}
options={providerCatalog.map((item) => ({
label: item.label,
value: item.value,
}))}
onChange={(value) => {
setPage(1)
setProviderFilter(value)
}}
/>
<Select
allowClear
placeholder="筛选状态"
style={{ width: 140 }}
value={statusFilter}
options={[
{ label: '启用', value: true },
{ label: '停用', value: false },
]}
onChange={(value) => {
setPage(1)
setStatusFilter(value)
}}
/>
</div>
<div className="admin-toolbar-right">
<Button icon={<ReloadOutlined />} onClick={loadConfigs}>
刷新
</Button>
</div>
</div>
<ListTable
rowKey="config_id"
className="model-config-table"
loading={loading}
columns={columns}
dataSource={configs}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
showTotal: (value) => `${value}`,
}}
/>
</Card>
<Modal
title={editingConfigId ? '编辑模型配置' : '新增模型配置'}
open={modalVisible}
width={860}
className="model-config-modal"
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnClose
onCancel={closeModal}
onOk={() => form.submit()}
confirmLoading={submitting}
okText={editingConfigId ? '保存修改' : '创建'}
cancelText="取消"
styles={{
body: {
maxHeight: 'calc(80vh - 180px)',
overflowY: 'auto',
paddingRight: 8,
},
}}
footer={(_, { OkBtn, CancelBtn }) => (
<>
<CancelBtn />
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleFormTest}
>
测试模型
</Button>
<OkBtn />
</>
)}
>
<div className="model-config-modal-hint">
<strong>自动生成规则</strong> 选择提供方后会自动带出默认 `base_url`填写模型标识后会自动生成模型名称模型编码
如果你手动改过这些字段后续就不会再被自动覆盖
</div>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
llm_timeout: 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: 2048,
is_active: true,
is_default: false,
}}
>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Form.Item
label="提供方"
name="provider"
rules={[{ required: true, message: '请选择模型提供方' }]}
>
<Select
showSearch
placeholder="请选择模型提供方"
optionFilterProp="label"
options={providerCatalog.map((item) => ({
label: item.label,
value: item.value,
}))}
/>
</Form.Item>
<Form.Item
label="Base URL"
name="endpoint_url"
rules={[{ required: true, message: '请输入 base_url' }]}
>
<Input
placeholder="选择提供方后自动带出,也可以手动覆盖"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, endpointUrl: false }))
}}
/>
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="模型标识"
name="llm_model_name"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型标识或部署名' }]}
>
<Input placeholder="如 gpt-4.1-mini / qwen3.6-plus / claude-3-5-sonnet-latest" />
</Form.Item>
<Form.Item
label="请求超时(秒)"
name="llm_timeout"
style={{ width: 180 }}
rules={[{ required: true, message: '请输入超时时间' }]}
>
<InputNumber min={5} max={600} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="模型名称"
name="model_name"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input
placeholder="会根据提供方和模型标识自动生成"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, modelName: false }))
}}
/>
</Form.Item>
<Form.Item
label="模型编码"
name="model_code"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型编码' }]}
>
<Input
placeholder="会自动生成,支持手动调整"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, modelCode: false }))
}}
/>
</Form.Item>
</Space>
<Form.Item label="API Key" name="api_key">
<Input.Password
placeholder="支持留空后稍后补齐;测试非 Ollama 模型时建议填写"
autoComplete="new-password"
/>
</Form.Item>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="Temperature"
name="llm_temperature"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 temperature' }]}
>
<InputNumber min={0} max={2} step={0.05} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="Top P"
name="llm_top_p"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 top_p' }]}
>
<InputNumber min={0} max={1} step={0.05} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="Max Tokens"
name="llm_max_tokens"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 max_tokens' }]}
>
<InputNumber min={1} max={32768} step={128} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Form.Item label="系统提示词" name="llm_system_prompt">
<TextArea
rows={4}
placeholder="可选。测试模型时会作为 system prompt 一并发送。"
/>
</Form.Item>
<Form.Item label="描述" name="description">
<TextArea rows={2} placeholder="可填写用途、场景、适用业务等说明" />
</Form.Item>
<Space size={24}>
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Form.Item label="设为默认" name="is_default" valuePropName="checked">
<Switch checkedChildren="默认" unCheckedChildren="普通" />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
)
}
export default ModelConfigs