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:
parent
333547df3d
commit
53f46218d3
@ -50,7 +50,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['chunk-RK3FTE5R.js']
|
||||
exclude: ['chunk-PZ64DZKH.js']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -19,6 +19,25 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.whenReady().then(async () => {
|
||||
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
|
||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||
|
||||
|
||||
@ -163,6 +163,19 @@ export class WindowService {
|
||||
mainWindow.webContents.setWindowOpenHandler((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/')) {
|
||||
const fileName = url.replace('http://file/', '')
|
||||
const storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
|
||||
40
src/renderer/src/components/OAuth/OAuthButton.tsx
Normal file
40
src/renderer/src/components/OAuth/OAuthButton.tsx
Normal 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
|
||||
@ -8,3 +8,5 @@ export const platform = window.electron?.process?.platform
|
||||
export const isMac = platform === 'darwin'
|
||||
export const isWindows = platform === 'win32' || platform === 'win64'
|
||||
export const isLinux = platform === 'linux'
|
||||
|
||||
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
|
||||
|
||||
@ -15,9 +15,17 @@ const resources = {
|
||||
'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({
|
||||
resources,
|
||||
lng: localStorage.getItem('language') || navigator.language || 'en-US',
|
||||
lng: getLanguage(),
|
||||
fallbackLng: 'en-US',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
|
||||
@ -509,7 +509,6 @@
|
||||
"provider.check": "Check",
|
||||
"provider.docs_check": "Check",
|
||||
"provider.docs_more_details": "for more details",
|
||||
"provider.get_api_key": "Get API Key",
|
||||
"provider.search_placeholder": "Search model id or name",
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@ -688,6 +687,12 @@
|
||||
"esc_back": "back",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -672,6 +672,12 @@
|
||||
"esc_back": "戻る",
|
||||
"copy_last_message": "C キーを押してコピー"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"oauth_button": "{{provider}}で認証",
|
||||
"get_key": "取得",
|
||||
"get_key_success": "APIキーの自動取得に成功しました",
|
||||
"login": "認証"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -506,7 +506,6 @@
|
||||
"provider.check": "Проверить",
|
||||
"provider.docs_check": "Проверить",
|
||||
"provider.docs_more_details": "для получения дополнительной информации",
|
||||
"provider.get_api_key": "Получить ключ API",
|
||||
"provider.search_placeholder": "Поиск по ID или имени модели",
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@ -685,6 +684,12 @@
|
||||
"esc_back": "возвращения",
|
||||
"copy_last_message": "Нажмите C для копирования"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"oauth_button": "Авторизоваться с {{provider}}",
|
||||
"get_key": "Получить",
|
||||
"get_key_success": "Автоматический получение ключа API успешно",
|
||||
"login": "Войти"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -489,7 +489,7 @@
|
||||
"delete.title": "删除提供商",
|
||||
"docs_check": "查看",
|
||||
"docs_more_details": "获取更多详情",
|
||||
"get_api_key": "点击这里获取密钥",
|
||||
"get_api_key": "获取密钥",
|
||||
"no_models": "请先添加模型再检查 API 连接",
|
||||
"not_checked": "未检查",
|
||||
"remove_duplicate_keys": "移除重复密钥",
|
||||
@ -674,6 +674,12 @@
|
||||
"esc_back": "返回",
|
||||
"copy_last_message": "按 C 键复制"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"oauth_button": "使用{{provider}}登录",
|
||||
"get_key": "获取",
|
||||
"get_key_success": "自动获取密钥成功",
|
||||
"login": "登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -488,7 +488,7 @@
|
||||
"delete.title": "刪除提供者",
|
||||
"docs_check": "檢查",
|
||||
"docs_more_details": "查看更多細節",
|
||||
"get_api_key": "獲取 API 密鑰",
|
||||
"get_api_key": "獲取密鑰",
|
||||
"no_models": "請先添加模型再檢查 API 連接",
|
||||
"not_checked": "未檢查",
|
||||
"remove_duplicate_keys": "移除重複密鑰",
|
||||
@ -673,6 +673,12 @@
|
||||
"esc_back": "返回",
|
||||
"copy_last_message": "按 C 鍵複製"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"oauth_button": "使用{{provider}}登入",
|
||||
"get_key": "獲取",
|
||||
"get_key_success": "自動獲取密鑰成功",
|
||||
"login": "登入"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import ModelTags from '@renderer/components/ModelTags'
|
||||
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
|
||||
import { EMBEDDING_REGEX, getModelLogo, VISION_REGEX } from '@renderer/config/models'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
@ -16,6 +17,7 @@ import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { isOpenAIProvider } from '@renderer/providers/ProviderFactory'
|
||||
import { checkApi } from '@renderer/services/ApiService'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setModel } from '@renderer/store/assistants'
|
||||
import { Model, ModelType, Provider } from '@renderer/types'
|
||||
@ -61,17 +63,18 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
const { defaultModel, setDefaultModel } = useDefaultModel()
|
||||
|
||||
const modelGroups = groupBy(models, 'group')
|
||||
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(provider.apiKey)
|
||||
setApiHost(provider.apiHost)
|
||||
}, [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 onUpdateApiKey = () => {
|
||||
if (apiKey.trim()) {
|
||||
if (apiKey !== 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 = () => {
|
||||
setApiHost(configedApiHost)
|
||||
updateProvider({ ...provider, apiHost: configedApiHost })
|
||||
@ -201,16 +197,28 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
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 (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingTitle>
|
||||
<Flex align="center">
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderName>{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}</ProviderName>
|
||||
{officialWebsite! && (
|
||||
<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>
|
||||
)}
|
||||
</Flex>
|
||||
@ -232,6 +240,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
type="password"
|
||||
autoFocus={provider.enabled && apiKey === ''}
|
||||
/>
|
||||
{isProviderSupportAuth(provider) && <OAuthButton provider={provider} />}
|
||||
<Button
|
||||
type={apiValid ? 'primary' : 'default'}
|
||||
ghost={apiValid}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { Provider } from '@renderer/types'
|
||||
|
||||
export function getProviderName(id: string) {
|
||||
const provider = store.getState().llm.providers.find((p) => p.id === id)
|
||||
@ -13,3 +14,8 @@ export function getProviderName(id: string) {
|
||||
|
||||
return provider?.name
|
||||
}
|
||||
|
||||
export function isProviderSupportAuth(provider: Provider) {
|
||||
const supportProviders = ['silicon']
|
||||
return supportProviders.includes(provider.id)
|
||||
}
|
||||
|
||||
@ -43,6 +43,16 @@ const initialState: LlmState = {
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'deepseek',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.deepseek.com',
|
||||
models: SYSTEM_MODELS.deepseek,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
@ -94,16 +104,6 @@ const initialState: LlmState = {
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'deepseek',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.deepseek.com',
|
||||
models: SYSTEM_MODELS.deepseek,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'ocoolai',
|
||||
name: 'ocoolAI',
|
||||
|
||||
@ -1,12 +1,48 @@
|
||||
import { SILICON_CLIENT_ID } from '@renderer/config/constant'
|
||||
import { getLanguageCode } from '@renderer/i18n'
|
||||
export const oauthWithSiliconFlow = async (setKey) => {
|
||||
const clientId = 'SFrugiu0ezVmREv8BAU6GV'
|
||||
const ACCOUNT_ENDPOINT = 'https://account.siliconflow.cn'
|
||||
const authUrl = `${ACCOUNT_ENDPOINT}/oauth?client_id=${clientId}`
|
||||
const popup = window.open(authUrl, 'oauthPopup', 'width=600,height=600')
|
||||
window.addEventListener('message', (event) => {
|
||||
const authUrl = `https://account.siliconflow.cn/oauth?client_id=${SILICON_CLIENT_ID}`
|
||||
|
||||
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) => {
|
||||
if (event.data.length > 0 && event.data[0]['secretKey'] !== undefined) {
|
||||
setKey(event.data[0]['secretKey'])
|
||||
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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user