SettingsChats.jsx 16 KB

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