|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from 'react'; |
|
|
import JSONEditor from '../../../common/ui/JSONEditor'; |
|
|
import { |
|
|
SideSheet, |
|
|
Form, |
|
|
Button, |
|
|
Space, |
|
|
Spin, |
|
|
Typography, |
|
|
Card, |
|
|
Tag, |
|
|
Avatar, |
|
|
Col, |
|
|
Row, |
|
|
} from '@douyinfe/semi-ui'; |
|
|
import { Save, X, FileText } from 'lucide-react'; |
|
|
import { IconLink } from '@douyinfe/semi-icons'; |
|
|
import { API, showError, showSuccess } from '../../../../helpers'; |
|
|
import { useTranslation } from 'react-i18next'; |
|
|
import { useIsMobile } from '../../../../hooks/common/useIsMobile'; |
|
|
|
|
|
const { Text, Title } = Typography; |
|
|
|
|
|
|
|
|
const ENDPOINT_TEMPLATE = { |
|
|
openai: { path: '/v1/chat/completions', method: 'POST' }, |
|
|
'openai-response': { path: '/v1/responses', method: 'POST' }, |
|
|
anthropic: { path: '/v1/messages', method: 'POST' }, |
|
|
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' }, |
|
|
'jina-rerank': { path: '/rerank', method: 'POST' }, |
|
|
'image-generation': { path: '/v1/images/generations', method: 'POST' }, |
|
|
}; |
|
|
|
|
|
const nameRuleOptions = [ |
|
|
{ label: '精确名称匹配', value: 0 }, |
|
|
{ label: '前缀名称匹配', value: 1 }, |
|
|
{ label: '包含名称匹配', value: 2 }, |
|
|
{ label: '后缀名称匹配', value: 3 }, |
|
|
]; |
|
|
|
|
|
const EditModelModal = (props) => { |
|
|
const { t } = useTranslation(); |
|
|
const [loading, setLoading] = useState(false); |
|
|
const isMobile = useIsMobile(); |
|
|
const formApiRef = useRef(null); |
|
|
const isEdit = props.editingModel && props.editingModel.id !== undefined; |
|
|
const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]); |
|
|
|
|
|
|
|
|
const [vendors, setVendors] = useState([]); |
|
|
|
|
|
|
|
|
const [tagGroups, setTagGroups] = useState([]); |
|
|
const [endpointGroups, setEndpointGroups] = useState([]); |
|
|
|
|
|
|
|
|
const fetchVendors = async () => { |
|
|
try { |
|
|
const res = await API.get('/api/vendors/?page_size=1000'); |
|
|
if (res.data.success) { |
|
|
const items = res.data.data.items || res.data.data || []; |
|
|
setVendors(Array.isArray(items) ? items : []); |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const fetchPrefillGroups = async () => { |
|
|
try { |
|
|
const [tagRes, endpointRes] = await Promise.all([ |
|
|
API.get('/api/prefill_group?type=tag'), |
|
|
API.get('/api/prefill_group?type=endpoint'), |
|
|
]); |
|
|
if (tagRes?.data?.success) { |
|
|
setTagGroups(tagRes.data.data || []); |
|
|
} |
|
|
if (endpointRes?.data?.success) { |
|
|
setEndpointGroups(endpointRes.data.data || []); |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
if (props.visiable) { |
|
|
fetchVendors(); |
|
|
fetchPrefillGroups(); |
|
|
} |
|
|
}, [props.visiable]); |
|
|
|
|
|
const getInitValues = () => ({ |
|
|
model_name: props.editingModel?.model_name || '', |
|
|
description: '', |
|
|
icon: '', |
|
|
tags: [], |
|
|
vendor_id: undefined, |
|
|
vendor: '', |
|
|
vendor_icon: '', |
|
|
endpoints: '', |
|
|
name_rule: props.editingModel?.model_name ? 0 : undefined, |
|
|
status: true, |
|
|
sync_official: true, |
|
|
}); |
|
|
|
|
|
const handleCancel = () => { |
|
|
props.handleClose(); |
|
|
}; |
|
|
|
|
|
const loadModel = async () => { |
|
|
if (!isEdit || !props.editingModel.id) return; |
|
|
|
|
|
setLoading(true); |
|
|
try { |
|
|
const res = await API.get(`/api/models/${props.editingModel.id}`); |
|
|
const { success, message, data } = res.data; |
|
|
if (success) { |
|
|
|
|
|
if (data.tags) { |
|
|
data.tags = data.tags.split(',').filter(Boolean); |
|
|
} else { |
|
|
data.tags = []; |
|
|
} |
|
|
|
|
|
if (!data.endpoints) { |
|
|
data.endpoints = ''; |
|
|
} |
|
|
|
|
|
data.status = data.status === 1; |
|
|
data.sync_official = (data.sync_official ?? 1) === 1; |
|
|
if (formApiRef.current) { |
|
|
formApiRef.current.setValues({ ...getInitValues(), ...data }); |
|
|
} |
|
|
} else { |
|
|
showError(message); |
|
|
} |
|
|
} catch (error) { |
|
|
showError(t('加载模型信息失败')); |
|
|
} |
|
|
setLoading(false); |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
if (formApiRef.current) { |
|
|
if (!isEdit) { |
|
|
formApiRef.current.setValues({ |
|
|
...getInitValues(), |
|
|
model_name: props.editingModel?.model_name || '', |
|
|
}); |
|
|
} |
|
|
} |
|
|
}, [props.editingModel?.id, props.editingModel?.model_name]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (props.visiable) { |
|
|
if (isEdit) { |
|
|
loadModel(); |
|
|
} else { |
|
|
formApiRef.current?.setValues({ |
|
|
...getInitValues(), |
|
|
model_name: props.editingModel?.model_name || '', |
|
|
}); |
|
|
} |
|
|
} else { |
|
|
formApiRef.current?.reset(); |
|
|
} |
|
|
}, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]); |
|
|
|
|
|
const submit = async (values) => { |
|
|
setLoading(true); |
|
|
try { |
|
|
const submitData = { |
|
|
...values, |
|
|
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags, |
|
|
endpoints: values.endpoints || '', |
|
|
status: values.status ? 1 : 0, |
|
|
sync_official: values.sync_official ? 1 : 0, |
|
|
}; |
|
|
|
|
|
if (isEdit) { |
|
|
submitData.id = props.editingModel.id; |
|
|
const res = await API.put('/api/models/', submitData); |
|
|
const { success, message } = res.data; |
|
|
if (success) { |
|
|
showSuccess(t('模型更新成功!')); |
|
|
props.refresh(); |
|
|
props.handleClose(); |
|
|
} else { |
|
|
showError(t(message)); |
|
|
} |
|
|
} else { |
|
|
const res = await API.post('/api/models/', submitData); |
|
|
const { success, message } = res.data; |
|
|
if (success) { |
|
|
showSuccess(t('模型创建成功!')); |
|
|
props.refresh(); |
|
|
props.handleClose(); |
|
|
} else { |
|
|
showError(t(message)); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
showError(error.response?.data?.message || t('操作失败')); |
|
|
} |
|
|
setLoading(false); |
|
|
formApiRef.current?.setValues(getInitValues()); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<SideSheet |
|
|
placement={placement} |
|
|
title={ |
|
|
<Space> |
|
|
{isEdit ? ( |
|
|
<Tag color='blue' shape='circle'> |
|
|
{t('更新')} |
|
|
</Tag> |
|
|
) : ( |
|
|
<Tag color='green' shape='circle'> |
|
|
{t('新建')} |
|
|
</Tag> |
|
|
)} |
|
|
<Title heading={4} className='m-0'> |
|
|
{isEdit ? t('更新模型信息') : t('创建新的模型')} |
|
|
</Title> |
|
|
</Space> |
|
|
} |
|
|
bodyStyle={{ padding: '0' }} |
|
|
visible={props.visiable} |
|
|
width={isMobile ? '100%' : 600} |
|
|
footer={ |
|
|
<div className='flex justify-end bg-white'> |
|
|
<Space> |
|
|
<Button |
|
|
theme='solid' |
|
|
className='!rounded-lg' |
|
|
onClick={() => formApiRef.current?.submitForm()} |
|
|
icon={<Save size={16} />} |
|
|
loading={loading} |
|
|
> |
|
|
{t('提交')} |
|
|
</Button> |
|
|
<Button |
|
|
theme='light' |
|
|
className='!rounded-lg' |
|
|
type='primary' |
|
|
onClick={handleCancel} |
|
|
icon={<X size={16} />} |
|
|
> |
|
|
{t('取消')} |
|
|
</Button> |
|
|
</Space> |
|
|
</div> |
|
|
} |
|
|
closeIcon={null} |
|
|
onCancel={() => handleCancel()} |
|
|
> |
|
|
<Spin spinning={loading}> |
|
|
<Form |
|
|
key={isEdit ? 'edit' : 'new'} |
|
|
initValues={getInitValues()} |
|
|
getFormApi={(api) => (formApiRef.current = api)} |
|
|
onSubmit={submit} |
|
|
> |
|
|
{({ values }) => ( |
|
|
<div className='p-2'> |
|
|
{/* 基本信息 */} |
|
|
<Card className='!rounded-2xl shadow-sm border-0'> |
|
|
<div className='flex items-center mb-2'> |
|
|
<Avatar size='small' color='green' className='mr-2 shadow-md'> |
|
|
<FileText size={16} /> |
|
|
</Avatar> |
|
|
<div> |
|
|
<Text className='text-lg font-medium'>{t('基本信息')}</Text> |
|
|
<div className='text-xs text-gray-600'> |
|
|
{t('设置模型的基本信息')} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<Row gutter={12}> |
|
|
<Col span={24}> |
|
|
<Form.Input |
|
|
field='model_name' |
|
|
label={t('模型名称')} |
|
|
placeholder={t('请输入模型名称,如:gpt-4')} |
|
|
rules={[{ required: true, message: t('请输入模型名称') }]} |
|
|
showClear |
|
|
/> |
|
|
</Col> |
|
|
|
|
|
<Col span={24}> |
|
|
<Form.Select |
|
|
field='name_rule' |
|
|
label={t('名称匹配类型')} |
|
|
placeholder={t('请选择名称匹配类型')} |
|
|
optionList={nameRuleOptions.map((o) => ({ |
|
|
label: t(o.label), |
|
|
value: o.value, |
|
|
}))} |
|
|
rules={[ |
|
|
{ required: true, message: t('请选择名称匹配类型') }, |
|
|
]} |
|
|
extraText={t( |
|
|
'根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含', |
|
|
)} |
|
|
style={{ width: '100%' }} |
|
|
/> |
|
|
</Col> |
|
|
|
|
|
<Col span={24}> |
|
|
<Form.Input |
|
|
field='icon' |
|
|
label={t('模型图标')} |
|
|
placeholder={t('请输入图标名称')} |
|
|
extraText={ |
|
|
<span> |
|
|
{t( |
|
|
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ", |
|
|
)} |
|
|
<Typography.Text |
|
|
link={{ |
|
|
href: 'https://icons.lobehub.com/components/lobe-hub', |
|
|
target: '_blank', |
|
|
}} |
|
|
icon={<IconLink />} |
|
|
underline |
|
|
> |
|
|
{t('请点击我')} |
|
|
</Typography.Text> |
|
|
</span> |
|
|
} |
|
|
showClear |
|
|
/> |
|
|
</Col> |
|
|
|
|
|
<Col span={24}> |
|
|
<Form.TextArea |
|
|
field='description' |
|
|
label={t('描述')} |
|
|
placeholder={t('请输入模型描述')} |
|
|
rows={3} |
|
|
showClear |
|
|
/> |
|
|
</Col> |
|
|
<Col span={24}> |
|
|
<Form.TagInput |
|
|
field='tags' |
|
|
label={t('标签')} |
|
|
placeholder={t('输入标签或使用","分隔多个标签')} |
|
|
addOnBlur |
|
|
showClear |
|
|
onChange={(newTags) => { |
|
|
if (!formApiRef.current) return; |
|
|
const normalize = (tags) => { |
|
|
if (!Array.isArray(tags)) return []; |
|
|
return [ |
|
|
...new Set( |
|
|
tags.flatMap((tag) => |
|
|
tag |
|
|
.split(',') |
|
|
.map((t) => t.trim()) |
|
|
.filter(Boolean), |
|
|
), |
|
|
), |
|
|
]; |
|
|
}; |
|
|
const normalized = normalize(newTags); |
|
|
formApiRef.current.setValue('tags', normalized); |
|
|
}} |
|
|
style={{ width: '100%' }} |
|
|
{...(tagGroups.length > 0 && { |
|
|
extraText: ( |
|
|
<Space wrap> |
|
|
{tagGroups.map((group) => ( |
|
|
<Button |
|
|
key={group.id} |
|
|
size='small' |
|
|
type='primary' |
|
|
onClick={() => { |
|
|
if (formApiRef.current) { |
|
|
const currentTags = |
|
|
formApiRef.current.getValue('tags') || []; |
|
|
const newTags = [ |
|
|
...currentTags, |
|
|
...(group.items || []), |
|
|
]; |
|
|
const uniqueTags = [...new Set(newTags)]; |
|
|
formApiRef.current.setValue( |
|
|
'tags', |
|
|
uniqueTags, |
|
|
); |
|
|
} |
|
|
}} |
|
|
> |
|
|
{group.name} |
|
|
</Button> |
|
|
))} |
|
|
</Space> |
|
|
), |
|
|
})} |
|
|
/> |
|
|
</Col> |
|
|
<Col span={24}> |
|
|
<Form.Select |
|
|
field='vendor_id' |
|
|
label={t('供应商')} |
|
|
placeholder={t('选择模型供应商')} |
|
|
optionList={vendors.map((v) => ({ |
|
|
label: v.name, |
|
|
value: v.id, |
|
|
}))} |
|
|
filter |
|
|
showClear |
|
|
onChange={(value) => { |
|
|
const vendorInfo = vendors.find((v) => v.id === value); |
|
|
if (vendorInfo && formApiRef.current) { |
|
|
formApiRef.current.setValue( |
|
|
'vendor', |
|
|
vendorInfo.name, |
|
|
); |
|
|
} |
|
|
}} |
|
|
style={{ width: '100%' }} |
|
|
/> |
|
|
</Col> |
|
|
<Col span={24}> |
|
|
<JSONEditor |
|
|
field='endpoints' |
|
|
label={t('端点映射')} |
|
|
placeholder={ |
|
|
'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}' |
|
|
} |
|
|
value={values.endpoints} |
|
|
onChange={(val) => |
|
|
formApiRef.current?.setValue('endpoints', val) |
|
|
} |
|
|
formApi={formApiRef.current} |
|
|
editorType='object' |
|
|
template={ENDPOINT_TEMPLATE} |
|
|
templateLabel={t('填入模板')} |
|
|
extraText={t('留空则使用默认端点;支持 {path, method}')} |
|
|
extraFooter={ |
|
|
endpointGroups.length > 0 && ( |
|
|
<Space wrap> |
|
|
{endpointGroups.map((group) => ( |
|
|
<Button |
|
|
key={group.id} |
|
|
size='small' |
|
|
type='primary' |
|
|
onClick={() => { |
|
|
try { |
|
|
const current = |
|
|
formApiRef.current?.getValue( |
|
|
'endpoints', |
|
|
) || ''; |
|
|
let base = {}; |
|
|
if (current && current.trim()) |
|
|
base = JSON.parse(current); |
|
|
const groupObj = |
|
|
typeof group.items === 'string' |
|
|
? JSON.parse(group.items || '{}') |
|
|
: group.items || {}; |
|
|
const merged = { ...base, ...groupObj }; |
|
|
formApiRef.current?.setValue( |
|
|
'endpoints', |
|
|
JSON.stringify(merged, null, 2), |
|
|
); |
|
|
} catch (e) { |
|
|
try { |
|
|
const groupObj = |
|
|
typeof group.items === 'string' |
|
|
? JSON.parse(group.items || '{}') |
|
|
: group.items || {}; |
|
|
formApiRef.current?.setValue( |
|
|
'endpoints', |
|
|
JSON.stringify(groupObj, null, 2), |
|
|
); |
|
|
} catch {} |
|
|
} |
|
|
}} |
|
|
> |
|
|
{group.name} |
|
|
</Button> |
|
|
))} |
|
|
</Space> |
|
|
) |
|
|
} |
|
|
/> |
|
|
</Col> |
|
|
<Col span={24}> |
|
|
<Form.Switch |
|
|
field='sync_official' |
|
|
label={t('参与官方同步')} |
|
|
extraText={t( |
|
|
'关闭后,此模型将不会被“同步官方”自动覆盖或创建', |
|
|
)} |
|
|
size='large' |
|
|
/> |
|
|
</Col> |
|
|
<Col span={24}> |
|
|
<Form.Switch |
|
|
field='status' |
|
|
label={t('状态')} |
|
|
size='large' |
|
|
/> |
|
|
</Col> |
|
|
</Row> |
|
|
</Card> |
|
|
</div> |
|
|
)} |
|
|
</Form> |
|
|
</Spin> |
|
|
</SideSheet> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default EditModelModal; |
|
|
|