EditModelModal.jsx 15 KB

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