Skip to content

Commit 8bb9a42

Browse files
committed
feat: add clipboard magic string for quick channel creation from token copy
When copying a token, users can now choose "Copy Connection String" which encodes both the API key and server URL as a JSON clipboard payload (type: newapi_channel_conn). When opening the channel creation form, the clipboard is auto-detected and a banner offers to fill key + base_url, eliminating repeated tab-switching when connecting to another new-api instance.
1 parent d22f889 commit 8bb9a42

File tree

13 files changed

+251
-28
lines changed

13 files changed

+251
-28
lines changed

web/src/components/table/channels/modals/EditChannelModal.jsx

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import SecureVerificationModal from '../../../common/modals/SecureVerificationMo
6767
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
6868
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
6969
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
70+
import { parseChannelConnectionString } from '../../../../helpers/token';
7071
import { createApiCalls } from '../../../../services/secureVerification';
7172
import {
7273
collectInvalidStatusCodeEntries,
@@ -398,6 +399,9 @@ const EditChannelModal = (props) => {
398399
[],
399400
);
400401

402+
// 剪贴板连接信息自动检测
403+
const [clipboardConfig, setClipboardConfig] = useState(null);
404+
401405
// 高级设置折叠状态
402406
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false);
403407
const formContainerRef = useRef(null);
@@ -538,6 +542,35 @@ const EditChannelModal = (props) => {
538542
handleInputChange('settings', settingsJson);
539543
};
540544

545+
const applyClipboardConfig = (config) => {
546+
if (!config) return;
547+
setInputs((prev) => ({
548+
...prev,
549+
key: config.key,
550+
base_url: config.url,
551+
}));
552+
if (formApiRef.current) {
553+
formApiRef.current.setValue('key', config.key);
554+
formApiRef.current.setValue('base_url', config.url);
555+
}
556+
setClipboardConfig(null);
557+
showSuccess(t('连接信息已填入'));
558+
};
559+
560+
const pasteFromClipboard = async () => {
561+
try {
562+
const text = await navigator.clipboard.readText();
563+
const parsed = parseChannelConnectionString(text);
564+
if (parsed) {
565+
applyClipboardConfig(parsed);
566+
} else {
567+
showInfo(t('剪贴板中未检测到连接信息'));
568+
}
569+
} catch {
570+
showError(t('无法读取剪贴板'));
571+
}
572+
};
573+
541574
const isIonetLocked = isIonetChannel && isEdit;
542575

