SettingsChats.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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, { useEffect, useState, useRef } from 'react';
  16. import {
  17. Banner,
  18. Button,
  19. Form,
  20. Space,
  21. Spin,
  22. RadioGroup,
  23. Radio,
  24. Table,
  25. Modal,
  26. Input,
  27. Divider,
  28. } from '@douyinfe/semi-ui';
  29. import {
  30. IconPlus,
  31. IconEdit,
  32. IconDelete,
  33. IconSearch,
  34. IconSaveStroked,
  35. } from '@douyinfe/semi-icons';
  36. import {
  37. compareObjects,
  38. API,
  39. showError,
  40. showSuccess,
  41. showWarning,
  42. verifyJSON,
  43. } from '../../../helpers';
  44. import { useTranslation } from 'react-i18next';
  45. export default function SettingsChats(props) {
  46. const { t } = useTranslation();
  47. const [loading, setLoading] = useState(false);
  48. const [inputs, setInputs] = useState({
  49. Chats: '[]',
  50. });
  51. const refForm = useRef();
  52. const [inputsRow, setInputsRow] = useState(inputs);
  53. const [editMode, setEditMode] = useState('visual');
  54. const [chatConfigs, setChatConfigs] = useState([]);
  55. const [modalVisible, setModalVisible] = useState(false);
  56. const [editingConfig, setEditingConfig] = useState(null);
  57. const [isEdit, setIsEdit] = useState(false);
  58. const [searchText, setSearchText] = useState('');
  59. const modalFormRef = useRef();
  60. const jsonToConfigs = (jsonString) => {
  61. try {
  62. const configs = JSON.parse(jsonString);
  63. return Array.isArray(configs)
  64. ? configs.map((config, index) => ({
  65. id: index,
  66. name: Object.keys(config)[0] || '',
  67. url: Object.values(config)[0] || '',
  68. }))
  69. : [];
  70. } catch (error) {
  71. console.error('JSON parse error:', error);
  72. return [];
  73. }
  74. };
  75. const configsToJson = (configs) => {
  76. const jsonArray = configs.map((config) => ({
  77. [config.name]: config.url,
  78. }));
  79. return JSON.stringify(jsonArray, null, 2);
  80. };
  81. const syncJsonToConfigs = () => {
  82. const configs = jsonToConfigs(inputs.Chats);
  83. setChatConfigs(configs);
  84. };
  85. const syncConfigsToJson = (configs) => {
  86. const jsonString = configsToJson(configs);
  87. setInputs((prev) => ({
  88. ...prev,
  89. Chats: jsonString,
  90. }));
  91. if (refForm.current && editMode === 'json') {
  92. refForm.current.setValues({ Chats: jsonString });
  93. }
  94. };
  95. async function onSubmit() {
  96. try {
  97. console.log('Starting validation...');
  98. await refForm.current
  99. .validate()
  100. .then(() => {
  101. console.log('Validation passed');
  102. const updateArray = compareObjects(inputs, inputsRow);
  103. if (!updateArray.length)
  104. return showWarning(t('你似乎并没有修改什么'));
  105. const requestQueue = updateArray.map((item) => {
  106. let value = '';
  107. if (typeof inputs[item.key] === 'boolean') {
  108. value = String(inputs[item.key]);
  109. } else {
  110. value = inputs[item.key];
  111. }
  112. return API.put('/api/option/', {
  113. key: item.key,
  114. value,
  115. });
  116. });
  117. setLoading(true);
  118. Promise.all(requestQueue)
  119. .then((res) => {
  120. if (requestQueue.length === 1) {
  121. if (res.includes(undefined)) return;
  122. } else if (requestQueue.length > 1) {
  123. if (res.includes(undefined))
  124. return showError(t('部分保存失败,请重试'));
  125. }
  126. showSuccess(t('保存成功'));
  127. props.refresh();
  128. })
  129. .catch(() => {
  130. showError(t('保存失败,请重试'));
  131. })
  132. .finally(() => {
  133. setLoading(false);
  134. });
  135. })
  136. .catch((error) => {
  137. console.error('Validation failed:', error);
  138. showError(t('请检查输入'));
  139. });
  140. } catch (error) {
  141. showError(t('请检查输入'));
  142. console.error(error);
  143. }
  144. }
  145. useEffect(() => {
  146. const currentInputs = {};
  147. for (let key in props.options) {
  148. if (Object.keys(inputs).includes(key)) {
  149. if (key === 'Chats') {
  150. const obj = JSON.parse(props.options[key]);
  151. currentInputs[key] = JSON.stringify(obj, null, 2);
  152. } else {
  153. currentInputs[key] = props.options[key];
  154. }
  155. }
  156. }
  157. setInputs(currentInputs);
  158. setInputsRow(structuredClone(currentInputs));
  159. if (refForm.current) {
  160. refForm.current.setValues(currentInputs);
  161. }
  162. // 同步到可视化配置
  163. const configs = jsonToConfigs(currentInputs.Chats || '[]');
  164. setChatConfigs(configs);
  165. }, [props.options]);
  166. useEffect(() => {
  167. if (editMode === 'visual') {
  168. syncJsonToConfigs();
  169. }
  170. }, [inputs.Chats, editMode]);
  171. useEffect(() => {
  172. if (refForm.current && editMode === 'json') {
  173. refForm.current.setValues(inputs);
  174. }
  175. }, [editMode, inputs]);
  176. const handleAddConfig = () => {
  177. setEditingConfig({ name: '', url: '' });
  178. setIsEdit(false);
  179. setModalVisible(true);
  180. setTimeout(() => {
  181. if (modalFormRef.current) {
  182. modalFormRef.current.setValues({ name: '', url: '' });
  183. }
  184. }, 100);
  185. };
  186. const handleEditConfig = (config) => {
  187. setEditingConfig({ ...config });
  188. setIsEdit(true);
  189. setModalVisible(true);
  190. setTimeout(() => {
  191. if (modalFormRef.current) {
  192. modalFormRef.current.setValues(config);
  193. }
  194. }, 100);
  195. };
  196. const handleDeleteConfig = (id) => {
  197. const newConfigs = chatConfigs.filter((config) => config.id !== id);
  198. setChatConfigs(newConfigs);
  199. syncConfigsToJson(newConfigs);
  200. showSuccess(t('删除成功'));
  201. };
  202. const handleModalOk = () => {
  203. if (modalFormRef.current) {
  204. modalFormRef.current
  205. .validate()
  206. .then((values) => {
  207. // 检查名称是否重复
  208. const isDuplicate = chatConfigs.some(
  209. (config) =>
  210. config.name === values.name &&
  211. (!isEdit || config.id !== editingConfig.id)
  212. );
  213. if (isDuplicate) {
  214. showError(t('聊天应用名称已存在,请使用其他名称'));
  215. return;
  216. }
  217. if (isEdit) {
  218. const newConfigs = chatConfigs.map((config) =>
  219. config.id === editingConfig.id
  220. ? { ...editingConfig, name: values.name, url: values.url }
  221. : config,
  222. );
  223. setChatConfigs(newConfigs);
  224. syncConfigsToJson(newConfigs);
  225. } else {
  226. const maxId =
  227. chatConfigs.length > 0
  228. ? Math.max(...chatConfigs.map((c) => c.id))
  229. : -1;
  230. const newConfig = {
  231. id: maxId + 1,
  232. name: values.name,
  233. url: values.url,
  234. };
  235. const newConfigs = [...chatConfigs, newConfig];
  236. setChatConfigs(newConfigs);
  237. syncConfigsToJson(newConfigs);
  238. }
  239. setModalVisible(false);
  240. setEditingConfig(null);
  241. showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
  242. })
  243. .catch((error) => {
  244. console.error('Modal form validation error:', error);
  245. });
  246. }
  247. };
  248. const handleModalCancel = () => {
  249. setModalVisible(false);
  250. setEditingConfig(null);
  251. };
  252. const filteredConfigs = chatConfigs.filter(
  253. (config) =>
  254. !searchText ||
  255. config.name.toLowerCase().includes(searchText.toLowerCase()),
  256. );
  257. const highlightKeywords = (text) => {
  258. if (!text) return text;
  259. const parts = text.split(/(\{address\}|\{key\})/g);
  260. return parts.map((part, index) => {
  261. if (part === '{address}') {
  262. return (
  263. <span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
  264. {part}
  265. </span>
  266. );
  267. } else if (part === '{key}') {
  268. return (
  269. <span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
  270. {part}
  271. </span>
  272. );
  273. }
  274. return part;
  275. });
  276. };
  277. const columns = [
  278. {
  279. title: t('聊天应用名称'),
  280. dataIndex: 'name',
  281. key: 'name',
  282. render: (text) => text || t('未命名'),
  283. },
  284. {
  285. title: t('URL链接'),
  286. dataIndex: 'url',
  287. key: 'url',
  288. render: (text) => (
  289. <div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
  290. {highlightKeywords(text)}
  291. </div>
  292. ),
  293. },
  294. {
  295. title: t('操作'),
  296. key: 'action',
  297. render: (_, record) => (
  298. <Space>
  299. <Button
  300. type='primary'
  301. icon={<IconEdit />}
  302. size='small'
  303. onClick={() => handleEditConfig(record)}
  304. >
  305. {t('编辑')}
  306. </Button>
  307. <Button
  308. type='danger'
  309. icon={<IconDelete />}
  310. size='small'
  311. onClick={() => handleDeleteConfig(record.id)}
  312. >
  313. {t('删除')}
  314. </Button>
  315. </Space>
  316. ),
  317. },
  318. ];
  319. return (
  320. <Spin spinning={loading}>
  321. <Space vertical style={{ width: '100%' }}>
  322. <Form.Section text={t('聊天设置')}>
  323. <Banner
  324. type='info'
  325. description={t(
  326. '链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
  327. )}
  328. />
  329. <Divider />
  330. <div style={{ marginBottom: 16 }}>
  331. <span style={{ marginRight: 16, fontWeight: 600 }}>
  332. {t('编辑模式')}:
  333. </span>
  334. <RadioGroup
  335. type='button'
  336. value={editMode}
  337. onChange={(e) => {
  338. const newMode = e.target.value;
  339. setEditMode(newMode);
  340. // 确保模式切换时数据正确同步
  341. setTimeout(() => {
  342. if (newMode === 'json' && refForm.current) {
  343. refForm.current.setValues(inputs);
  344. }
  345. }, 100);
  346. }}
  347. >
  348. <Radio value='visual'>{t('可视化编辑')}</Radio>
  349. <Radio value='json'>{t('JSON编辑')}</Radio>
  350. </RadioGroup>
  351. </div>
  352. {editMode === 'visual' ? (
  353. <div>
  354. <Space style={{ marginBottom: 16 }}>
  355. <Button
  356. type='primary'
  357. icon={<IconPlus />}
  358. onClick={handleAddConfig}
  359. >
  360. {t('添加聊天配置')}
  361. </Button>
  362. <Button
  363. type='primary'
  364. theme='solid'
  365. icon={<IconSaveStroked />}
  366. onClick={onSubmit}
  367. >
  368. {t('保存聊天设置')}
  369. </Button>
  370. <Input
  371. prefix={<IconSearch />}
  372. placeholder={t('搜索聊天应用名称')}
  373. value={searchText}
  374. onChange={(value) => setSearchText(value)}
  375. style={{ width: 250 }}
  376. showClear
  377. />
  378. </Space>
  379. <Table
  380. columns={columns}
  381. dataSource={filteredConfigs}
  382. rowKey='id'
  383. pagination={{
  384. pageSize: 10,
  385. showSizeChanger: false,
  386. showQuickJumper: true,
  387. showTotal: (total, range) =>
  388. t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
  389. total,
  390. start: range[0],
  391. end: range[1],
  392. }),
  393. }}
  394. />
  395. </div>
  396. ) : (
  397. <Form
  398. values={inputs}
  399. getFormApi={(formAPI) => (refForm.current = formAPI)}
  400. >
  401. <Form.TextArea
  402. label={t('聊天配置')}
  403. extraText={''}
  404. placeholder={t('为一个 JSON 文本')}
  405. field={'Chats'}
  406. autosize={{ minRows: 6, maxRows: 12 }}
  407. trigger='blur'
  408. stopValidateWithError
  409. rules={[
  410. {
  411. validator: (rule, value) => {
  412. return verifyJSON(value);
  413. },
  414. message: t('不是合法的 JSON 字符串'),
  415. },
  416. ]}
  417. onChange={(value) =>
  418. setInputs({
  419. ...inputs,
  420. Chats: value,
  421. })
  422. }
  423. />
  424. </Form>
  425. )}
  426. </Form.Section>
  427. {editMode === 'json' && (
  428. <Space>
  429. <Button
  430. type='primary'
  431. icon={<IconSaveStroked />}
  432. onClick={onSubmit}
  433. >
  434. {t('保存聊天设置')}
  435. </Button>
  436. </Space>
  437. )}
  438. </Space>
  439. <Modal
  440. title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
  441. visible={modalVisible}
  442. onOk={handleModalOk}
  443. onCancel={handleModalCancel}
  444. width={600}
  445. >
  446. <Form getFormApi={(api) => (modalFormRef.current = api)}>
  447. <Form.Input
  448. field='name'
  449. label={t('聊天应用名称')}
  450. placeholder={t('请输入聊天应用名称')}
  451. rules={[
  452. { required: true, message: t('请输入聊天应用名称') },
  453. { min: 1, message: t('名称不能为空') },
  454. ]}
  455. />
  456. <Form.Input
  457. field='url'
  458. label={t('URL链接')}
  459. placeholder={t('请输入完整的URL链接')}
  460. rules={[{ required: true, message: t('请输入URL链接') }]}
  461. />
  462. <Banner
  463. type='info'
  464. description={t(
  465. '提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址',
  466. )}
  467. style={{ marginTop: 16 }}
  468. />
  469. </Form>
  470. </Modal>
  471. </Spin>
  472. );
  473. }