feat: add cc-switch integration and modal for token management

- Introduced a new CCSwitchModal component for managing CCSwitch configurations.
- Updated the TokensPage to include functionality for opening the CCSwitch modal.
- Enhanced the useTokensData hook to handle CCSwitch URLs and trigger the modal.
- Modified chat settings to include a new "CC Switch" entry.
- Updated sidebar logic to skip certain links based on the new configuration.
This commit is contained in:
CaIon
2026-03-01 23:23:20 +08:00
parent 1583463436
commit c1cb03456c
12 changed files with 6361 additions and 992 deletions
+2 -2
View File
@@ -251,9 +251,9 @@ const SiderBar = ({ onNavigate = () => {} }) => {
for (let key in chats[i]) {
let link = chats[i][key];
if (typeof link !== 'string') continue; // 确保链接是字符串
if (link.startsWith('fluent')) {
if (link.startsWith('fluent') || link.startsWith('ccswitch')) {
shouldSkip = true;
break; // 跳过 Fluent Read
break;
}
chat.text = key;
chat.itemKey = 'chat' + i;
+23 -2
View File
@@ -38,6 +38,7 @@ import TokensActions from './TokensActions';
import TokensFilters from './TokensFilters';
import TokensDescription from './TokensDescription';
import EditTokenModal from './modals/EditTokenModal';
import CCSwitchModal from './modals/CCSwitchModal';
import { useTokensData } from '../../../hooks/tokens/useTokensData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
@@ -45,8 +46,10 @@ import { createCardProPagination } from '../../../helpers/utils';
function TokensPage() {
// Define the function first, then pass it into the hook to avoid TDZ errors
const openFluentNotificationRef = useRef(null);
const tokensData = useTokensData((key) =>
openFluentNotificationRef.current?.(key),
const openCCSwitchModalRef = useRef(null);
const tokensData = useTokensData(
(key) => openFluentNotificationRef.current?.(key),
(key) => openCCSwitchModalRef.current?.(key),
);
const isMobile = useIsMobile();
const latestRef = useRef({
@@ -60,6 +63,8 @@ function TokensPage() {
const [selectedModel, setSelectedModel] = useState('');
const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
const [prefillKey, setPrefillKey] = useState('');
const [ccSwitchVisible, setCCSwitchVisible] = useState(false);
const [ccSwitchKey, setCCSwitchKey] = useState('');
// Keep latest data for handlers inside notifications
useEffect(() => {
@@ -183,6 +188,15 @@ function TokensPage() {
// assign after definition so hook callback can call it safely
openFluentNotificationRef.current = openFluentNotification;
function openCCSwitchModal(key) {
if (modelOptions.length === 0) {
loadModels();
}
setCCSwitchKey(key || '');
setCCSwitchVisible(true);
}
openCCSwitchModalRef.current = openCCSwitchModal;
// Prefill to Fluent handler
const handlePrefillToFluent = () => {
const {
@@ -363,6 +377,13 @@ function TokensPage() {
handleClose={closeEdit}
/>
<CCSwitchModal
visible={ccSwitchVisible}
onClose={() => setCCSwitchVisible(false)}
tokenKey={ccSwitchKey}
modelOptions={modelOptions}
/>
<CardPro
type='type1'
descriptionArea={
@@ -0,0 +1,195 @@
/*
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, { useState, useEffect, useMemo } from 'react';
import {
Modal,
RadioGroup,
Radio,
Select,
Input,
Toast,
Typography,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { selectFilter } from '../../../../helpers';
const APP_CONFIGS = {
claude: {
label: 'Claude',
defaultName: 'My Claude',
modelFields: [
{ key: 'model', label: '主模型' },
{ key: 'haikuModel', label: 'Haiku 模型' },
{ key: 'sonnetModel', label: 'Sonnet 模型' },
{ key: 'opusModel', label: 'Opus 模型' },
],
},
codex: {
label: 'Codex',
defaultName: 'My Codex',
modelFields: [{ key: 'model', label: '主模型' }],
},
gemini: {
label: 'Gemini',
defaultName: 'My Gemini',
modelFields: [{ key: 'model', label: '主模型' }],
},
};
function getServerAddress() {
try {
const raw = localStorage.getItem('status');
if (raw) {
const status = JSON.parse(raw);
if (status.server_address) return status.server_address;
}
} catch (_) {}
return window.location.origin;
}
function buildCCSwitchURL(app, name, models, apiKey) {
const serverAddress = getServerAddress();
const endpoint = app === 'codex' ? serverAddress + '/v1' : serverAddress;
const params = new URLSearchParams();
params.set('resource', 'provider');
params.set('app', app);
params.set('name', name);
params.set('endpoint', endpoint);
params.set('apiKey', apiKey);
for (const [k, v] of Object.entries(models)) {
if (v) params.set(k, v);
}
params.set('homepage', serverAddress);
params.set('enabled', 'true');
return `ccswitch://v1/import?${params.toString()}`;
}
export default function CCSwitchModal({
visible,
onClose,
tokenKey,
modelOptions,
}) {
const { t } = useTranslation();
const [app, setApp] = useState('claude');
const [name, setName] = useState(APP_CONFIGS.claude.defaultName);
const [models, setModels] = useState({});
const currentConfig = APP_CONFIGS[app];
useEffect(() => {
if (visible) {
setModels({});
setApp('claude');
setName(APP_CONFIGS.claude.defaultName);
}
}, [visible]);
const handleAppChange = (val) => {
setApp(val);
setName(APP_CONFIGS[val].defaultName);
setModels({});
};
const handleModelChange = (field, value) => {
setModels((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = () => {
if (!models.model) {
Toast.warning(t('请选择主模型'));
return;
}
const apiKey = 'sk-' + tokenKey;
const url = buildCCSwitchURL(app, name, models, apiKey);
window.open(url, '_blank');
onClose();
};
const fieldLabelStyle = useMemo(
() => ({
marginBottom: 4,
fontSize: 13,
color: 'var(--semi-color-text-1)',
}),
[],
);
return (
<Modal
title={t('填入 CC Switch')}
visible={visible}
onCancel={onClose}
onOk={handleSubmit}
okText={t('打开 CC Switch')}
cancelText={t('取消')}
maskClosable={false}
width={480}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={fieldLabelStyle}>{t('应用')}</div>
<RadioGroup
type='button'
value={app}
onChange={(e) => handleAppChange(e.target.value)}
style={{ width: '100%' }}
>
{Object.entries(APP_CONFIGS).map(([key, cfg]) => (
<Radio key={key} value={key}>
{cfg.label}
</Radio>
))}
</RadioGroup>
</div>
<div>
<div style={fieldLabelStyle}>{t('名称')}</div>
<Input
value={name}
onChange={setName}
placeholder={currentConfig.defaultName}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key}>
<div style={fieldLabelStyle}>
{t(field.label)}
{field.key === 'model' && (
<Typography.Text type='danger'> *</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择模型')}
optionList={modelOptions}
value={models[field.key] || undefined}
onChange={(val) => handleModelChange(field.key, val)}
filter={selectFilter}
style={{ width: '100%' }}
showClear
searchable
emptyContent={t('暂无数据')}
/>
</div>
))}
</div>
</Modal>
);
}
+15 -1
View File
@@ -30,7 +30,7 @@ import {
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useTokensData = (openFluentNotification) => {
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
// Basic state
@@ -124,6 +124,10 @@ export const useTokensData = (openFluentNotification) => {
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
if (url && url.startsWith('ccswitch')) {
openCCSwitchModal(record.key);
return;
}
if (url && url.startsWith('fluent')) {
openFluentNotification(record.key);
return;
@@ -147,6 +151,16 @@ export const useTokensData = (openFluentNotification) => {
encodeToBase64(JSON.stringify(cherryConfig)),
);
url = url.replaceAll('{cherryConfig}', encodedConfig);
} else if (url.includes('{aionuiConfig}') === true) {
let aionuiConfig = {
platform: 'new-api',
baseUrl: serverAddress,
apiKey: 'sk-' + record.key,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(aionuiConfig)),
);
url = url.replaceAll('{aionuiConfig}', encodedConfig);
} else {
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);
+625 -278
View File
File diff suppressed because it is too large Load Diff
+583 -169
View File
File diff suppressed because it is too large Load Diff
+580 -168
View File
File diff suppressed because it is too large Load Diff
+583 -168
View File
File diff suppressed because it is too large Load Diff
+572 -164
View File
File diff suppressed because it is too large Load Diff
+3083
View File
File diff suppressed because it is too large Load Diff
+94 -40
View File
@@ -21,6 +21,7 @@ import React, { useEffect, useState, useRef } from 'react';
import {
Banner,
Button,
Dropdown,
Form,
Space,
Spin,
@@ -37,6 +38,7 @@ import {
IconDelete,
IconSearch,
IconSaveStroked,
IconBolt,
} from '@douyinfe/semi-icons';
import {
compareObjects,
@@ -64,6 +66,37 @@ export default function SettingsChats(props) {
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);
@@ -105,49 +138,47 @@ export default function SettingsChats(props) {
async function onSubmit() {
try {
console.log('Starting validation...');
await refForm.current
.validate()
.then(() => {
console.log('Validation passed');
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);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
})
.catch((error) => {
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);
@@ -390,6 +421,29 @@ export default function SettingsChats(props) {
>
{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'