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 6b9296c7ce
commit 96264d2f8f
12 changed files with 6361 additions and 992 deletions

View File

@@ -13,9 +13,15 @@ var Chats = []map[string]string{
{
"Cherry Studio": "cherrystudio://providers/api-keys?v=1&data={cherryConfig}",
},
//{
// "AionUI": "aionui://provider/add?v=1&data={aionuiConfig}",
//},
{
"流畅阅读": "fluentread",
},
{
"CC Switch": "ccswitch",
},
{
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
},

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;

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={

View File

@@ -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>
);
}

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);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3083
web/src/i18n/locales/zh.json vendored Normal file

File diff suppressed because it is too large Load Diff

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,11 +138,16 @@ export default function SettingsChats(props) {
async function onSubmit() {
try {
console.log('Starting validation...');
await refForm.current
.validate()
.then(() => {
console.log('Validation passed');
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('你似乎并没有修改什么'));
@@ -126,28 +164,21 @@ export default function SettingsChats(props) {
});
});
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('部分保存失败,请重试'));
try {
const res = await Promise.all(requestQueue);
if (res.includes(undefined)) {
if (requestQueue.length > 1) {
showError(t('部分保存失败,请重试'));
}
return;
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
} catch {
showError(t('保存失败,请重试'));
})
.finally(() => {
} finally {
setLoading(false);
});
})
.catch((error) => {
console.error('Validation failed:', error);
showError(t('请检查输入'));
});
}
} 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'