import { useEffect, useState } from 'react' import { Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Radio, Select, Slider, Space, Switch, Tabs, Tag, } from 'antd' import { CloudServerOutlined, CommentOutlined, DeploymentUnitOutlined, 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 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 [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 providerValue = Form.useWatch('provider', form) const llmModelNameValue = Form.useWatch('llm_model_name', form) const isEmbedding = editingType === 'embedding' 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 () => { 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) 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 handleTabChange = (key) => { setActiveType(key) setPage(1) setKeyword('') setProviderFilter(undefined) setStatusFilter(undefined) } const openCreateModal = () => { const defaultProvider = providerCatalog[0]?.value || 'openai' const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || '' setEditingConfigId(null) setEditingType(activeType) setAutoFillFlags({ endpointUrl: true, modelName: true, modelCode: true, }) form.setFieldsValue({ model_type: activeType, provider: defaultProvider, 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, is_active: true, 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 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.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: '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: 'right', width: 170, render: (_, record) => ( handleDelete(record)} > ), }, ) return baseColumns } const renderListPanel = (type) => ( <>
{ setPage(1) setKeyword(event.target.value) }} onSearch={(value) => { setPage(1) setKeyword(value) }} /> { setPage(1) setStatusFilter(value) }} />
{ 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 }) => ( <> )} >
自动生成规则: 选择提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。 如果你手动改过这些字段,后续就不会再被自动覆盖。 {isEmbedding && ( <>
向量维度: 留空时按模型实际返回维度自动建立向量库;如填写,需与模型输出维度一致。更换不同维度的模型会重建该项目的向量库。 )}
{ setAutoFillFlags((current) => ({ ...current, endpointUrl: false })) }} /> { setAutoFillFlags((current) => ({ ...current, modelName: false })) }} /> { setAutoFillFlags((current) => ({ ...current, modelCode: false })) }} /> {isEmbedding ? ( ) : ( <>