| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- /*
- Copyright (C) 2025 QuantumNous
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
- For commercial licensing, please contact support@quantumnous.com
- */
- import React, { useEffect, useState, useRef } from 'react';
- import {
- Banner,
- Button,
- Dropdown,
- Form,
- Space,
- Spin,
- RadioGroup,
- Radio,
- Table,
- Modal,
- Input,
- Divider,
- } from '@douyinfe/semi-ui';
- import {
- IconPlus,
- IconEdit,
- IconDelete,
- IconSearch,
- IconSaveStroked,
- IconBolt,
- } from '@douyinfe/semi-icons';
- import {
- compareObjects,
- API,
- showError,
- showSuccess,
- showWarning,
- verifyJSON,
- } from '../../../helpers';
- import { useTranslation } from 'react-i18next';
- export default function SettingsChats(props) {
- const { t } = useTranslation();
- const [loading, setLoading] = useState(false);
- const [inputs, setInputs] = useState({
- Chats: '[]',
- });
- const refForm = useRef();
- const [inputsRow, setInputsRow] = useState(inputs);
- const [editMode, setEditMode] = useState('visual');
- const [chatConfigs, setChatConfigs] = useState([]);
- const [modalVisible, setModalVisible] = useState(false);
- const [editingConfig, setEditingConfig] = useState(null);
- const [isEdit, setIsEdit] = useState(false);
- const [searchText, setSearchText] = useState('');
- const modalFormRef = useRef();
- const BUILTIN_TEMPLATES = [
- { name: 'Cherry Studio', url: 'cherrystudio://providers/api-keys?v=1&data={cherryConfig}' },
- { name: '流畅阅读', url: 'fluentread' },
- { name: 'CC Switch', url: 'ccswitch' },
- { name: 'Lobe Chat', url: 'https://chat-preview.lobehub.com/?settings={"keyVaults":{"openai":{"apiKey":"{key}","baseURL":"{address}/v1"}}}' },
- { name: 'AI as Workspace', url: 'https://aiaw.app/set-provider?provider={"type":"openai","settings":{"apiKey":"{key}","baseURL":"{address}/v1","compatibility":"strict"}}' },
- { name: 'AMA 问天', url: 'ama://set-api-key?server={address}&key={key}' },
- { name: 'OpenCat', url: 'opencat://team/join?domain={address}&token={key}' },
- ];
- const addTemplates = (templates) => {
- const existingNames = new Set(chatConfigs.map((c) => c.name));
- const toAdd = templates.filter((tpl) => !existingNames.has(tpl.name));
- if (toAdd.length === 0) {
- showWarning(t('所选模板已存在'));
- return;
- }
- let maxId = chatConfigs.length > 0
- ? Math.max(...chatConfigs.map((c) => c.id))
- : -1;
- const newItems = toAdd.map((tpl) => ({
- id: ++maxId,
- name: tpl.name,
- url: tpl.url,
- }));
- const newConfigs = [...chatConfigs, ...newItems];
- setChatConfigs(newConfigs);
- syncConfigsToJson(newConfigs);
- showSuccess(t('已添加 {{count}} 个模板', { count: toAdd.length }));
- };
- const jsonToConfigs = (jsonString) => {
- try {
- const configs = JSON.parse(jsonString);
- return Array.isArray(configs)
- ? configs.map((config, index) => ({
- id: index,
- name: Object.keys(config)[0] || '',
- url: Object.values(config)[0] || '',
- }))
- : [];
- } catch (error) {
- console.error('JSON parse error:', error);
- return [];
- }
- };
- const configsToJson = (configs) => {
- const jsonArray = configs.map((config) => ({
- [config.name]: config.url,
- }));
- return JSON.stringify(jsonArray, null, 2);
- };
- const syncJsonToConfigs = () => {
- const configs = jsonToConfigs(inputs.Chats);
- setChatConfigs(configs);
- };
- const syncConfigsToJson = (configs) => {
- const jsonString = configsToJson(configs);
- setInputs((prev) => ({
- ...prev,
- Chats: jsonString,
- }));
- if (refForm.current && editMode === 'json') {
- refForm.current.setValues({ Chats: jsonString });
- }
- };
- async function onSubmit() {
- try {
- if (editMode === 'json' && refForm.current) {
- try {
- await refForm.current.validate();
- } catch (error) {
- console.error('Validation failed:', error);
- showError(t('请检查输入'));
- return;
- }
- }
- const updateArray = compareObjects(inputs, inputsRow);
- if (!updateArray.length)
- return showWarning(t('你似乎并没有修改什么'));
- const requestQueue = updateArray.map((item) => {
- let value = '';
- if (typeof inputs[item.key] === 'boolean') {
- value = String(inputs[item.key]);
- } else {
- value = inputs[item.key];
- }
- return API.put('/api/option/', {
- key: item.key,
- value,
- });
- });
- setLoading(true);
- try {
- const res = await Promise.all(requestQueue);
- if (res.includes(undefined)) {
- if (requestQueue.length > 1) {
- showError(t('部分保存失败,请重试'));
- }
- return;
- }
- showSuccess(t('保存成功'));
- props.refresh();
- } catch {
- showError(t('保存失败,请重试'));
- } finally {
- setLoading(false);
- }
- } catch (error) {
- showError(t('请检查输入'));
- console.error(error);
- }
- }
- useEffect(() => {
- const currentInputs = {};
- for (let key in props.options) {
- if (Object.keys(inputs).includes(key)) {
- if (key === 'Chats') {
- const obj = JSON.parse(props.options[key]);
- currentInputs[key] = JSON.stringify(obj, null, 2);
- } else {
- currentInputs[key] = props.options[key];
- }
- }
- }
- setInputs(currentInputs);
- setInputsRow(structuredClone(currentInputs));
- if (refForm.current) {
- refForm.current.setValues(currentInputs);
- }
- // 同步到可视化配置
- const configs = jsonToConfigs(currentInputs.Chats || '[]');
- setChatConfigs(configs);
- }, [props.options]);
- useEffect(() => {
- if (editMode === 'visual') {
- syncJsonToConfigs();
- }
- }, [inputs.Chats, editMode]);
- useEffect(() => {
- if (refForm.current && editMode === 'json') {
- refForm.current.setValues(inputs);
- }
- }, [editMode, inputs]);
- const handleAddConfig = () => {
- setEditingConfig({ name: '', url: '' });
- setIsEdit(false);
- setModalVisible(true);
- setTimeout(() => {
- if (modalFormRef.current) {
- modalFormRef.current.setValues({ name: '', url: '' });
- }
- }, 100);
- };
- const handleEditConfig = (config) => {
- setEditingConfig({ ...config });
- setIsEdit(true);
- setModalVisible(true);
- setTimeout(() => {
- if (modalFormRef.current) {
- modalFormRef.current.setValues(config);
- }
- }, 100);
- };
- const handleDeleteConfig = (id) => {
- const newConfigs = chatConfigs.filter((config) => config.id !== id);
- setChatConfigs(newConfigs);
- syncConfigsToJson(newConfigs);
- showSuccess(t('删除成功'));
- };
- const handleModalOk = () => {
- if (modalFormRef.current) {
- modalFormRef.current
- .validate()
- .then((values) => {
- // 检查名称是否重复
- const isDuplicate = chatConfigs.some(
- (config) =>
- config.name === values.name &&
- (!isEdit || config.id !== editingConfig.id),
- );
- if (isDuplicate) {
- showError(t('聊天应用名称已存在,请使用其他名称'));
- return;
- }
- if (isEdit) {
- const newConfigs = chatConfigs.map((config) =>
- config.id === editingConfig.id
- ? { ...editingConfig, name: values.name, url: values.url }
- : config,
- );
- setChatConfigs(newConfigs);
- syncConfigsToJson(newConfigs);
- } else {
- const maxId =
- chatConfigs.length > 0
- ? Math.max(...chatConfigs.map((c) => c.id))
- : -1;
- const newConfig = {
- id: maxId + 1,
- name: values.name,
- url: values.url,
- };
- const newConfigs = [...chatConfigs, newConfig];
- setChatConfigs(newConfigs);
- syncConfigsToJson(newConfigs);
- }
- setModalVisible(false);
- setEditingConfig(null);
- showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
- })
- .catch((error) => {
- console.error('Modal form validation error:', error);
- });
- }
- };
- const handleModalCancel = () => {
- setModalVisible(false);
- setEditingConfig(null);
- };
- const filteredConfigs = chatConfigs.filter(
- (config) =>
- !searchText ||
- config.name.toLowerCase().includes(searchText.toLowerCase()),
- );
- const highlightKeywords = (text) => {
- if (!text) return text;
- const parts = text.split(/(\{address\}|\{key\})/g);
- return parts.map((part, index) => {
- if (part === '{address}') {
- return (
- <span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
- {part}
- </span>
- );
- } else if (part === '{key}') {
- return (
- <span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
- {part}
- </span>
- );
- }
- return part;
- });
- };
- const columns = [
- {
- title: t('聊天应用名称'),
- dataIndex: 'name',
- key: 'name',
- render: (text) => text || t('未命名'),
- },
- {
- title: t('URL链接'),
- dataIndex: 'url',
- key: 'url',
- render: (text) => (
- <div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
- {highlightKeywords(text)}
- </div>
- ),
- },
- {
- title: t('操作'),
- key: 'action',
- render: (_, record) => (
- <Space>
- <Button
- type='primary'
- icon={<IconEdit />}
- size='small'
- onClick={() => handleEditConfig(record)}
- >
- {t('编辑')}
- </Button>
- <Button
- type='danger'
- icon={<IconDelete />}
- size='small'
- onClick={() => handleDeleteConfig(record.id)}
- >
- {t('删除')}
- </Button>
- </Space>
- ),
- },
- ];
- return (
- <Spin spinning={loading}>
- <Space vertical style={{ width: '100%' }}>
- <Form.Section text={t('聊天设置')}>
- <Banner
- type='info'
- description={t(
- '链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
- )}
- />
- <Divider />
- <div style={{ marginBottom: 16 }}>
- <span style={{ marginRight: 16, fontWeight: 600 }}>
- {t('编辑模式')}:
- </span>
- <RadioGroup
- type='button'
- value={editMode}
- onChange={(e) => {
- const newMode = e.target.value;
- setEditMode(newMode);
- // 确保模式切换时数据正确同步
- setTimeout(() => {
- if (newMode === 'json' && refForm.current) {
- refForm.current.setValues(inputs);
- }
- }, 100);
- }}
- >
- <Radio value='visual'>{t('可视化编辑')}</Radio>
- <Radio value='json'>{t('JSON编辑')}</Radio>
- </RadioGroup>
- </div>
- {editMode === 'visual' ? (
- <div>
- <Space style={{ marginBottom: 16 }}>
- <Button
- type='primary'
- icon={<IconPlus />}
- onClick={handleAddConfig}
- >
- {t('添加聊天配置')}
- </Button>
- <Dropdown
- trigger='click'
- position='bottomLeft'
- menu={[
- ...BUILTIN_TEMPLATES.map((tpl, idx) => ({
- node: 'item',
- key: String(idx),
- name: tpl.name,
- onClick: () => addTemplates([tpl]),
- })),
- { node: 'divider', key: 'divider' },
- {
- node: 'item',
- key: 'all',
- name: t('全部填入'),
- onClick: () => addTemplates(BUILTIN_TEMPLATES),
- },
- ]}
- >
- <Button icon={<IconBolt />}>
- {t('填入模板')}
- </Button>
- </Dropdown>
- <Button
- type='primary'
- theme='solid'
- icon={<IconSaveStroked />}
- onClick={onSubmit}
- >
- {t('保存聊天设置')}
- </Button>
- <Input
- prefix={<IconSearch />}
- placeholder={t('搜索聊天应用名称')}
- value={searchText}
- onChange={(value) => setSearchText(value)}
- style={{ width: 250 }}
- showClear
- />
- </Space>
- <Table
- columns={columns}
- dataSource={filteredConfigs}
- rowKey='id'
- pagination={{
- pageSize: 10,
- showSizeChanger: false,
- showQuickJumper: true,
- showTotal: (total, range) =>
- t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
- total,
- start: range[0],
- end: range[1],
- }),
- }}
- />
- </div>
- ) : (
- <Form
- values={inputs}
- getFormApi={(formAPI) => (refForm.current = formAPI)}
- >
- <Form.TextArea
- label={t('聊天配置')}
- extraText={''}
- placeholder={t('为一个 JSON 文本')}
- field={'Chats'}
- autosize={{ minRows: 6, maxRows: 12 }}
- trigger='blur'
- stopValidateWithError
- rules={[
- {
- validator: (rule, value) => {
- return verifyJSON(value);
- },
- message: t('不是合法的 JSON 字符串'),
- },
- ]}
- onChange={(value) =>
- setInputs({
- ...inputs,
- Chats: value,
- })
- }
- />
- </Form>
- )}
- </Form.Section>
- {editMode === 'json' && (
- <Space>
- <Button
- type='primary'
- icon={<IconSaveStroked />}
- onClick={onSubmit}
- >
- {t('保存聊天设置')}
- </Button>
- </Space>
- )}
- </Space>
- <Modal
- title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
- visible={modalVisible}
- onOk={handleModalOk}
- onCancel={handleModalCancel}
- width={600}
- >
- <Form getFormApi={(api) => (modalFormRef.current = api)}>
- <Form.Input
- field='name'
- label={t('聊天应用名称')}
- placeholder={t('请输入聊天应用名称')}
- rules={[
- { required: true, message: t('请输入聊天应用名称') },
- { min: 1, message: t('名称不能为空') },
- ]}
- />
- <Form.Input
- field='url'
- label={t('URL链接')}
- placeholder={t('请输入完整的URL链接')}
- rules={[{ required: true, message: t('请输入URL链接') }]}
- />
- <Banner
- type='info'
- description={t(
- '提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址',
- )}
- style={{ marginTop: 16 }}
- />
- </Form>
- </Modal>
- </Spin>
- );
- }
|