543576
const handleInputChange = (name, value) => {
@@ -1269,6 +1302,13 @@ const EditChannelModal = (props) => {
12691302
loadChannel();
12701303
} else {
12711304
formApiRef.current?.setValues(getInitValues());
1305+
// best-effort clipboard auto-detect for new channels
1306+
navigator.clipboard.readText().then((text) => {
1307+
const parsed = parseChannelConnectionString(text);
1308+
if (parsed) {
1309+
setClipboardConfig(parsed);
1310+
}
1311+
}).catch(() => {});
12721312
}
12731313
fetchModelGroups();
12741314
// 重置手动输入模式状态
@@ -1329,6 +1369,8 @@ const EditChannelModal = (props) => {
13291369
setInputs(getInitValues());
13301370
// 重置密钥显示状态
13311371
resetKeyDisplayState();
1372+
// 重置剪贴板检测状态
1373+
setClipboardConfig(null);
13321374
};
13331375

13341376
const handleVertexUploadChange = ({ fileList }) => {
@@ -2077,14 +2119,27 @@ const EditChannelModal = (props) => {
20772119
<SideSheet
20782120
placement={isEdit ? 'right' : 'left'}
20792121
title={
2080-
<Space>
2081-
<Tag color='blue' shape='circle'>
2082-
{isEdit ? t('编辑') : t('新建')}
2083-
</Tag>
2084-
<Title heading={4} className='m-0'>
2085-
{isEdit ? t('更新渠道信息') : t('创建新的渠道')}
2086-
</Title>
2087-
</Space>
2122+
<div className='flex items-center justify-between w-full'>
2123+
<Space>
2124+
<Tag color='blue' shape='circle'>
2125+
{isEdit ? t('编辑') : t('新建')}
2126+
</Tag>
2127+
<Title heading={4} className='m-0'>
2128+
{isEdit ? t('更新渠道信息') : t('创建新的渠道')}
2129+
</Title>
2130+
</Space>
2131+
{!isEdit && (
2132+
<Button
2133+
size='small'
2134+
type='tertiary'
2135+
className='ec-dbcd0a3c01b55203 shrink-0'
2136+
icon={<IconBolt />}
2137+
onClick={pasteFromClipboard}
2138+
>
2139+
{t('从剪贴板粘贴配置')}
2140+
</Button>
2141+
)}
2142+
</div>
20882143
}
20892144
bodyStyle={{ padding: '0' }}
20902145
visible={props.visible}
@@ -2446,6 +2501,34 @@ const EditChannelModal = (props) => {
24462501
<>
24472502
<Spin spinning={loading}>
24482503
<div className='p-2 space-y-3' ref={formContainerRef}>
2504+
{!isEdit && clipboardConfig && (
2505+
<Banner
2506+
type='info'
2507+
className='ec-dbcd0a3c01b55203'
2508+
description={
2509+
<div className='flex items-center justify-between gap-2'>
2510+
<span>{t('检测到剪贴板中的连接信息')}</span>
2511+
<div className='flex gap-1'>
2512+
<Button
2513+
size='small'
2514+
theme='solid'
2515+
type='primary'
2516+
onClick={() => applyClipboardConfig(clipboardConfig)}
2517+
>
2518+
{t('自动填入')}
2519+
</Button>
2520+
<Button
2521+
size='small'
2522+
type='tertiary'
2523+
onClick={() => setClipboardConfig(null)}
2524+
>
2525+
{t('忽略')}
2526+
</Button>
2527+
</div>
2528+
</div>
2529+
}
2530+
/>
2531+
)}
24492532
{/* Core Configuration Card - Always Visible */}
24502533
<Card className='!rounded-2xl shadow-sm border-0'>
24512534
{/* Header */}

web/src/components/table/tokens/TokensColumnDefs.jsx

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ const renderTokenKey = (
116116
loadingTokenKeys,
117117
toggleTokenVisibility,
118118
copyTokenKey,
119+
copyTokenConnectionString,
120+
t,
119121
) => {
120122
const revealed = !!showKeys[record.id];
121123
const loading = !!loadingTokenKeys[record.id];
@@ -145,18 +147,35 @@ const renderTokenKey = (
145147
await toggleTokenVisibility(record);
146148
}}
147149
/>
148-
<Button
149-
theme='borderless'
150-
size='small'
151-
type='tertiary'
152-
icon={<IconCopy />}
153-
loading={loading}
154-
aria-label='copy token key'
155-
onClick={async (e) => {
156-
e.stopPropagation();
157-
await copyTokenKey(record);
158-
}}
159-
/>
150+
<Dropdown
151+
trigger='click'
152+
position='bottomRight'
153+
clickToHide
154+
menu={[
155+
{
156+
node: 'item',
157+
name: t('复制密钥'),
158+
onClick: () => copyTokenKey(record),
159+
},
160+
{
161+
node: 'item',
162+
name: t('复制连接信息'),
163+
onClick: () => copyTokenConnectionString(record),
164+
},
165+
]}
166+
>
167+
<Button
168+
theme='borderless'
169+
size='small'
170+
type='tertiary'
171+
icon={<IconCopy />}
172+
loading={loading}
173+
aria-label='copy token key'
174+
onClick={async (e) => {
175+
e.stopPropagation();
176+
}}
177+
/>
178+
</Dropdown>
160179
</div>
161180
}
162181
/>
@@ -444,6 +463,7 @@ export const getTokensColumns = ({
444463
loadingTokenKeys,
445464
toggleTokenVisibility,
446465
copyTokenKey,
466+
copyTokenConnectionString,
447467
manageToken,
448468
onOpenLink,
449469
setEditingToken,
@@ -484,6 +504,8 @@ export const getTokensColumns = ({
484504
loadingTokenKeys,
485505
toggleTokenVisibility,
486506
copyTokenKey,
507+
copyTokenConnectionString,
508+
t,
487509
),
488510
},
489511
{

web/src/components/table/tokens/TokensTable.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const TokensTable = (tokensData) => {
4343
loadingTokenKeys,
4444
toggleTokenVisibility,
4545
copyTokenKey,
46+
copyTokenConnectionString,
4647
manageToken,
4748
onOpenLink,
4849
setEditingToken,
@@ -60,6 +61,7 @@ const TokensTable = (tokensData) => {
6061
loadingTokenKeys,
6162
toggleTokenVisibility,
6263
copyTokenKey,
64+
copyTokenConnectionString,
6365
manageToken,
6466
onOpenLink,
6567
setEditingToken,
@@ -73,6 +75,7 @@ const TokensTable = (tokensData) => {
7375
loadingTokenKeys,
7476
toggleTokenVisibility,
7577
copyTokenKey,
78+
copyTokenConnectionString,
7679
manageToken,
7780
onOpenLink,
7881
setEditingToken,

web/src/helpers/token.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,41 @@ export function getServerAddress() {
8080

8181
return serverAddress;
8282
}
83+
84+
export const CHANNEL_CONN_CLIPBOARD_TYPE = 'newapi_channel_conn';
85+
86+
/**
87+
* @param {string} key - 完整的 API key(含 sk- 前缀)
88+
* @param {string} url - 服务器地址
89+
* @returns {string} JSON 格式的连接字符串
90+
*/
91+
export function encodeChannelConnectionString(key, url) {
92+
return JSON.stringify({
93+
_type: CHANNEL_CONN_CLIPBOARD_TYPE,
94+
key,
95+
url,
96+
});
97+
}
98+
99+
/**
100+
* @param {string} text - 剪贴板文本
101+
* @returns {{ key: string, url: string } | null}
102+
*/
103+
export function parseChannelConnectionString(text) {
104+
if (!text || typeof text !== 'string') return null;
105+
try {
106+
const parsed = JSON.parse(text.trim());
107+
if (
108+
parsed &&
109+
typeof parsed === 'object' &&
110+
parsed._type === CHANNEL_CONN_CLIPBOARD_TYPE &&
111+
typeof parsed.key === 'string' &&
112+
typeof parsed.url === 'string'
113+
) {
114+
return { key: parsed.key, url: parsed.url };
115+
}
116+
} catch {
117+
// not valid JSON
118+
}
119+
return null;
120+
}

web/src/hooks/tokens/useTokensData.jsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ import {
2929
} from '../../helpers';
3030
import { ITEMS_PER_PAGE } from '../../constants';
3131
import { useTableCompactMode } from '../common/useTableCompactMode';
32-
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
32+
import {
33+
fetchTokenKey as fetchTokenKeyById,
34+
getServerAddress,
35+
encodeChannelConnectionString,
36+
} from '../../helpers/token';
3337

3438
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
3539
const { t } = useTranslation();
@@ -198,6 +202,13 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
198202
await copyText(`sk-${fullKey}`);
199203
};
200204

205+
const copyTokenConnectionString = async (record) => {
206+
const fullKey = await fetchTokenKey(record);
207+
const serverUrl = getServerAddress();
208+
const connStr = encodeChannelConnectionString(`sk-${fullKey}`, serverUrl);
209+
await copyText(connStr);
210+
};
211+
201212
// Open link function for chat integrations
202213
const onOpenLink = async (type, url, record) => {
203214
const fullKey = await fetchTokenKey(record);
@@ -465,6 +476,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
465476
fetchTokenKey,
466477
toggleTokenVisibility,
467478
copyTokenKey,
479+
copyTokenConnectionString,
468480
onOpenLink,
469481
manageToken,
470482
searchTokens,

web/src/i18n/locales/en.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3352,6 +3352,15 @@
33523352
"输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens",
33533353
"输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens",
33543354
"例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$",
3355-
"支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching."
3355+
"支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching.",
3356+
"复制密钥": "Copy Key",
3357+
"复制连接信息": "Copy Connection String",
3358+
"检测到剪贴板中的连接信息": "Connection info detected in clipboard",
3359+
"自动填入": "Auto-fill",
3360+
"忽略": "Ignore",
3361+
"从剪贴板粘贴配置": "Paste Config",
3362+
"剪贴板中未检测到连接信息": "No connection info found in clipboard",
3363+
"连接信息已填入": "Connection info applied",
3364+
"无法读取剪贴板": "Cannot read clipboard"
33563365
}
33573366
}

web/src/i18n/locales/fr.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3308,6 +3308,15 @@
33083308
"输入价格:{{symbol}}{{price}} / 1M tokens": "Prix d'entrée : {{symbol}}{{price}} / 1M tokens",
33093309
"输出价格 {{symbol}}{{price}} / 1M tokens": "Prix de sortie {{symbol}}{{price}} / 1M tokens",
33103310
"输出价格:{{symbol}}{{price}} / 1M tokens": "Prix de sortie : {{symbol}}{{price}} / 1M tokens",
3311-
"输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens"
3311+
"输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens",
3312+
"复制密钥": "Copier la clé",
3313+
"复制连接信息": "Copier les infos de connexion",
3314+
"检测到剪贴板中的连接信息": "Informations de connexion détectées dans le presse-papiers",
3315+
"自动填入": "Remplir auto",
3316+
"忽略": "Ignorer",
3317+
"从剪贴板粘贴配置": "Coller la config",
3318+
"剪贴板中未检测到连接信息": "Aucune info de connexion trouvée dans le presse-papiers",
3319+
"连接信息已填入": "Informations de connexion appliquées",
3320+
"无法读取剪贴板": "Impossible de lire le presse-papiers"
33123321
}
33133322
}

web/src/i18n/locales/ja.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3289,6 +3289,15 @@
32893289
"输入价格:{{symbol}}{{price}} / 1M tokens": "入力価格:{{symbol}}{{price}} / 1M tokens",
32903290
"输出价格 {{symbol}}{{price}} / 1M tokens": "補完料金 {{symbol}}{{price}} / 1M tokens",
32913291
"输出价格:{{symbol}}{{price}} / 1M tokens": "補完料金:{{symbol}}{{price}} / 1M tokens",
3292-
"输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens"
3292+
"输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens",
3293+
"复制密钥": "キーをコピー",
3294+
"复制连接信息": "接続情報をコピー",
3295+
"检测到剪贴板中的连接信息": "クリップボードに接続情報が検出されました",
3296+
"自动填入": "自動入力",
3297+
"忽略": "無視",
3298+
"从剪贴板粘贴配置": "クリップボードから貼り付け",
3299+
"剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
3300+
"连接信息已填入": "接続情報を入力しました",
3301+
"无法读取剪贴板": "クリップボードを読み取れません"
32933302
}
32943303
}

web/src/i18n/locales/ru.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3322,6 +3322,15 @@
33223322
"输入价格:{{symbol}}{{price}} / 1M tokens": "Цена ввода: {{symbol}}{{price}} / 1M tokens",
33233323
"输出价格 {{symbol}}{{price}} / 1M tokens": "Цена вывода {{symbol}}{{price}} / 1M tokens",
33243324
"输出价格:{{symbol}}{{price}} / 1M tokens": "Цена вывода: {{symbol}}{{price}} / 1M tokens",
3325-
"输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens"
3325+
"输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens",
3326+
"复制密钥": "Копировать ключ",
3327+
"复制连接信息": "Копировать данные подключения",
3328+
"检测到剪贴板中的连接信息": "В буфере обмена обнаружены данные подключения",
3329+
"自动填入": "Заполнить",
3330+
"忽略": "Игнорировать",
3331+
"从剪贴板粘贴配置": "Вставить конфигурацию",
3332+
"剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
3333+
"连接信息已填入": "Данные подключения применены",
3334+
"无法读取剪贴板": "Не удалось прочитать буфер обмена"
33263335
}
33273336
}

0 commit comments

Comments
 (0)