EditModelModal.jsx 15 KB

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