EditModelModal.jsx 15 KB

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