feat: add oauth for siliconflow (#976)

* wip: silicon oauth

* feat: Add custom protocol handler for SiliconFlow OAuth login

* feat: Improve SiliconFlow OAuth flow with dynamic key update

* feat: Enhance OAuth and Provider Settings UI

* feat: Refactor SiliconFlow OAuth and update localization strings

* chore: Update provider localization and system provider configuration

* feat: Add OAuth support for AIHubMix provider
This commit is contained in:
亢奋猫 2025-02-04 15:41:40 +08:00 committed by GitHub
parent 333547df3d
commit 53f46218d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 200 additions and 39 deletions

View File

@ -50,7 +50,7 @@ export default defineConfig({
} }
}, },
optimizeDeps: { optimizeDeps: {
exclude: ['chunk-RK3FTE5R.js'] exclude: ['chunk-PZ64DZKH.js']
} }
} }
}) })

View File

@ -19,6 +19,25 @@ if (!app.requestSingleInstanceLock()) {
app.whenReady().then(async () => { app.whenReady().then(async () => {
await updateUserDataPath() await updateUserDataPath()
// Register custom protocol
if (!app.isDefaultProtocolClient('cherrystudio')) {
app.setAsDefaultProtocolClient('cherrystudio')
}
// Handle protocol open
app.on('open-url', (event, url) => {
event.preventDefault()
const parsedUrl = new URL(url)
if (parsedUrl.pathname === 'siliconflow.oauth.login') {
const code = parsedUrl.searchParams.get('code')
if (code) {
// Handle the OAuth code here
console.log('OAuth code received:', code)
// You can send this code to your renderer process via IPC if needed
}
}
})
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')

View File

@ -163,6 +163,19 @@ export class WindowService {
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
const { url } = details const { url } = details
const oauthProviderUrls = ['https://account.siliconflow.cn']
if (oauthProviderUrls.some((url) => url.startsWith(url))) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
webPreferences: {
partition: 'persist:webview'
}
}
}
}
if (url.includes('http://file/')) { if (url.includes('http://file/')) {
const fileName = url.replace('http://file/', '') const fileName = url.replace('http://file/', '')
const storageDir = path.join(app.getPath('userData'), 'Data', 'Files') const storageDir = path.join(app.getPath('userData'), 'Data', 'Files')

View File

@ -0,0 +1,40 @@
import { useProvider } from '@renderer/hooks/useProvider'
import { Provider } from '@renderer/types'
import { oauthWithAihubmix, oauthWithSiliconFlow } from '@renderer/utils/oauth'
import { Button, ButtonProps } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props extends ButtonProps {
provider: Provider
}
const OAuthButton: FC<Props> = (props) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(props.provider.id)
const onAuth = () => {
const onSuccess = (key: string) => {
if (key.trim()) {
updateProvider({ ...provider, apiKey: key })
window.message.success(t('auth.get_key_success'))
}
}
if (provider.id === 'silicon') {
oauthWithSiliconFlow(onSuccess)
}
if (provider.id === 'aihubmix') {
oauthWithAihubmix(onSuccess)
}
}
return (
<Button onClick={onAuth} {...props}>
{t('auth.get_key')}
</Button>
)
}
export default OAuthButton

View File

@ -8,3 +8,5 @@ export const platform = window.electron?.process?.platform
export const isMac = platform === 'darwin' export const isMac = platform === 'darwin'
export const isWindows = platform === 'win32' || platform === 'win64' export const isWindows = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux' export const isLinux = platform === 'linux'
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'

View File

@ -15,9 +15,17 @@ const resources = {
'ru-RU': ruRU 'ru-RU': ruRU
} }
export const getLanguage = () => {
return localStorage.getItem('language') || navigator.language || 'en-US'
}
export const getLanguageCode = () => {
return getLanguage().split('-')[0]
}
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources, resources,
lng: localStorage.getItem('language') || navigator.language || 'en-US', lng: getLanguage(),
fallbackLng: 'en-US', fallbackLng: 'en-US',
interpolation: { interpolation: {
escapeValue: false escapeValue: false

View File

@ -509,7 +509,6 @@
"provider.check": "Check", "provider.check": "Check",
"provider.docs_check": "Check", "provider.docs_check": "Check",
"provider.docs_more_details": "for more details", "provider.docs_more_details": "for more details",
"provider.get_api_key": "Get API Key",
"provider.search_placeholder": "Search model id or name", "provider.search_placeholder": "Search model id or name",
"proxy": { "proxy": {
"mode": { "mode": {
@ -688,6 +687,12 @@
"esc_back": "back", "esc_back": "back",
"copy_last_message": "Press C to copy" "copy_last_message": "Press C to copy"
} }
},
"auth": {
"oauth_button": "Auth with {{provider}}",
"get_key": "Get",
"get_key_success": "API key automatically obtained successfully",
"login": "Login"
} }
} }
} }

View File

@ -672,6 +672,12 @@
"esc_back": "戻る", "esc_back": "戻る",
"copy_last_message": "C キーを押してコピー" "copy_last_message": "C キーを押してコピー"
} }
},
"auth": {
"oauth_button": "{{provider}}で認証",
"get_key": "取得",
"get_key_success": "APIキーの自動取得に成功しました",
"login": "認証"
} }
} }
} }

