SettingsChats.jsx 16 KB

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