diff --git a/electron-builder.yml b/electron-builder.yml
index ecbbc10057..c65f20ed32 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -11,6 +11,11 @@ electronLanguages:
- en # for macOS
directories:
buildResources: build
+
+protocols:
+ - name: Cherry Studio
+ schemes:
+ - cherrystudio
files:
- '**/*'
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
diff --git a/src/main/index.ts b/src/main/index.ts
index 3699335a90..46ebd7c6e6 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -124,19 +124,27 @@ if (!app.requestSingleInstanceLock()) {
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
+
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
+ const handleOpenUrl = (args: string[]) => {
+ const url = args.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
+ if (url) handleProtocolUrl(url)
+ }
+
+ // for windows to start with url
+ handleOpenUrl(process.argv)
+
// Listen for second instance
app.on('second-instance', (_event, argv) => {
windowService.showMainWindow()
// Protocol handler for Windows/Linux
// The commandLine is an array of strings where the last item might be the URL
- const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
- if (url) handleProtocolUrl(url)
+ handleOpenUrl(argv)
})
app.on('browser-window-created', (_, window) => {
diff --git a/src/main/services/ProtocolClient.ts b/src/main/services/ProtocolClient.ts
index 7e0b274816..cac0983fd6 100644
--- a/src/main/services/ProtocolClient.ts
+++ b/src/main/services/ProtocolClient.ts
@@ -19,7 +19,7 @@ export function registerProtocolClient(app: Electron.App) {
}
}
- app.setAsDefaultProtocolClient('cherrystudio')
+ app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL)
}
export function handleProtocolUrl(url: string) {
diff --git a/src/main/services/urlschema/handle-providers.ts b/src/main/services/urlschema/handle-providers.ts
index bc109437e6..d23f3749db 100644
--- a/src/main/services/urlschema/handle-providers.ts
+++ b/src/main/services/urlschema/handle-providers.ts
@@ -1,37 +1,47 @@
-import { IpcChannel } from '@shared/IpcChannel'
import Logger from 'electron-log'
import { windowService } from '../WindowService'
-export function handleProvidersProtocolUrl(url: URL) {
- const params = new URLSearchParams(url.search)
+export async function handleProvidersProtocolUrl(url: URL) {
switch (url.pathname) {
case '/api-keys': {
// jsonConfig example:
// {
// "id": "tokenflux",
// "baseUrl": "https://tokenflux.ai/v1",
- // "apiKey": "sk-xxxx"
+ // "apiKey": "sk-xxxx",
+ // "name": "TokenFlux", // optional
+ // "type": "openai" // optional
// }
- // cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))}
+ // cherrystudio://providers/api-keys?v=1&data={base64Encode(JSON.stringify(jsonConfig))}
+
+ // replace + and / to _ and - because + and / are processed by URLSearchParams
+ const processedSearch = url.search.replaceAll('+', '_').replaceAll('/', '-')
+ const params = new URLSearchParams(processedSearch)
const data = params.get('data')
- if (data) {
- const stringify = Buffer.from(data, 'base64').toString('utf8')
- Logger.info('get api keys from urlschema: ', stringify)
- const jsonConfig = JSON.parse(stringify)
- Logger.info('get api keys from urlschema: ', jsonConfig)
- const mainWindow = windowService.getMainWindow()
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig)
- mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`)
- }
+ const mainWindow = windowService.getMainWindow()
+ const version = params.get('v')
+ if (version == '1') {
+ // TODO: handle different version
+ Logger.info('handleProvidersProtocolUrl', { data, version })
+ }
+
+ // add check there is window.navigate function in mainWindow
+ if (
+ mainWindow &&
+ !mainWindow.isDestroyed() &&
+ (await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
+ ) {
+ mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
} else {
- Logger.error('No data found in URL')
+ setTimeout(() => {
+ handleProvidersProtocolUrl(url)
+ }, 1000)
}
break
}
default:
- console.error(`Unknown MCP protocol URL: ${url}`)
+ Logger.error(`Unknown MCP protocol URL: ${url}`)
break
}
}
diff --git a/src/renderer/src/hooks/useProvider.ts b/src/renderer/src/hooks/useProvider.ts
index 0e044602cb..95a8a8fa0b 100644
--- a/src/renderer/src/hooks/useProvider.ts
+++ b/src/renderer/src/hooks/useProvider.ts
@@ -1,5 +1,5 @@
import { createSelector } from '@reduxjs/toolkit'
-import store, { useAppDispatch, useAppSelector } from '@renderer/store'
+import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addModel,
addProvider,
@@ -10,7 +10,6 @@ import {
updateProviders
} from '@renderer/store/llm'
import { Assistant, Model, Provider } from '@renderer/types'
-import { IpcChannel } from '@shared/IpcChannel'
import { useDefaultModel } from './useAssistant'
@@ -64,17 +63,3 @@ export function useProviderByAssistant(assistant: Assistant) {
const { provider } = useProvider(model.provider)
return provider
}
-
-// Listen for server changes from main process
-window.electron.ipcRenderer.on(IpcChannel.Provider_AddKey, (_, data) => {
- console.log('Received provider key data:', data)
- const { id, apiKey } = data
- // for now only suppor tokenflux, but in the future we can support more
- if (id === 'tokenflux') {
- if (apiKey) {
- store.dispatch(updateProvider({ id, apiKey } as Provider))
- window.message.success('Provider API key updated')
- console.log('Provider API key updated:', apiKey)
- }
- }
-})
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index d62eb985de..0a529756b2 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -1650,6 +1650,15 @@
"models.quick_assistant_default_tag": "Default",
"models.use_model": "Default Model",
"models.use_assistant": "Use Assistant",
+ "models.provider_key_confirm_title": "Add Provider API Key",
+ "models.provider_name": "Provider Name",
+ "models.provider_id": "Provider ID",
+ "models.base_url": "Base URL",
+ "models.api_key": "API Key",
+ "models.provider_key_add_confirm": "Do you want to add the API key for {{provider}}?",
+ "models.provider_key_override_confirm": "{{provider}} already has an API key ({{existingKey}}). Do you want to override it with the new key ({{newKey}})?",
+ "models.provider_key_added": "Successfully added API key for {{provider}}",
+ "models.provider_key_overridden": "Successfully updated API key for {{provider}}",
"moresetting": "More Settings",
"moresetting.check.confirm": "Confirm Selection",
"moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index b4b660d9c1..30f5f2fb0a 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -1638,6 +1638,18 @@
"models.quick_assistant_default_tag": "デフォルト",
"models.use_model": "デフォルトモデル",
"models.use_assistant": "アシスタントの活用",
+ "models.provider_key_confirm_title": "{{provider}} の API キーを追加",
+ "models.provider_name": "プロバイダー名",
+ "models.provider_id": "プロバイダー ID",
+ "models.base_url": "ベース URL",
+ "models.api_key": "API キー",
+ "models.provider_key_add_confirm": "{{provider}} の API キーを追加しますか?",
+ "models.provider_key_already_exists": "{{provider}} には同じ API キーがすでに存在します。追加しません。",
+ "models.provider_key_added": "{{provider}} の API キーを追加しました",
+ "models.provider_key_overridden": "{{provider}} の API キーを更新しました",
+ "models.provider_key_no_change": "{{provider}} の API キーは変更されませんでした",
+ "models.provider_key_add_failed_by_empty_data": "{{provider}} の API キーを追加できませんでした。データが空です。",
+ "models.provider_key_add_failed_by_invalid_data": "{{provider}} の API キーを追加できませんでした。データ形式が無効です。",
"moresetting": "詳細設定",
"moresetting.check.confirm": "選択を確認",
"moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index b31008607c..b850bf3f22 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -1638,6 +1638,18 @@
"models.quick_assistant_default_tag": "умолчанию",
"models.use_model": "модель по умолчанию",
"models.use_assistant": "Использование ассистентов",
+ "models.provider_key_confirm_title": "Добавить API ключ для {{provider}}",
+ "models.provider_name": "Имя провайдера",
+ "models.provider_id": "ID провайдера",
+ "models.base_url": "Базовый URL",
+ "models.api_key": "API ключ",
+ "models.provider_key_add_confirm": "Добавить API ключ для {{provider}}?",
+ "models.provider_key_already_exists": "{{provider}} уже существует один и тот же API ключ, не будет добавлен",
+ "models.provider_key_added": "API ключ для {{provider}} успешно добавлен",
+ "models.provider_key_overridden": "API ключ для {{provider}} успешно обновлен",
+ "models.provider_key_no_change": "API ключ для {{provider}} не изменился",
+ "models.provider_key_add_failed_by_empty_data": "Не удалось добавить API ключ для {{provider}}, данные пусты",
+ "models.provider_key_add_failed_by_invalid_data": "Не удалось добавить API ключ для {{provider}}, данные имеют неверный формат",
"moresetting": "Дополнительные настройки",
"moresetting.check.confirm": "Подтвердить выбор",
"moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 5b989686b3..2b2176a457 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -1650,6 +1650,18 @@
"models.quick_assistant_default_tag": "默认",
"models.use_model": "默认模型",
"models.use_assistant": "使用助手",
+ "models.provider_key_confirm_title": "为{{provider}}添加 API 密钥",
+ "models.provider_name": "服务商名称",
+ "models.provider_id": "服务商 ID",
+ "models.base_url": "基础 URL",
+ "models.api_key": "API 密钥",
+ "models.provider_key_add_confirm": "是否要为 {{provider}} 添加 API 密钥?",
+ "models.provider_key_already_exists": "{{provider}} 已存在相同API 密钥, 不会重复添加",
+ "models.provider_key_added": "成功为 {{provider}} 添加 API 密钥",
+ "models.provider_key_overridden": "成功更新 {{provider}} 的 API 密钥",
+ "models.provider_key_no_change": "{{provider}} 的 API 密钥没有变化",
+ "models.provider_key_add_failed_by_empty_data": "添加服务商 API 密钥失败,数据为空",
+ "models.provider_key_add_failed_by_invalid_data": "添加服务商 API 密钥失败,数据格式错误",
"moresetting": "更多设置",
"moresetting.check.confirm": "确认勾选",
"moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 3e0cf72b5f..8640d46428 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -1641,6 +1641,18 @@
"models.quick_assistant_default_tag": "預設",
"models.use_model": "預設模型",
"models.use_assistant": "使用助手",
+ "models.provider_key_confirm_title": "為{{provider}}添加 API 密鑰",
+ "models.provider_name": "提供者名稱",
+ "models.provider_id": "提供者 ID",
+ "models.base_url": "基礎 URL",
+ "models.api_key": "API 密鑰",
+ "models.provider_key_add_confirm": "是否要為 {{provider}} 添加 API 密鑰?",
+ "models.provider_key_already_exists": "{{provider}} 已存在相同API 密鑰, 不會重複添加",
+ "models.provider_key_added": "成功為 {{provider}} 添加 API 密鑰",
+ "models.provider_key_overridden": "成功更新 {{provider}} 的 API 密鑰",
+ "models.provider_key_no_change": "{{provider}} 的 API 密鑰沒有變化",
+ "models.provider_key_add_failed_by_empty_data": "添加提供者 API 密鑰失敗,數據為空",
+ "models.provider_key_add_failed_by_invalid_data": "添加提供者 API 密鑰失敗,數據格式錯誤",
"moresetting": "更多設定",
"moresetting.check.confirm": "確認勾選",
"moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",
diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx
index bb06dfe995..65f0886f5f 100644
--- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx
+++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx
@@ -5,10 +5,10 @@ import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import ImageStorage from '@renderer/services/ImageStorage'
import { INITIAL_PROVIDERS } from '@renderer/store/llm'
-import { Provider } from '@renderer/types'
+import { Provider, ProviderType } from '@renderer/types'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
-import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
-import { Search, UserPen } from 'lucide-react'
+import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
+import { Eye, EyeOff, Search, UserPen } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom'
@@ -61,6 +61,206 @@ const ProvidersList: FC = () => {
}
}, [providers, searchParams])
+ // Handle provider add key from URL schema
+ useEffect(() => {
+ const handleProviderAddKey = (data: {
+ id: string
+ apiKey: string
+ baseUrl: string
+ type?: ProviderType
+ name?: string
+ }) => {
+ const { id, apiKey: newApiKey, baseUrl, type, name } = data
+
+ // 查找匹配的 provider
+ let existingProvider = providers.find((p) => p.id === id)
+ const isNewProvider = !existingProvider
+
+ if (!existingProvider) {
+ existingProvider = {
+ id,
+ name: name || id,
+ type: type || 'openai',
+ apiKey: '',
+ apiHost: baseUrl || '',
+ models: [],
+ enabled: true,
+ isSystem: false
+ }
+ }
+
+ const providerDisplayName = existingProvider.isSystem
+ ? t(`provider.${existingProvider.id}`)
+ : existingProvider.name
+
+ // 检查是否已有 API Key
+ const hasExistingKey = existingProvider.apiKey && existingProvider.apiKey.trim() !== ''
+
+ // 检查新的 API Key 是否已经存在
+ const existingKeys = hasExistingKey ? existingProvider.apiKey.split(',').map((k) => k.trim()) : []
+ const keyAlreadyExists = existingKeys.includes(newApiKey.trim())
+
+ const confirmMessage = keyAlreadyExists
+ ? t('settings.models.provider_key_already_exists', {
+ provider: providerDisplayName,
+ key: '*********'
+ })
+ : t('settings.models.provider_key_add_confirm', {
+ provider: providerDisplayName,
+ newKey: '*********'
+ })
+
+ const createModalContent = () => {
+ let showApiKey = false
+
+ const toggleApiKey = () => {
+ showApiKey = !showApiKey
+ // 重新渲染模态框内容
+ updateModalContent()
+ }
+
+ const updateModalContent = () => {
+ const content = (
+
+
+
+ {t('settings.models.provider_name')}:
+ {providerDisplayName}
+
+
+ {t('settings.models.provider_id')}:
+ {id}
+
+ {baseUrl && (
+
+ {t('settings.models.base_url')}:
+ {baseUrl}
+
+ )}
+
+ {t('settings.models.api_key')}:
+
+ {showApiKey ? newApiKey : '*********'}
+
+ {showApiKey ? : }
+
+
+
+
+ {confirmMessage}
+
+ )
+
+ // 更新模态框内容
+ if (modalInstance) {
+ modalInstance.update({
+ content: content
+ })
+ }
+ }
+
+ const modalInstance = window.modal.confirm({
+ title: t('settings.models.provider_key_confirm_title', { provider: providerDisplayName }),
+ content: (
+
+
+
+ {t('settings.models.provider_name')}:
+ {providerDisplayName}
+
+
+ {t('settings.models.provider_id')}:
+ {id}
+
+ {baseUrl && (
+
+ {t('settings.models.base_url')}:
+ {baseUrl}
+
+ )}
+
+ {t('settings.models.api_key')}:
+
+ {showApiKey ? newApiKey : '*********'}
+
+ {showApiKey ? : }
+
+
+
+
+ {confirmMessage}
+
+ ),
+ okText: keyAlreadyExists ? t('common.confirm') : t('common.add'),
+ cancelText: t('common.cancel'),
+ centered: true,
+ onCancel() {
+ window.navigate(`/settings/provider?id=${id}`)
+ },
+ onOk() {
+ window.navigate(`/settings/provider?id=${id}`)
+ if (keyAlreadyExists) {
+ // 如果 key 已经存在,只显示消息,不做任何更改
+ window.message.info(t('settings.models.provider_key_no_change', { provider: providerDisplayName }))
+ return
+ }
+
+ // 如果 key 不存在,添加到现有 keys 的末尾
+ const finalApiKey = hasExistingKey ? `${existingProvider.apiKey},${newApiKey.trim()}` : newApiKey.trim()
+
+ const updatedProvider = {
+ ...existingProvider,
+ apiKey: finalApiKey,
+ ...(baseUrl && { apiHost: baseUrl })
+ }
+
+ if (isNewProvider) {
+ addProvider(updatedProvider)
+ } else {
+ updateProvider(updatedProvider)
+ }
+
+ setSelectedProvider(updatedProvider)
+ window.message.success(t('settings.models.provider_key_added', { provider: providerDisplayName }))
+ }
+ })
+
+ return modalInstance
+ }
+
+ createModalContent()
+ }
+
+ // 检查 URL 参数
+ const addProviderData = searchParams.get('addProviderData')
+ if (!addProviderData) {
+ return
+ }
+
+ try {
+ const base64Decode = (base64EncodedString: string) =>
+ new TextDecoder().decode(Uint8Array.from(atob(base64EncodedString), (m) => m.charCodeAt(0)))
+ const {
+ id,
+ apiKey: newApiKey,
+ baseUrl,
+ type,
+ name
+ } = JSON.parse(base64Decode(addProviderData.replaceAll('_', '+').replaceAll('-', '/')))
+
+ if (!id || !newApiKey || !baseUrl) {
+ window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data'))
+ window.navigate('/settings/provider')
+ return
+ }
+
+ handleProviderAddKey({ id, apiKey: newApiKey, baseUrl, type, name })
+ } catch (error) {
+ window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data'))
+ window.navigate('/settings/provider')
+ }
+ }, [searchParams])
+
const onDragEnd = (result: DropResult) => {
setDragging(false)
if (result.destination) {
@@ -380,4 +580,97 @@ const AddButtonWrapper = styled.div`
align-items: center;
padding: 10px 8px;
`
+
+const ProviderInfoContainer = styled.div`
+ color: var(--color-text);
+`
+
+const ProviderInfoCard = styled(Card)`
+ margin-bottom: 16px;
+ background-color: var(--color-background-soft);
+ border: 1px solid var(--color-border);
+
+ .ant-card-body {
+ padding: 12px;
+ }
+`
+
+const ProviderInfoRow = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+`
+
+const ProviderInfoLabel = styled.span`
+ font-weight: 600;
+ color: var(--color-text-2);
+ min-width: 80px;
+`
+
+const ProviderInfoValue = styled.span`
+ font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
+ background-color: var(--color-background-soft);
+ padding: 2px 6px;
+ border-radius: 4px;
+ border: 1px solid var(--color-border);
+ word-break: break-all;
+ flex: 1;
+ margin-left: 8px;
+`
+
+const ConfirmMessage = styled.div`
+ color: var(--color-text);
+ line-height: 1.5;
+`
+
+const ApiKeyContainer = styled.div`
+ display: flex;
+ align-items: center;
+ flex: 1;
+ margin-left: 8px;
+ position: relative;
+`
+
+const ApiKeyValue = styled.span`
+ font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
+ background-color: var(--color-background-soft);
+ padding: 2px 32px 2px 6px;
+ border-radius: 4px;
+ border: 1px solid var(--color-border);
+ word-break: break-all;
+ flex: 1;
+`
+
+const EyeButton = styled.button`
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--color-text-3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px;
+ border-radius: 2px;
+ transition: all 0.2s ease;
+ position: absolute;
+ right: 4px;
+ top: 50%;
+ transform: translateY(-50%);
+
+ &:hover {
+ color: var(--color-text);
+ background-color: var(--color-background-mute);
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--color-primary-outline);
+ }
+`
+
export default ProvidersList