View File

@ -506,7 +506,6 @@
"provider.check": "Проверить", "provider.check": "Проверить",
"provider.docs_check": "Проверить", "provider.docs_check": "Проверить",
"provider.docs_more_details": "для получения дополнительной информации", "provider.docs_more_details": "для получения дополнительной информации",
"provider.get_api_key": "Получить ключ API",
"provider.search_placeholder": "Поиск по ID или имени модели", "provider.search_placeholder": "Поиск по ID или имени модели",
"proxy": { "proxy": {
"mode": { "mode": {
@ -685,6 +684,12 @@
"esc_back": "возвращения", "esc_back": "возвращения",
"copy_last_message": "Нажмите C для копирования" "copy_last_message": "Нажмите C для копирования"
} }
},
"auth": {
"oauth_button": "Авторизоваться с {{provider}}",
"get_key": "Получить",
"get_key_success": "Автоматический получение ключа API успешно",
"login": "Войти"
} }
} }
} }

View File

@ -489,7 +489,7 @@
"delete.title": "删除提供商", "delete.title": "删除提供商",
"docs_check": "查看", "docs_check": "查看",
"docs_more_details": "获取更多详情", "docs_more_details": "获取更多详情",
"get_api_key": "点击这里获取密钥", "get_api_key": "获取密钥",
"no_models": "请先添加模型再检查 API 连接", "no_models": "请先添加模型再检查 API 连接",
"not_checked": "未检查", "not_checked": "未检查",
"remove_duplicate_keys": "移除重复密钥", "remove_duplicate_keys": "移除重复密钥",
@ -674,6 +674,12 @@
"esc_back": "返回", "esc_back": "返回",
"copy_last_message": "按 C 键复制" "copy_last_message": "按 C 键复制"
} }
},
"auth": {
"oauth_button": "使用{{provider}}登录",
"get_key": "获取",
"get_key_success": "自动获取密钥成功",
"login": "登录"
} }
} }
} }

View File

@ -488,7 +488,7 @@
"delete.title": "刪除提供者", "delete.title": "刪除提供者",
"docs_check": "檢查", "docs_check": "檢查",
"docs_more_details": "查看更多細節", "docs_more_details": "查看更多細節",
"get_api_key": "獲取 API 密鑰", "get_api_key": "獲取密鑰",
"no_models": "請先添加模型再檢查 API 連接", "no_models": "請先添加模型再檢查 API 連接",
"not_checked": "未檢查", "not_checked": "未檢查",
"remove_duplicate_keys": "移除重複密鑰", "remove_duplicate_keys": "移除重複密鑰",
@ -673,6 +673,12 @@
"esc_back": "返回", "esc_back": "返回",
"copy_last_message": "按 C 鍵複製" "copy_last_message": "按 C 鍵複製"
} }
},
"auth": {
"oauth_button": "使用{{provider}}登入",
"get_key": "獲取",
"get_key_success": "自動獲取密鑰成功",
"login": "登入"
} }
} }
} }

