EditModelModal.jsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useEffect, useRef, useMemo } from 'react';
  16. import JSONEditor from '../../../common/ui/JSONEditor';
  17. import {
  18. SideSheet,
  19. Form,
  20. Button,
  21. Space,
  22. Spin,
  23. Typography,
  24. Card,
  25. Tag,
  26. Avatar,
  27. Col,
  28. Row,
  29. } from '@douyinfe/semi-ui';
  30. import { Save, X, FileText } from 'lucide-react';
  31. import { IconLink } from '@douyinfe/semi-icons';
  32. import { API, showError, showSuccess } from '../../../../helpers';
  33. import { useTranslation } from 'react-i18next';
  34. import { useIsMobile } from '../../../../hooks/common/useIsMobile';
  35. const { Text, Title } = Typography;
  36. // Example endpoint template for quick fill
  37. const ENDPOINT_TEMPLATE = {
  38. openai: { path: '/v1/chat/completions', method: 'POST' },
  39. 'openai-response': { path: '/v1/responses', method: 'POST' },
  40. anthropic: { path: '/v1/messages', method: 'POST' },
  41. gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
  42. 'jina-rerank': { path: '/rerank', method: 'POST' },
  43. 'image-generation': { path: '/v1/images/generations', method: 'POST' },
  44. };
  45. const nameRuleOptions = [
  46. { label: '精确名称匹配', value: 0 },
  47. { label: '前缀名称匹配', value: 1 },
  48. { label: '包含名称匹配', value: 2 },
  49. { label: '后缀名称匹配', value: 3 },
  50. ];
  51. const EditModelModal = (props) => {
  52. const { t } = useTranslation();
  53. const [loading, setLoading] = useState(false);
  54. const isMobile = useIsMobile();
  55. const formApiRef = useRef(null);
  56. const isEdit = props.editingModel && props.editingModel.id !== undefined;
  57. const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
  58. // 供应商列表
  59. const [vendors, setVendors] = useState([]);
  60. // 预填组(标签、端点)
  61. const [tagGroups, setTagGroups] = useState([]);
  62. const [endpointGroups, setEndpointGroups] = useState([]);
  63. // 获取供应商列表
  64. const fetchVendors = async () => {
  65. try {
  66. const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
  67. if (res.data.success) {
  68. const items = res.data.data.items || res.data.data || [];
  69. setVendors(Array.isArray(items) ? items : []);
  70. }
  71. } catch (error) {
  72. // ignore
  73. }
  74. };
  75. // 获取预填组(标签、端点)
  76. const fetchPrefillGroups = async () => {
  77. try {
  78. const [tagRes, endpointRes] = await Promise.all([
  79. API.get('/api/prefill_group?type=tag'),
  80. API.get('/api/prefill_group?type=endpoint'),
  81. ]);
  82. if (tagRes?.data?.success) {
  83. setTagGroups(tagRes.data.data || []);
  84. }
  85. if (endpointRes?.data?.success) {
  86. setEndpointGroups(endpointRes.data.data || []);
  87. }
  88. } catch (error) {
  89. // ignore
  90. }
  91. };
  92. useEffect(() => {
  93. if (props.visiable) {
  94. fetchVendors();
  95. fetchPrefillGroups();
  96. }
  97. }, [props.visiable]);
  98. const getInitValues = () => ({
  99. model_name: props.editingModel?.model_name || '',
  100. description: '',
  101. icon: '',
  102. tags: [],
  103. vendor_id: undefined,
  104. vendor: '',
  105. vendor_icon: '',
  106. endpoints: '',
  107. name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
  108. status: true,
  109. });
  110. const handleCancel = () => {
  111. props.handleClose();
  112. };
  113. const loadModel = async () => {
  114. if (!isEdit || !props.editingModel.id) return;
  115. setLoading(true);
  116. try {
  117. const res = await API.get(`/api/models/${props.editingModel.id}`);
  118. const { success, message, data } = res.data;
  119. if (success) {
  120. // 处理tags
  121. if (data.tags) {
  122. data.tags = data.tags.split(',').filter(Boolean);
  123. } else {
  124. data.tags = [];
  125. }
  126. // endpoints 保持原始 JSON 字符串,若为空设为空串
  127. if (!data.endpoints) {
  128. data.endpoints = '';
  129. }
  130. // 处理status,将数字转为布尔值
  131. data.status = data.status === 1;
  132. if (formApiRef.current) {
  133. formApiRef.current.setValues({ ...getInitValues(), ...data });
  134. }
  135. } else {
  136. showError(message);
  137. }
  138. } catch (error) {
  139. showError(t('加载模型信息失败'));
  140. }
  141. setLoading(false);
  142. };
  143. useEffect(() => {
  144. if (formApiRef.current) {
  145. if (!isEdit) {
  146. formApiRef.current.setValues({
  147. ...getInitValues(),
  148. model_name: props.editingModel?.model_name || '',
  149. });
  150. }
  151. }
  152. }, [props.editingModel?.id, props.editingModel?.model_name]);
  153. useEffect(() => {
  154. if (props.visiable) {
  155. if (isEdit) {
  156. loadModel();
  157. } else {
  158. formApiRef.current?.setValues({
  159. ...getInitValues(),
  160. model_name: props.editingModel?.model_name || '',
  161. });
  162. }
  163. } else {
  164. formApiRef.current?.reset();
  165. }
  166. }, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
  167. const submit = async (values) => {
  168. setLoading(true);
  169. try {
  170. const submitData = {
  171. ...values,
  172. tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
  173. endpoints: values.endpoints || '',
  174. status: values.status ? 1 : 0,
  175. };
  176. if (isEdit) {
  177. submitData.id = props.editingModel.id;
  178. const res = await API.put('/api/models/', submitData);
  179. const { success, message } = res.data;
  180. if (success) {
  181. showSuccess(t('模型更新成功!'));
  182. props.refresh();
  183. props.handleClose();
  184. } else {
  185. showError(t(message));
  186. }
  187. } else {
  188. const res = await API.post('/api/models/', submitData);
  189. const { success, message } = res.data;
  190. if (success) {
  191. showSuccess(t('模型创建成功!'));
  192. props.refresh();
  193. props.handleClose();
  194. } else {
  195. showError(t(message));
  196. }
  197. }
  198. } catch (error) {
  199. showError(error.response?.data?.message || t('操作失败'));
  200. }
  201. setLoading(false);
  202. formApiRef.current?.setValues(getInitValues());
  203. };
  204. return (
  205. <SideSheet
  206. placement={placement}
  207. title={
  208. <Space>
  209. {isEdit ? (
  210. <Tag color='blue' shape='circle'>
  211. {t('更新')}
  212. </Tag>
  213. ) : (
  214. <Tag color='green' shape='circle'>
  215. {t('新建')}
  216. </Tag>
  217. )}
  218. <Title heading={4} className='m-0'>
  219. {isEdit ? t('更新模型信息') : t('创建新的模型')}
  220. </Title>
  221. </Space>
  222. }
  223. bodyStyle={{ padding: '0' }}
  224. visible={props.visiable}
  225. width={isMobile ? '100%' : 600}
  226. footer={
  227. <div className='flex justify-end bg-white'>
  228. <Space>
  229. <Button
  230. theme='solid'
  231. className='!rounded-lg'
  232. onClick={() => formApiRef.current?.submitForm()}
  233. icon={<Save size={16} />}
  234. loading={loading}
  235. >
  236. {t('提交')}
  237. </Button>
  238. <Button
  239. theme='light'
  240. className='!rounded-lg'
  241. type='primary'
  242. onClick={handleCancel}
  243. icon={<X size={16} />}
  244. >
  245. {t('取消')}
  246. </Button>
  247. </Space>
  248. </div>
  249. }
  250. closeIcon={null}
  251. onCancel={() => handleCancel()}
  252. >
  253. <Spin spinning={loading}>
  254. <Form
  255. key={isEdit ? 'edit' : 'new'}
  256. initValues={getInitValues()}
  257. getFormApi={(api) => (formApiRef.current = api)}
  258. onSubmit={submit}
  259. >
  260. {({ values }) => (
  261. <div className='p-2'>
  262. {/* 基本信息 */}
  263. <Card className='!rounded-2xl shadow-sm border-0'>
  264. <div className='flex items-center mb-2'>
  265. <Avatar size='small' color='green' className='mr-2 shadow-md'>
  266. <FileText size={16} />
  267. </Avatar>
  268. <div>
  269. <Text className='text-lg font-medium'>{t('基本信息')}</Text>
  270. <div className='text-xs text-gray-600'>{t('设置模型的基本信息')}</div>
  271. </div>
  272. </div>
  273. <Row gutter={12}>
  274. <Col span={24}>
  275. <Form.Input
  276. field='model_name'
  277. label={t('模型名称')}
  278. placeholder={t('请输入模型名称,如:gpt-4')}
  279. rules={[{ required: true, message: t('请输入模型名称') }]}
  280. showClear
  281. />
  282. </Col>
  283. <Col span={24}>
  284. <Form.Select
  285. field='name_rule'
  286. label={t('名称匹配类型')}
  287. placeholder={t('请选择名称匹配类型')}
  288. optionList={nameRuleOptions.map(o => ({ label: t(o.label), value: o.value }))}
  289. rules={[{ required: true, message: t('请选择名称匹配类型') }]}
  290. extraText={t('根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含')}
  291. style={{ width: '100%' }}
  292. />
  293. </Col>
  294. <Col span={24}>
  295. <Form.Input
  296. field='icon'
  297. label={t('模型图标')}
  298. placeholder={t('请输入图标名称')}
  299. extraText={
  300. <span>
  301. {t('图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={\'platform\'}、OpenRouter.Avatar.shape={\'square\'},查询所有可用图标请 ')}
  302. <Typography.Text
  303. link={{ href: 'https://icons.lobehub.com/components/lobe-hub', target: '_blank' }}
  304. icon={<IconLink />}
  305. underline
  306. >
  307. {t('请点击我')}
  308. </Typography.Text>
  309. </span>
  310. }
  311. showClear
  312. />
  313. </Col>
  314. <Col span={24}>
  315. <Form.TextArea
  316. field='description'
  317. label={t('描述')}
  318. placeholder={t('请输入模型描述')}
  319. rows={3}
  320. showClear
  321. />
  322. </Col>
  323. <Col span={24}>
  324. <Form.TagInput
  325. field='tags'
  326. label={t('标签')}
  327. placeholder={t('输入标签或使用","分隔多个标签')}
  328. addOnBlur
  329. showClear
  330. onChange={(newTags) => {
  331. if (!formApiRef.current) return;
  332. const normalize = (tags) => {
  333. if (!Array.isArray(tags)) return [];
  334. return [...new Set(tags.flatMap(tag => tag.split(',').map(t => t.trim()).filter(Boolean)))];
  335. };
  336. const normalized = normalize(newTags);
  337. formApiRef.current.setValue('tags', normalized);
  338. }}
  339. style={{ width: '100%' }}
  340. {...(tagGroups.length > 0 && {
  341. extraText: (
  342. <Space wrap>
  343. {tagGroups.map(group => (
  344. <Button
  345. key={group.id}
  346. size='small'
  347. type='primary'
  348. onClick={() => {
  349. if (formApiRef.current) {
  350. const currentTags = formApiRef.current.getValue('tags') || [];
  351. const newTags = [...currentTags, ...(group.items || [])];
  352. const uniqueTags = [...new Set(newTags)];
  353. formApiRef.current.setValue('tags', uniqueTags);
  354. }
  355. }}
  356. >
  357. {group.name}
  358. </Button>
  359. ))}
  360. </Space>
  361. )
  362. })}
  363. />
  364. </Col>
  365. <Col span={24}>
  366. <Form.Select
  367. field='vendor_id'
  368. label={t('供应商')}
  369. placeholder={t('选择模型供应商')}
  370. optionList={vendors.map(v => ({ label: v.name, value: v.id }))}
  371. filter
  372. showClear
  373. onChange={(value) => {
  374. const vendorInfo = vendors.find(v => v.id === value);
  375. if (vendorInfo && formApiRef.current) {
  376. formApiRef.current.setValue('vendor', vendorInfo.name);
  377. }
  378. }}
  379. style={{ width: '100%' }}
  380. />
  381. </Col>
  382. <Col span={24}>
  383. <JSONEditor
  384. field='endpoints'
  385. label={t('端点映射')}
  386. placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
  387. value={values.endpoints}
  388. onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
  389. formApi={formApiRef.current}
  390. editorType='object'
  391. template={ENDPOINT_TEMPLATE}
  392. templateLabel={t('填入模板')}
  393. extraText={t('留空则使用默认端点;支持 {path, method}')}
  394. extraFooter={endpointGroups.length > 0 && (
  395. <Space wrap>
  396. {endpointGroups.map(group => (
  397. <Button
  398. key={group.id}
  399. size='small'
  400. type='primary'
  401. onClick={() => {
  402. try {
  403. const current = formApiRef.current?.getValue('endpoints') || '';
  404. let base = {};
  405. if (current && current.trim()) base = JSON.parse(current);
  406. const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
  407. const merged = { ...base, ...groupObj };
  408. formApiRef.current?.setValue('endpoints', JSON.stringify(merged, null, 2));
  409. } catch (e) {
  410. try {
  411. const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
  412. formApiRef.current?.setValue('endpoints', JSON.stringify(groupObj, null, 2));
  413. } catch { }
  414. }
  415. }}
  416. >
  417. {group.name}
  418. </Button>
  419. ))}
  420. </Space>
  421. )}
  422. />
  423. </Col>
  424. <Col span={24}>
  425. <Form.Switch
  426. field='status'
  427. label={t('状态')}
  428. size="large"
  429. />
  430. </Col>
  431. </Row>
  432. </Card>
  433. </div>
  434. )}
  435. </Form>
  436. </Spin>
  437. </SideSheet>
  438. );
  439. };
  440. export default EditModelModal;