import { useEffect, useRef, useState } from 'react'
import {
Button,
Card,
Form,
Grid,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Select,
Slider,
Space,
Switch,
Tabs,
Tag,
} from 'antd'
import {
CloudServerOutlined,
CommentOutlined,
DeploymentUnitOutlined,
ExperimentOutlined,
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
StarOutlined,
} from '@ant-design/icons'
import {
createLLMModelConfig,
deleteLLMModelConfig,
getLLMModelConfigDetail,
getLLMModelConfigs,
getLLMProviderCatalog,
setDefaultLLMModelConfig,
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
const MAX_TOKENS_OPTIONS = [
{ label: '4K', value: 4096 },
{ label: '8K', value: 8192 },
{ label: '16K', value: 16384 },
{ label: '32K', value: 32768 },
]
const DEFAULT_MAX_TOKENS = 8192
const MODEL_TYPE_META = {
chat: {
label: '对话模型',
icon: ,
description: '用于知识库对话、问答生成的大语言模型。',
addText: '新增对话模型',
},
embedding: {
label: '向量模型',
icon: ,
description: '用于文档向量化(ZVec)与语义检索的 Embedding 模型。',
addText: '新增向量模型',
},
}
function buildModelCode(provider, llmModelName, modelType) {
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'
const base = `llm_${providerPart}_${suffix}`
return modelType === 'embedding' ? `emb_${base}` : base
}
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 screens = Grid.useBreakpoint()
const [activeType, setActiveType] = useState('chat')
const [editingType, setEditingType] = useState('chat')
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 loadRequestIdRef = useRef(0)
const providerValue = Form.useWatch('provider', form)
const llmModelNameValue = Form.useWatch('llm_model_name', form)
const isEmbedding = editingType === 'embedding'
const isLocalProvider = providerValue === 'local'
const localModels = providerCatalog.find((item) => item.value === 'local')?.models || []
const availableProviders = providerCatalog.filter((item) => (
!item.model_types || item.model_types.includes(editingType)
))
useEffect(() => {
loadProviderCatalog()
}, [])
useEffect(() => {
loadConfigs()
}, [activeType, 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, editingType)
if (nextModelCode !== currentValues.model_code) {
nextValues.model_code = nextModelCode
}
}
}
if (Object.keys(nextValues).length > 0) {
form.setFieldsValue(nextValues)
}
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, editingType, 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 () => {
const requestId = ++loadRequestIdRef.current
try {
setLoading(true)
const params = {
page,
page_size: pageSize,
model_type: activeType,
}
if (keyword) params.keyword = keyword
if (providerFilter) params.provider = providerFilter
if (statusFilter !== undefined) params.is_active = statusFilter
const res = await getLLMModelConfigs(params)
if (requestId !== loadRequestIdRef.current) return
setConfigs(res.data || [])
setTotal(res.total || 0)
} catch (error) {
console.error('Load llm model configs error:', error)
Toast.error('加载模型配置失败')
} finally {
if (requestId === loadRequestIdRef.current) {
setLoading(false)
}
}
}
const getProviderMeta = (provider) => providerCatalog.find((item) => item.value === provider)
const handleTabChange = (key) => {
setActiveType(key)
setPage(1)
setKeyword('')
setProviderFilter(undefined)
setStatusFilter(undefined)
}
const openCreateModal = () => {
const compatibleProviders = providerCatalog.filter((item) => (
!item.model_types || item.model_types.includes(activeType)
))
const defaultProvider = activeType === 'embedding'
? (compatibleProviders.find((item) => item.value === 'local')?.value || compatibleProviders[0]?.value)
: compatibleProviders[0]?.value
const resolvedProvider = defaultProvider || 'openai'
const defaultEndpointUrl = getProviderMeta(resolvedProvider)?.default_endpoint_url || ''
const providerDefaults = getProviderMeta(resolvedProvider)
setEditingConfigId(null)
setEditingType(activeType)
setAutoFillFlags({
endpointUrl: true,
modelName: true,
modelCode: true,
})
form.setFieldsValue({
model_type: activeType,
provider: resolvedProvider,
endpoint_url: defaultEndpointUrl,
llm_timeout: activeType === 'embedding' ? 60 : 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: DEFAULT_MAX_TOKENS,
embedding_dimension: undefined,
chunk_size: providerDefaults?.default_chunk_size || 800,
chunk_overlap: providerDefaults?.default_chunk_overlap ?? 150,
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 detailType = detail.model_type || 'chat'
const providerMeta = getProviderMeta(detail.provider)
setEditingConfigId(record.config_id)
setEditingType(detailType)
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, detailType),
})
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)
const payload = { ...values, model_type: editingType }
if (editingConfigId) {
await updateLLMModelConfig(editingConfigId, payload)
Toast.success('模型配置更新成功')
} else {
await createLLMModelConfig(payload)
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 handleSetDefault = (record) => {
const isVectorModel = record.model_type === 'embedding'
Modal.confirm({
title: isVectorModel ? '切换默认向量模型?' : '切换默认对话模型?',
content: isVectorModel
? '新文件和后续检索将使用该模型。已有项目向量由旧模型生成,请在切换后对相关项目执行一次全量向量化。'
: '新建知识库问答将默认选中该模型,已有会话不会改变。',
okText: '设为默认',
cancelText: '取消',
onOk: async () => {
try {
await setDefaultLLMModelConfig(record.config_id)
setConfigs((current) => current
.map((item) => ({
...item,
is_default: item.config_id === record.config_id,
}))
.sort((left, right) => Number(right.is_default) - Number(left.is_default)))
Toast.success(isVectorModel ? '默认向量模型已切换,请重建已有项目向量' : '默认对话模型已切换')
await loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '切换默认模型失败')
}
},
})
}
const showTestResult = (result) => {
const providerLabel = getProviderMeta(result.provider)?.label || result.provider
const lines = [
`提供方:${providerLabel}`,
`模型:${result.llm_model_name}`,
`延迟:${result.latency_ms} ms`,
]
if (result.dimension) {
lines.push(`向量维度:${result.dimension}`)
}
if (result.preview) {
lines.push(`返回预览:${result.preview}`)
}
const description = (
{lines.map((line, index) => (
{line}
))}
)
Toast.success('模型测试成功', description, 5)
}
const handleFormTest = async () => {
try {
const values = await form.validateFields()
setTesting(true)
const res = await testLLMModelConfig({ ...values, model_type: editingType })
showTestResult(res.data)
} catch (error) {
// 表单校验未通过:不弹提示,仅高亮表单项
// 接口失败:已由全局请求拦截器统一弹出错误 Toast,这里不重复提示
} finally {
setTesting(false)
}
}
const getColumns = (type) => {
const baseColumns = [
{
title: '模型名称',
dataIndex: 'model_name',
key: 'model_name',
width: 240,
render: (_, record) => (
{record.model_name}
{record.is_default && 默认}
{record.model_code}
),
},
{
title: '提供方',
dataIndex: 'provider',
key: 'provider',
width: 140,
render: (provider) => (
{getProviderMeta(provider)?.label || provider || '-'}
),
},
{
title: '模型标识',
dataIndex: 'llm_model_name',
key: 'llm_model_name',
width: 180,
},
]
if (type === 'embedding') {
baseColumns.push({
title: '向量维度',
dataIndex: 'embedding_dimension',
key: 'embedding_dimension',
width: 110,
render: (value) => (value ? {value} : 自动),
})
baseColumns.push({
title: '分块 / 重叠',
key: 'chunk_options',
width: 130,
render: (_, record) => `${record.chunk_size} / ${record.chunk_overlap}`,
})
}
baseColumns.push(
{
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) => (
handleStatusChange(record, checked)}
/>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 180,
render: (value) => formatDateTime(value),
},
{
title: '操作',
key: 'action',
fixed: screens.md ? 'right' : undefined,
width: screens.md ? 260 : 220,
render: (_, record) => (
{!record.is_default && (
}
onClick={() => handleSetDefault(record)}
>
设为默认
)}
}
onClick={() => openEditModal(record)}
>
编辑
handleDelete(record)}
>
}
>
删除
),
},
)
return baseColumns
}
const renderListPanel = (type) => (
<>
{
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
}
onClick={openCreateModal}
>
{MODEL_TYPE_META[type].addText}
} onClick={loadConfigs}>
刷新
{
setPage(nextPage)
setPageSize(nextPageSize)
},
showTotal: (value) => `共 ${value} 条`,
}}
/>
>
)
const tabItems = Object.entries(MODEL_TYPE_META).map(([type, meta]) => ({
key: type,
label: (
{meta.icon}
{meta.label}
),
children: (
{meta.description}
{activeType === type ? renderListPanel(type) : null}
),
}))
return (
}
/>
form.submit()}
confirmLoading={submitting}
okText={editingConfigId ? '保存修改' : '创建'}
cancelText="取消"
styles={{
body: {
maxHeight: 'calc(80vh - 180px)',
overflowY: 'auto',
paddingRight: 8,
},
}}
footer={(_, { OkBtn, CancelBtn }) => (
<>
}
loading={testing}
onClick={handleFormTest}
>
测试模型
>
)}
>
自动生成规则: 选择远程提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。
如果你手动改过这些字段,后续就不会再被自动覆盖。
{isEmbedding && (
<>
索引参数: 向量维度必须与模型输出一致;分块大小与重叠会影响检索粒度,修改后应对已有项目执行全量向量化。
>
)}
({
label: item.label,
value: item.value,
}))}
/>
{!isLocalProvider && (
{
setAutoFillFlags((current) => ({ ...current, endpointUrl: false }))
}}
/>
)}
{isLocalProvider ? (
({
value: item.name,
label: `${item.name}${item.dimension ? ` (${item.dimension} 维)` : ''}${item.ready ? '' : ' - 权重未就绪'}`,
disabled: !item.ready,
}))}
onChange={(value) => {
const selected = localModels.find((item) => item.name === value)
if (selected?.dimension) {
form.setFieldValue('embedding_dimension', selected.dimension)
}
}}
/>
) : (
)}
{
setAutoFillFlags((current) => ({ ...current, modelName: false }))
}}
/>
{
setAutoFillFlags((current) => ({ ...current, modelCode: false }))
}}
/>
{!isLocalProvider && (
)}
{isEmbedding ? (
({
validator(_, value) {
if (value < getFieldValue('chunk_size')) return Promise.resolve()
return Promise.reject(new Error('必须小于分块字符数'))
},
}),
]}
>
) : (
<>
>
)}
)
}
export default ModelConfigs