'use client'; import { useState, useEffect, useMemo } from 'react'; import { Typography, Box, Button, TextField, Grid, Card, CardContent, Dialog, DialogTitle, DialogContent, DialogActions, FormControl, Autocomplete, Slider, InputLabel, Select, MenuItem, Stack, Paper, Tooltip, IconButton, Chip, Divider, CircularProgress } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorIcon from '@mui/icons-material/Error'; import { DEFAULT_MODEL_SETTINGS } from '@/constant/model'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { toast } from 'sonner'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ScienceIcon from '@mui/icons-material/Science'; import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety'; import { useRouter } from 'next/navigation'; import { useAtom } from 'jotai'; import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store'; import { getProviderLogo, sortProvidersByPriority } from '@/lib/util/providerLogo'; export default function ModelSettings({ projectId }) { const { t } = useTranslation(); const router = useRouter(); // 展示端点的最大长度 const MAX_ENDPOINT_DISPLAY = 80; const MAX_GENERATION_TOKENS = 131072; // 模型对话框状态 const [openModelDialog, setOpenModelDialog] = useState(false); const [editingModel, setEditingModel] = useState(null); const [loading, setLoading] = useState(true); const [providerList, setProviderList] = useState([]); const [providerOptions, setProviderOptions] = useState([]); const [selectedProvider, setSelectedProvider] = useState({}); const [models, setModels] = useState([]); const [modelConfigList, setModelConfigList] = useAtom(modelConfigListAtom); const [selectedModelInfo, setSelectedModelInfo] = useAtom(selectedModelInfoAtom); const orderedModelConfigList = useMemo( () => sortProvidersByPriority(modelConfigList, item => item.providerId), [modelConfigList] ); const [modelConfigForm, setModelConfigForm] = useState({ id: '', providerId: '', providerName: '', endpoint: '', apiKey: '', modelId: '', modelName: '', type: 'text', temperature: 0.0, maxTokens: DEFAULT_MODEL_SETTINGS.maxTokens, topP: 0, topK: 0, status: 1 }); const [healthStatusMap, setHealthStatusMap] = useState({}); const [batchCheckingHealth, setBatchCheckingHealth] = useState(false); const isModelConfigured = model => { if (!model) return false; const hasEndpoint = Boolean(String(model.endpoint || '').trim()); const hasModel = Boolean(String(model.modelId || model.modelName || '').trim()); const providerId = String(model.providerId || '').toLowerCase(); if (providerId === 'ollama') { return hasEndpoint && hasModel; } const hasApiKey = Boolean(String(model.apiKey || '').trim()); return hasEndpoint && hasApiKey && hasModel; }; const configuredModelList = useMemo(() => orderedModelConfigList.filter(isModelConfigured), [orderedModelConfigList]); const unconfiguredModelList = useMemo( () => orderedModelConfigList.filter(model => !isModelConfigured(model)), [orderedModelConfigList] ); const normalizePositiveInteger = value => { const parsedValue = Number(value); if (!Number.isInteger(parsedValue) || parsedValue < 1) { return null; } return parsedValue; }; const getSafeMaxTokensValue = value => { return normalizePositiveInteger(value) ?? DEFAULT_MODEL_SETTINGS.maxTokens; }; useEffect(() => { getProvidersList(); getModelConfigList(); }, []); // 获取提供商列表 const getProvidersList = () => { axios.get('/api/llm/providers').then(response => { console.log('获取的模型列表', response.data); const sortedProviders = sortProvidersByPriority(response.data, item => item.id); setProviderList(sortedProviders); const providerOptions = sortedProviders.map(provider => ({ id: provider.id, label: provider.name })); if (sortedProviders.length > 0) { setSelectedProvider(sortedProviders[0]); getProviderModels(sortedProviders[0].id); } setProviderOptions(providerOptions); }); }; // 裁剪端点展示长度(不改变实际值,仅用于 UI 展示) const formatEndpoint = model => { if (!model?.endpoint) return ''; const base = model.endpoint.replace(/^https?:\/\//, ''); if (base.length > MAX_ENDPOINT_DISPLAY) { return base.slice(0, MAX_ENDPOINT_DISPLAY) + '...'; } return base; }; // 获取模型配置列表 const getModelConfigList = () => { axios .get(`/api/projects/${projectId}/model-config`) .then(response => { setModelConfigList(sortProvidersByPriority(response.data.data, item => item.providerId)); setLoading(false); }) .catch(error => { setLoading(false); toast.error('Fetch model list Error'); }); }; const onChangeProvider = (event, newValue) => { console.log('选择提供商', newValue, typeof newValue); if (typeof newValue === 'string') { // 用户手动输入了自定义提供商 setModelConfigForm(prev => ({ ...prev, providerId: 'custom', endpoint: '', providerName: '' })); } else if (newValue && newValue.id) { // 用户从下拉列表中选择了一个提供商 const selectedProvider = providerList.find(p => p.id === newValue.id); if (selectedProvider) { setSelectedProvider(selectedProvider); setModelConfigForm(prev => ({ ...prev, providerId: selectedProvider.id, endpoint: selectedProvider.apiUrl, providerName: selectedProvider.name, modelName: '' })); getProviderModels(newValue.id); } } }; // 获取提供商的模型列表(DB) const getProviderModels = providerId => { axios .get(`/api/llm/model?providerId=${providerId}`) .then(response => { setModels(response.data); }) .catch(error => { toast.error('Get Models Error'); }); }; // 同步模型列表 const refreshProviderModels = async () => { let data = await getNewModels(); if (!data) return; if (data.length > 0) { setModels(data); toast.success('Refresh Success'); const newModelsData = await axios.post('/api/llm/model', { newModels: data, providerId: selectedProvider.id }); if (newModelsData.status === 200) { toast.success('Get Model Success'); } } else { toast.info('No Models Need Refresh'); } }; // 获取最新模型列表 async function getNewModels() { try { if (!modelConfigForm || !modelConfigForm.endpoint) { return null; } const providerId = modelConfigForm.providerId; console.log(providerId, 'getNewModels providerId'); // 使用后端 API 代理请求 const res = await axios.post('/api/llm/fetch-models', { endpoint: modelConfigForm.endpoint, providerId: providerId, apiKey: modelConfigForm.apiKey }); return res.data; } catch (err) { if (err.response && err.response.status === 401) { toast.error('API Key Invalid'); } else { toast.error('Get Model List Error'); } return null; } } const getHealthCheckErrorMessage = error => { if (error?.response?.data?.error) return String(error.response.data.error); if (error?.response?.data?.message) return String(error.response.data.message); if (error?.message) return String(error.message); return t('models.endpointCheckFailed', { defaultValue: 'Endpoint check failed' }); }; const checkModelEndpointHealth = async (model, { silent = false } = {}) => { if (!model?.id) return false; const endpoint = String(model.endpoint || '').trim(); if (!endpoint) { setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'error', message: t('models.endpointMissing', { defaultValue: 'Endpoint is empty' }) } })); if (!silent) { toast.error(t('models.endpointMissing', { defaultValue: 'Endpoint is empty' })); } return false; } setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'checking', message: t('models.checking', { defaultValue: 'Checking...' }) } })); try { const response = await axios.post('/api/llm/fetch-models', { endpoint, providerId: model.providerId, apiKey: model.apiKey }); const resultList = Array.isArray(response.data) ? response.data : []; const currentModelId = String(model.modelId || model.modelName || '').trim(); const hasMatchedModel = !currentModelId || resultList.some(item => { return item?.modelId === currentModelId || item?.modelName === currentModelId; }); if (!hasMatchedModel) { setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'warning', message: t('models.endpointReachableModelMissing', { defaultValue: 'Endpoint reachable, but current model is not in the returned model list' }), checkedAt: Date.now() } })); if (!silent) { toast.warning( t('models.endpointReachableModelMissing', { defaultValue: 'Endpoint reachable, but current model is not in the returned model list' }) ); } return true; } setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'success', message: t('models.endpointHealthy', { defaultValue: 'Endpoint is healthy' }), checkedAt: Date.now() } })); if (!silent) { toast.success(t('models.endpointHealthy', { defaultValue: 'Endpoint is healthy' })); } return true; } catch (error) { const message = getHealthCheckErrorMessage(error); setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'error', message, checkedAt: Date.now() } })); if (!silent) { toast.error(message); } return false; } }; const checkAllConfiguredModelHealth = async () => { if (configuredModelList.length === 0) { toast.info(t('models.noConfiguredModels', { defaultValue: 'No configured models to check' })); return; } setBatchCheckingHealth(true); let okCount = 0; let failCount = 0; for (const model of configuredModelList) { const isHealthy = await checkModelEndpointHealth(model, { silent: true }); if (isHealthy) { okCount += 1; } else { failCount += 1; } } setBatchCheckingHealth(false); toast.success( t('models.healthCheckSummary', { defaultValue: `Health check completed: ${okCount} healthy, ${failCount} failed`, okCount, failCount }) ); }; const getHealthStatusInfo = model => { const status = healthStatusMap[model.id]?.status || 'idle'; const message = healthStatusMap[model.id]?.message; if (status === 'checking') { return { color: 'default', icon: , label: t('models.checking', { defaultValue: 'Checking...' }), message }; } if (status === 'success') { return { color: 'success', icon: , label: t('models.healthy', { defaultValue: 'Healthy' }), message }; } if (status === 'warning') { return { color: 'warning', icon: , label: t('models.reachable', { defaultValue: 'Reachable' }), message }; } if (status === 'error') { return { color: 'error', icon: , label: t('models.unhealthy', { defaultValue: 'Unhealthy' }), message }; } return { color: 'default', icon: , label: t('models.notChecked', { defaultValue: 'Not checked' }), message: t('models.notChecked', { defaultValue: 'Not checked' }) }; }; // 打开模型对话框 const handleOpenModelDialog = (model = null) => { if (model) { setEditingModel(model); console.log('handleOpenModelDialog', model); // 兼容逻辑:如果 modelId 为空,则用 modelName 作为 modelId const initialForm = { ...model }; if (!initialForm.modelId && initialForm.modelName) { initialForm.modelId = initialForm.modelName; } // 编辑现有模型时,为未设置的参数应用默认值 setModelConfigForm({ ...initialForm, temperature: model.temperature !== undefined ? model.temperature : DEFAULT_MODEL_SETTINGS.temperature, maxTokens: model.maxTokens !== undefined ? model.maxTokens : DEFAULT_MODEL_SETTINGS.maxTokens, topP: model.topP !== undefined && model.topP !== 0 ? model.topP : DEFAULT_MODEL_SETTINGS.topP }); getProviderModels(model.providerId); } else { setEditingModel(null); // 添加新模型时,完全重置表单 setModelConfigForm({ providerId: selectedProvider?.id || '', providerName: selectedProvider?.name || '', endpoint: selectedProvider?.apiUrl || '', apiKey: '', modelId: '', modelName: '', type: 'text', ...DEFAULT_MODEL_SETTINGS, id: '' }); if (selectedProvider?.id) { getProviderModels(selectedProvider.id); } } setOpenModelDialog(true); }; // 关闭模型对话框 const handleCloseModelDialog = () => { setEditingModel(null); setOpenModelDialog(false); }; // 处理模型表单变更 const handleModelFormChange = e => { const { name, value } = e.target; console.log('handleModelFormChange', name, value); setModelConfigForm(prev => ({ ...prev, [name]: value })); }; const handleMaxTokensSliderChange = (event, newValue) => { const value = Array.isArray(newValue) ? newValue[0] : newValue; const normalizedValue = normalizePositiveInteger(value); if (normalizedValue === null) { return; } setModelConfigForm(prev => ({ ...prev, maxTokens: normalizedValue })); }; const handleMaxTokensInputChange = e => { const { value } = e.target; if (value === '') { setModelConfigForm(prev => ({ ...prev, maxTokens: '' })); return; } const normalizedValue = normalizePositiveInteger(value); if (normalizedValue === null) { return; } setModelConfigForm(prev => ({ ...prev, maxTokens: normalizedValue })); }; const handleMaxTokensInputBlur = () => { const normalizedValue = normalizePositiveInteger(modelConfigForm.maxTokens); if (normalizedValue !== null) { return; } setModelConfigForm(prev => ({ ...prev, maxTokens: DEFAULT_MODEL_SETTINGS.maxTokens })); }; // 保存模型 const handleSaveModel = () => { // 确保有模型 ID const normalizedModelId = String(modelConfigForm.modelId || '').trim(); const normalizedModelName = String(modelConfigForm.modelName || '').trim(); const isEditingExistingModel = Boolean(modelConfigForm.id || editingModel?.id); if (!isEditingExistingModel && !normalizedModelId) { toast.error(t('models.modelIdPlaceholder')); return; } const normalizedMaxTokens = normalizePositiveInteger(modelConfigForm.maxTokens); if (normalizedMaxTokens === null) { toast.error(t('models.maxTokensPositiveError', { defaultValue: 'Max Tokens must be a positive integer' })); return; } // 如果模型名称为空,则默认为模型 ID const dataToSave = { ...modelConfigForm, modelId: normalizedModelId, maxTokens: normalizedMaxTokens, modelName: normalizedModelName || normalizedModelId }; axios .post(`/api/projects/${projectId}/model-config`, dataToSave) .then(response => { if (selectedModelInfo && selectedModelInfo.id === response.data.id) { setSelectedModelInfo(response.data); } toast.success(t('settings.saveSuccess')); getModelConfigList(); handleCloseModelDialog(); }) .catch(error => { toast.error(t('settings.saveFailed')); console.error(error); }); }; // 删除模型 const handleDeleteModel = id => { axios .delete(`/api/projects/${projectId}/model-config/${id}`) .then(response => { toast.success(t('settings.deleteSuccess')); getModelConfigList(); }) .catch(error => { toast.error(t('settings.deleteFailed')); }); }; // 获取模型状态图标和颜色 const getModelStatusInfo = model => { const providerId = String(model?.providerId || '').toLowerCase(); if (providerId === 'ollama') { return { icon: , color: 'success', text: t('models.localModel') }; } else if (model.apiKey) { return { icon: , color: 'success', text: t('models.apiKeyConfigured') }; } else { return { icon: , color: 'warning', text: t('models.apiKeyNotConfigured') }; } }; const renderModelCard = model => { const modelStatus = getModelStatusInfo(model); const healthStatus = getHealthStatusInfo(model); const providerId = String(model?.providerId || '').toLowerCase(); const endpointLabel = `${formatEndpoint(model)}${ providerId !== 'ollama' && !model.apiKey ? ' (' + t('models.unconfiguredAPIKey') + ')' : '' }`; return ( { e.target.src = '/imgs/models/default.svg'; }} /> {model.modelName ? model.modelName : t('models.unselectedModel')} {model.providerName} checkModelEndpointHealth(model)} disabled={healthStatusMap[model.id]?.status === 'checking'} > router.push(`/projects/${projectId}/playground?modelId=${model.id}`)} color="secondary" > handleOpenModelDialog(model)} color="primary"> handleDeleteModel(model.id)} disabled={modelConfigList.length <= 1} color="error" > ); }; if (loading) { return {t('textSplit.loading')}; } return ( {t('settings.modelConfig')} : } onClick={checkAllConfiguredModelHealth} size="small" disabled={batchCheckingHealth || configuredModelList.length === 0} sx={{ textTransform: 'none' }} > {batchCheckingHealth ? t('models.checking', { defaultValue: 'Checking...' }) : t('models.checkAllEndpointHealth', { defaultValue: 'Check all endpoints' })} } onClick={() => router.push(`/projects/${projectId}/playground`)} size="small" sx={{ textTransform: 'none' }} > {t('playground.title')} } onClick={() => handleOpenModelDialog()} size="small" sx={{ textTransform: 'none' }} > {t('models.add')} {t('models.configuredModels', { defaultValue: 'Configured Models' })} {configuredModelList.map(renderModelCard)} {configuredModelList.length === 0 && ( {t('models.noConfiguredModels', { defaultValue: 'No configured models' })} )} {t('models.unconfiguredModels', { defaultValue: 'Unconfigured Models' })} {unconfiguredModelList.map(renderModelCard)} {unconfiguredModelList.length === 0 && ( {t('models.noUnconfiguredModels', { defaultValue: 'No unconfigured models' })} )} {/* 模型表单对话框 */} {editingModel ? t('models.edit') : t('models.add')} {/* provider */} option.label} value={ providerOptions.find(p => p.id === modelConfigForm.providerId) || { id: 'custom', label: modelConfigForm.providerName || '' } } onChange={onChangeProvider} renderInput={params => ( { // 当用户手动输入时,更新 provider 字段 setModelConfigForm(prev => ({ ...prev, providerId: 'custom', providerName: e.target.value })); }} /> )} renderOption={(props, option) => { return ( { e.target.src = '/imgs/models/default.svg'; }} /> {option.label} ); }} /> {/* 接口地址 */} {/* API Key */} {/* 模型 ID */} model && model.modelId) .map(model => ({ label: `${model.modelName} (${model.modelId})`, modelName: model.modelName, modelId: model.modelId, providerId: model.providerId }))} value={modelConfigForm.modelId} onChange={(event, newValue) => { console.log('newValue', newValue); const newId = newValue?.modelId || newValue || ''; const newName = newValue?.modelName || newValue?.modelId || newValue || ''; setModelConfigForm(prev => ({ ...prev, modelId: newId, // 如果当前名称为空或与旧 ID 一致,则同步更新名称 modelName: !prev.modelName || prev.modelName === prev.modelId ? newName : prev.modelName })); }} renderInput={params => ( { setModelConfigForm(prev => ({ ...prev, modelId: e.target.value })); }} /> )} /> refreshProviderModels()} sx={{ ml: 2 }}> {t('models.refresh')} {/* 模型名称 */} {/* 新增:视觉模型选择项 */} {t('models.type')} {t('models.text')} {t('models.vision')} {t('models.temperature')} {modelConfigForm.temperature} {t('models.maxTokens')} {t('models.maxTokensInputTip', { defaultValue: `Slider range: 1-${MAX_GENERATION_TOKENS}. You can also input any positive integer.` })} {t('models.topP', { defaultValue: 'Top P' })} {modelConfigForm.topP} {t('common.cancel')} {t('common.save')} ); }