1057 lines
35 KiB
JavaScript
1057 lines
35 KiB
JavaScript
'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: <CircularProgress size={14} />,
|
||
label: t('models.checking', { defaultValue: 'Checking...' }),
|
||
message
|
||
};
|
||
}
|
||
|
||
if (status === 'success') {
|
||
return {
|
||
color: 'success',
|
||
icon: <CheckCircleIcon fontSize="small" />,
|
||
label: t('models.healthy', { defaultValue: 'Healthy' }),
|
||
message
|
||
};
|
||
}
|
||
|
||
if (status === 'warning') {
|
||
return {
|
||
color: 'warning',
|
||
icon: <ErrorIcon fontSize="small" />,
|
||
label: t('models.reachable', { defaultValue: 'Reachable' }),
|
||
message
|
||
};
|
||
}
|
||
|
||
if (status === 'error') {
|
||
return {
|
||
color: 'error',
|
||
icon: <ErrorIcon fontSize="small" />,
|
||
label: t('models.unhealthy', { defaultValue: 'Unhealthy' }),
|
||
message
|
||
};
|
||
}
|
||
|
||
return {
|
||
color: 'default',
|
||
icon: <HealthAndSafetyIcon fontSize="small" />,
|
||
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: <CheckCircleIcon fontSize="small" />,
|
||
color: 'success',
|
||
text: t('models.localModel')
|
||
};
|
||
} else if (model.apiKey) {
|
||
return {
|
||
icon: <CheckCircleIcon fontSize="small" />,
|
||
color: 'success',
|
||
text: t('models.apiKeyConfigured')
|
||
};
|
||
} else {
|
||
return {
|
||
icon: <ErrorIcon fontSize="small" />,
|
||
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 (
|
||
<Paper
|
||
key={model.id}
|
||
elevation={1}
|
||
sx={{
|
||
p: 2,
|
||
borderRadius: 2,
|
||
transition: 'all 0.2s',
|
||
'&:hover': {
|
||
boxShadow: 3,
|
||
transform: 'translateY(-2px)'
|
||
}
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1.5 }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||
<Box
|
||
component="img"
|
||
src={getProviderLogo(model.providerId, model.providerName)}
|
||
alt={model.providerName}
|
||
sx={{ width: 32, height: 32, objectFit: 'contain' }}
|
||
onError={e => {
|
||
e.target.src = '/imgs/models/default.svg';
|
||
}}
|
||
/>
|
||
<Box>
|
||
<Typography variant="subtitle1" fontWeight="bold">
|
||
{model.modelName ? model.modelName : t('models.unselectedModel')}
|
||
</Typography>
|
||
<Typography
|
||
variant="body2"
|
||
color="primary"
|
||
sx={{
|
||
fontWeight: 'medium',
|
||
bgcolor: 'primary.50',
|
||
px: 1,
|
||
py: 0.2,
|
||
borderRadius: 1,
|
||
display: 'inline-block'
|
||
}}
|
||
>
|
||
{model.providerName}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||
<Tooltip title={modelStatus.text}>
|
||
<Chip
|
||
icon={modelStatus.icon}
|
||
label={endpointLabel}
|
||
size="small"
|
||
color={modelStatus.color}
|
||
variant="outlined"
|
||
/>
|
||
</Tooltip>
|
||
|
||
<Tooltip title={healthStatus.message || healthStatus.label}>
|
||
<Chip
|
||
icon={healthStatus.icon}
|
||
label={healthStatus.label}
|
||
size="small"
|
||
color={healthStatus.color}
|
||
variant="outlined"
|
||
/>
|
||
</Tooltip>
|
||
|
||
<Tooltip title={t('models.checkEndpointHealth', { defaultValue: 'Check endpoint health' })}>
|
||
<span>
|
||
<IconButton
|
||
size="small"
|
||
color="success"
|
||
onClick={() => checkModelEndpointHealth(model)}
|
||
disabled={healthStatusMap[model.id]?.status === 'checking'}
|
||
>
|
||
<HealthAndSafetyIcon fontSize="small" />
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
|
||
<Tooltip title={t('models.typeTips')}>
|
||
<Chip
|
||
sx={{ marginLeft: '5px' }}
|
||
label={t(`models.${model.type || 'text'}`)}
|
||
size="small"
|
||
color={model.type === 'vision' ? 'secondary' : 'info'}
|
||
variant="outlined"
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title={t('playground.title')}>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => router.push(`/projects/${projectId}/playground?modelId=${model.id}`)}
|
||
color="secondary"
|
||
>
|
||
<ScienceIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
|
||
<Tooltip title={t('common.edit')}>
|
||
<IconButton size="small" onClick={() => handleOpenModelDialog(model)} color="primary">
|
||
<EditIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
|
||
<Tooltip title={t('common.delete')}>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => handleDeleteModel(model.id)}
|
||
disabled={modelConfigList.length <= 1}
|
||
color="error"
|
||
>
|
||
<DeleteIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
</Box>
|
||
</Paper>
|
||
);
|
||
};
|
||
|
||
if (loading) {
|
||
return <Typography>{t('textSplit.loading')}</Typography>;
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardContent>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||
<Typography variant="h6" fontWeight="bold">
|
||
{t('settings.modelConfig')}
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||
<Button
|
||
variant="outlined"
|
||
color="success"
|
||
startIcon={batchCheckingHealth ? <CircularProgress size={14} /> : <HealthAndSafetyIcon />}
|
||
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' })}
|
||
</Button>
|
||
<Button
|
||
variant="outlined"
|
||
color="secondary"
|
||
startIcon={<ScienceIcon />}
|
||
onClick={() => router.push(`/projects/${projectId}/playground`)}
|
||
size="small"
|
||
sx={{ textTransform: 'none' }}
|
||
>
|
||
{t('playground.title')}
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
startIcon={<AddIcon />}
|
||
onClick={() => handleOpenModelDialog()}
|
||
size="small"
|
||
sx={{ textTransform: 'none' }}
|
||
>
|
||
{t('models.add')}
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Stack spacing={2}>
|
||
<Box>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||
<Typography variant="subtitle2" color="text.secondary">
|
||
{t('models.configuredModels', { defaultValue: 'Configured Models' })}
|
||
</Typography>
|
||
<Chip size="small" label={configuredModelList.length} />
|
||
</Box>
|
||
<Stack spacing={2}>
|
||
{configuredModelList.map(renderModelCard)}
|
||
{configuredModelList.length === 0 && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
{t('models.noConfiguredModels', { defaultValue: 'No configured models' })}
|
||
</Typography>
|
||
)}
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
<Box>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||
<Typography variant="subtitle2" color="text.secondary">
|
||
{t('models.unconfiguredModels', { defaultValue: 'Unconfigured Models' })}
|
||
</Typography>
|
||
<Chip size="small" label={unconfiguredModelList.length} />
|
||
</Box>
|
||
<Stack spacing={2}>
|
||
{unconfiguredModelList.map(renderModelCard)}
|
||
{unconfiguredModelList.length === 0 && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
{t('models.noUnconfiguredModels', { defaultValue: 'No unconfigured models' })}
|
||
</Typography>
|
||
)}
|
||
</Stack>
|
||
</Box>
|
||
</Stack>
|
||
</CardContent>
|
||
|
||
{/* 模型表单对话框 */}
|
||
<Dialog open={openModelDialog} onClose={handleCloseModelDialog} maxWidth="sm" fullWidth>
|
||
<DialogTitle>{editingModel ? t('models.edit') : t('models.add')}</DialogTitle>
|
||
<DialogContent>
|
||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||
{/* provider */}
|
||
<Grid item xs={12}>
|
||
<FormControl fullWidth>
|
||
<Autocomplete
|
||
freeSolo
|
||
options={providerOptions}
|
||
getOptionLabel={option => option.label}
|
||
value={
|
||
providerOptions.find(p => p.id === modelConfigForm.providerId) || {
|
||
id: 'custom',
|
||
label: modelConfigForm.providerName || ''
|
||
}
|
||
}
|
||
onChange={onChangeProvider}
|
||
renderInput={params => (
|
||
<TextField
|
||
{...params}
|
||
label={t('models.provider')}
|
||
onChange={e => {
|
||
// 当用户手动输入时,更新 provider 字段
|
||
setModelConfigForm(prev => ({
|
||
...prev,
|
||
providerId: 'custom',
|
||
providerName: e.target.value
|
||
}));
|
||
}}
|
||
/>
|
||
)}
|
||
renderOption={(props, option) => {
|
||
return (
|
||
<div {...props}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<Box
|
||
component="img"
|
||
src={getProviderLogo(option.id, option.label)}
|
||
alt={option.label}
|
||
sx={{ width: 24, height: 24, objectFit: 'contain' }}
|
||
onError={e => {
|
||
e.target.src = '/imgs/models/default.svg';
|
||
}}
|
||
/>
|
||
{option.label}
|
||
</div>
|
||
</div>
|
||
);
|
||
}}
|
||
/>
|
||
</FormControl>
|
||
</Grid>
|
||
{/* 接口地址 */}
|
||
<Grid item xs={12}>
|
||
<TextField
|
||
fullWidth
|
||
label={t('models.endpoint')}
|
||
name="endpoint"
|
||
value={modelConfigForm.endpoint}
|
||
onChange={handleModelFormChange}
|
||
placeholder="例如: https://api.openai.com/v1"
|
||
/>
|
||
</Grid>
|
||
{/* API Key */}
|
||
<Grid item xs={12}>
|
||
<TextField
|
||
fullWidth
|
||
label={t('models.apiKey')}
|
||
name="apiKey"
|
||
type="password"
|
||
value={modelConfigForm.apiKey}
|
||
onChange={handleModelFormChange}
|
||
placeholder="例如: sk-..."
|
||
/>
|
||
</Grid>
|
||
{/* 模型 ID */}
|
||
<Grid item xs={12} style={{ display: 'flex', alignItems: 'center' }}>
|
||
<FormControl style={{ width: '70%' }}>
|
||
<Autocomplete
|
||
freeSolo
|
||
options={models
|
||
.filter(model => 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 => (
|
||
<TextField
|
||
{...params}
|
||
label={t('models.modelId')}
|
||
placeholder={t('models.modelIdPlaceholder')}
|
||
onChange={e => {
|
||
setModelConfigForm(prev => ({
|
||
...prev,
|
||
modelId: e.target.value
|
||
}));
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
</FormControl>
|
||
<Button variant="contained" onClick={() => refreshProviderModels()} sx={{ ml: 2 }}>
|
||
{t('models.refresh')}
|
||
</Button>
|
||
</Grid>
|
||
{/* 模型名称 */}
|
||
<Grid item xs={12}>
|
||
<TextField
|
||
fullWidth
|
||
label={t('models.modelName')}
|
||
name="modelName"
|
||
value={modelConfigForm.modelName}
|
||
onChange={handleModelFormChange}
|
||
placeholder={t('models.modelNamePlaceholder')}
|
||
/>
|
||
</Grid>
|
||
{/* 新增:视觉模型选择项 */}
|
||
<Grid item xs={12}>
|
||
<FormControl fullWidth>
|
||
<InputLabel>{t('models.type')}</InputLabel>
|
||
<Select
|
||
label={t('models.type')}
|
||
value={modelConfigForm.type || 'text'}
|
||
onChange={handleModelFormChange}
|
||
name="type"
|
||
>
|
||
<MenuItem value="text">{t('models.text')}</MenuItem>
|
||
<MenuItem value="vision">{t('models.vision')}</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<Typography id="question-generation-length-slider" gutterBottom>
|
||
{t('models.temperature')}
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||
<Slider
|
||
min={0}
|
||
max={2}
|
||
name="temperature"
|
||
value={modelConfigForm.temperature}
|
||
onChange={handleModelFormChange}
|
||
step={0.1}
|
||
valueLabelDisplay="auto"
|
||
aria-label="Temperature"
|
||
sx={{ flex: 1 }}
|
||
/>
|
||
<Typography variant="body2" sx={{ minWidth: '40px' }}>
|
||
{modelConfigForm.temperature}
|
||
</Typography>
|
||
</Box>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<Typography id="question-generation-length-slider" gutterBottom>
|
||
{t('models.maxTokens')}
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||
<Slider
|
||
min={1}
|
||
max={MAX_GENERATION_TOKENS}
|
||
name="maxTokens"
|
||
value={Math.min(getSafeMaxTokensValue(modelConfigForm.maxTokens), MAX_GENERATION_TOKENS)}
|
||
onChange={handleMaxTokensSliderChange}
|
||
step={1}
|
||
valueLabelDisplay="auto"
|
||
aria-label="maxTokens"
|
||
sx={{ flex: 1 }}
|
||
/>
|
||
<TextField
|
||
size="small"
|
||
type="number"
|
||
value={modelConfigForm.maxTokens}
|
||
onChange={handleMaxTokensInputChange}
|
||
onBlur={handleMaxTokensInputBlur}
|
||
inputProps={{ min: 1, step: 1 }}
|
||
sx={{ width: 170 }}
|
||
/>
|
||
</Box>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{t('models.maxTokensInputTip', {
|
||
defaultValue: `Slider range: 1-${MAX_GENERATION_TOKENS}. You can also input any positive integer.`
|
||
})}
|
||
</Typography>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<Typography id="top-p-slider" gutterBottom>
|
||
{t('models.topP', { defaultValue: 'Top P' })}
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||
<Slider
|
||
min={0}
|
||
max={1}
|
||
name="topP"
|
||
value={modelConfigForm.topP}
|
||
onChange={handleModelFormChange}
|
||
step={0.1}
|
||
valueLabelDisplay="auto"
|
||
aria-label="topP"
|
||
sx={{ flex: 1 }}
|
||
/>
|
||
<Typography variant="body2" sx={{ minWidth: '40px' }}>
|
||
{modelConfigForm.topP}
|
||
</Typography>
|
||
</Box>
|
||
</Grid>
|
||
</Grid>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={handleCloseModelDialog}>{t('common.cancel')}</Button>
|
||
<Button
|
||
onClick={handleSaveModel}
|
||
variant="contained"
|
||
disabled={!modelConfigForm.providerId || !modelConfigForm.providerName || !modelConfigForm.endpoint}
|
||
>
|
||
{t('common.save')}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</Card>
|
||
);
|
||
}
|