View File

@ -8,6 +8,7 @@ import {
SettingOutlined SettingOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import ModelTags from '@renderer/components/ModelTags' import ModelTags from '@renderer/components/ModelTags'
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
import { EMBEDDING_REGEX, getModelLogo, VISION_REGEX } from '@renderer/config/models' import { EMBEDDING_REGEX, getModelLogo, VISION_REGEX } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers' import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
@ -16,6 +17,7 @@ import { useProvider } from '@renderer/hooks/useProvider'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { isOpenAIProvider } from '@renderer/providers/ProviderFactory' import { isOpenAIProvider } from '@renderer/providers/ProviderFactory'
import { checkApi } from '@renderer/services/ApiService' import { checkApi } from '@renderer/services/ApiService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants' import { setModel } from '@renderer/store/assistants'
import { Model, ModelType, Provider } from '@renderer/types' import { Model, ModelType, Provider } from '@renderer/types'
@ -61,17 +63,18 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const { defaultModel, setDefaultModel } = useDefaultModel() const { defaultModel, setDefaultModel } = useDefaultModel()
const modelGroups = groupBy(models, 'group') const modelGroups = groupBy(models, 'group')
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
useEffect(() => { const providerConfig = PROVIDER_CONFIG[provider.id]
setApiKey(provider.apiKey) const officialWebsite = providerConfig?.websites?.official
setApiHost(provider.apiHost) const apiKeyWebsite = providerConfig?.websites?.apiKey
}, [provider]) const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models
const configedApiHost = providerConfig?.api?.url
const onUpdateApiKey = () => { const onUpdateApiKey = () => {
if (apiKey.trim()) { if (apiKey !== provider.apiKey) {
updateProvider({ ...provider, apiKey }) updateProvider({ ...provider, apiKey })
} else {
setApiKey(provider.apiKey)
} }
} }
@ -138,13 +141,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
} }
} }
const providerConfig = PROVIDER_CONFIG[provider.id]
const officialWebsite = providerConfig?.websites?.official
const apiKeyWebsite = providerConfig?.websites?.apiKey
const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models
const configedApiHost = providerConfig?.api?.url
const onReset = () => { const onReset = () => {
setApiHost(configedApiHost) setApiHost(configedApiHost)
updateProvider({ ...provider, apiHost: configedApiHost }) updateProvider({ ...provider, apiHost: configedApiHost })
@ -201,16 +197,28 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
return value.replaceAll('', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',') return value.replaceAll('', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',')
} }
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' useEffect(() => {
setApiKey(provider.apiKey)
setApiHost(provider.apiHost)
}, [provider.apiKey, provider.apiHost])
// Save apiKey to provider when unmount
useEffect(() => {
return () => {
if (apiKey.trim() && apiKey !== provider.apiKey) {
updateProvider({ ...provider, apiKey })
}
}
}, [apiKey, provider, updateProvider])
return ( return (
<SettingContainer theme={theme}> <SettingContainer theme={theme}>
<SettingTitle> <SettingTitle>
<Flex align="center"> <Flex align="center" gap={8}>
<ProviderName>{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}</ProviderName> <ProviderName>{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}</ProviderName>
{officialWebsite! && ( {officialWebsite! && (
<Link target="_blank" href={providerConfig.websites.official}> <Link target="_blank" href={providerConfig.websites.official}>
<ExportOutlined style={{ marginLeft: '8px', color: 'var(--color-text)', fontSize: '12px' }} /> <ExportOutlined style={{ color: 'var(--color-text)', fontSize: '12px' }} />
</Link> </Link>
)} )}
</Flex> </Flex>
@ -232,6 +240,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
type="password" type="password"
autoFocus={provider.enabled && apiKey === ''} autoFocus={provider.enabled && apiKey === ''}
/> />
{isProviderSupportAuth(provider) && <OAuthButton provider={provider} />}
<Button <Button
type={apiValid ? 'primary' : 'default'} type={apiValid ? 'primary' : 'default'}
ghost={apiValid} ghost={apiValid}

View File

@ -1,5 +1,6 @@
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
import { Provider } from '@renderer/types'
export function getProviderName(id: string) { export function getProviderName(id: string) {
const provider = store.getState().llm.providers.find((p) => p.id === id) const provider = store.getState().llm.providers.find((p) => p.id === id)
@ -13,3 +14,8 @@ export function getProviderName(id: string) {
return provider?.name return provider?.name
} }
export function isProviderSupportAuth(provider: Provider) {
const supportProviders = ['silicon']
return supportProviders.includes(provider.id)
}

View File

@ -43,6 +43,16 @@ const initialState: LlmState = {
isSystem: true, isSystem: true,
enabled: false enabled: false
}, },
{
id: 'deepseek',
name: 'deepseek',
type: 'openai',
apiKey: '',
apiHost: 'https://api.deepseek.com',
models: SYSTEM_MODELS.deepseek,
isSystem: true,
enabled: false
},
{ {
id: 'ollama', id: 'ollama',
name: 'Ollama', name: 'Ollama',
@ -94,16 +104,6 @@ const initialState: LlmState = {
isSystem: true, isSystem: true,
enabled: false enabled: false
}, },
{
id: 'deepseek',
name: 'deepseek',
type: 'openai',
apiKey: '',
apiHost: 'https://api.deepseek.com',
models: SYSTEM_MODELS.deepseek,
isSystem: true,
enabled: false
},
{ {
id: 'ocoolai', id: 'ocoolai',
name: 'ocoolAI', name: 'ocoolAI',

View File

@ -1,12 +1,48 @@
import { SILICON_CLIENT_ID } from '@renderer/config/constant'
import { getLanguageCode } from '@renderer/i18n'
export const oauthWithSiliconFlow = async (setKey) => { export const oauthWithSiliconFlow = async (setKey) => {
const clientId = 'SFrugiu0ezVmREv8BAU6GV' const authUrl = `https://account.siliconflow.cn/oauth?client_id=${SILICON_CLIENT_ID}`
const ACCOUNT_ENDPOINT = 'https://account.siliconflow.cn'
const authUrl = `${ACCOUNT_ENDPOINT}/oauth?client_id=${clientId}` const popup = window.open(
const popup = window.open(authUrl, 'oauthPopup', 'width=600,height=600') authUrl,
window.addEventListener('message', (event) => { 'oauth',
'width=720,height=720,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,alwaysOnTop=yes,alwaysRaised=yes'
)
const messageHandler = (event) => {
if (event.data.length > 0 && event.data[0]['secretKey'] !== undefined) { if (event.data.length > 0 && event.data[0]['secretKey'] !== undefined) {
setKey(event.data[0]['secretKey']) setKey(event.data[0]['secretKey'])
popup?.close() popup?.close()
window.removeEventListener('message', messageHandler)
} }
}) }
window.removeEventListener('message', messageHandler)
window.addEventListener('message', messageHandler)
}
export const oauthWithAihubmix = async (setKey) => {
const authUrl = `https://aihubmix.com/login?cherry_studio_oauth=true&lang=${getLanguageCode()}&aff=SJyh`
const popup = window.open(
authUrl,
'oauth',
'width=720,height=720,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,alwaysOnTop=yes,alwaysRaised=yes'
)
const messageHandler = (event) => {
const data = event.data
if (data && data.key === 'cherry_studio_oauth_callback') {
const apiKeys = data?.data?.apiKeys
if (apiKeys && apiKeys.length > 0) {
setKey(apiKeys[0].value)
popup?.close()
window.removeEventListener('message', messageHandler)
}
}
}
window.removeEventListener('message', messageHandler)
window.addEventListener('message', messageHandler)
} }