diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 7ba4164969..e8bd965065 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -173,5 +173,8 @@ export enum IpcChannel { StoreSync_Subscribe = 'store-sync:subscribe', StoreSync_Unsubscribe = 'store-sync:unsubscribe', StoreSync_OnUpdate = 'store-sync:on-update', - StoreSync_BroadcastSync = 'store-sync:broadcast-sync' + StoreSync_BroadcastSync = 'store-sync:broadcast-sync', + + // Provider + Provider_AddKey = 'provider:add-key' } diff --git a/src/main/services/ProtocolClient.ts b/src/main/services/ProtocolClient.ts index f37c61bb39..7e0b274816 100644 --- a/src/main/services/ProtocolClient.ts +++ b/src/main/services/ProtocolClient.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util' import { app } from 'electron' import Logger from 'electron-log' +import { handleProvidersProtocolUrl } from './urlschema/handle-providers' import { handleMcpProtocolUrl } from './urlschema/mcp-install' import { windowService } from './WindowService' @@ -34,6 +35,9 @@ export function handleProtocolUrl(url: string) { case 'mcp': handleMcpProtocolUrl(urlObj) return + case 'providers': + handleProvidersProtocolUrl(urlObj) + return } // You can send the data to your renderer process diff --git a/src/main/services/urlschema/handle-providers.ts b/src/main/services/urlschema/handle-providers.ts new file mode 100644 index 0000000000..bc109437e6 --- /dev/null +++ b/src/main/services/urlschema/handle-providers.ts @@ -0,0 +1,37 @@ +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) + switch (url.pathname) { + case '/api-keys': { + // jsonConfig example: + // { + // "id": "tokenflux", + // "baseUrl": "https://tokenflux.ai/v1", + // "apiKey": "sk-xxxx" + // } + // cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))} + 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}')`) + } + } else { + Logger.error('No data found in URL') + } + break + } + default: + console.error(`Unknown MCP protocol URL: ${url}`) + break + } +} diff --git a/src/renderer/src/assets/images/models/tokenflux.png b/src/renderer/src/assets/images/models/tokenflux.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/models/tokenflux.png differ diff --git a/src/renderer/src/assets/images/models/tokenflux_dark.png b/src/renderer/src/assets/images/models/tokenflux_dark.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/models/tokenflux_dark.png differ diff --git a/src/renderer/src/assets/images/providers/tokenflux.png b/src/renderer/src/assets/images/providers/tokenflux.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/providers/tokenflux.png differ diff --git a/src/renderer/src/components/OAuth/OAuthButton.tsx b/src/renderer/src/components/OAuth/OAuthButton.tsx index 6160783694..37909041bf 100644 --- a/src/renderer/src/components/OAuth/OAuthButton.tsx +++ b/src/renderer/src/components/OAuth/OAuthButton.tsx @@ -1,5 +1,5 @@ import { Provider } from '@renderer/types' -import { oauthWithAihubmix, oauthWithSiliconFlow } from '@renderer/utils/oauth' +import { oauthWithAihubmix, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth' import { Button, ButtonProps } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -27,6 +27,10 @@ const OAuthButton: FC = ({ provider, onSuccess, ...buttonProps }) => { if (provider.id === 'aihubmix') { oauthWithAihubmix(handleSuccess) } + + if (provider.id === 'tokenflux') { + oauthWithTokenFlux() + } } return ( diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts index db5f2c9174..6e1021bd43 100644 --- a/src/renderer/src/config/constant.ts +++ b/src/renderer/src/config/constant.ts @@ -10,6 +10,7 @@ export const isWindows = platform === 'win32' || platform === 'win64' export const isLinux = platform === 'linux' export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu' +export const TOKENFLUX_HOST = 'https://tokenflux.ai' // Messages loading configuration export const INITIAL_MESSAGES_COUNT = 20 diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 8cb7bf8209..a508b243ed 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -34,8 +34,10 @@ import DianxinModelLogo from '@renderer/assets/images/models/dianxin.png' import DianxinModelLogoDark from '@renderer/assets/images/models/dianxin_dark.png' import DoubaoModelLogo from '@renderer/assets/images/models/doubao.png' import DoubaoModelLogoDark from '@renderer/assets/images/models/doubao_dark.png' -import EmbeddingModelLogo from '@renderer/assets/images/models/embedding.png' -import EmbeddingModelLogoDark from '@renderer/assets/images/models/embedding.png' +import { + default as EmbeddingModelLogo, + default as EmbeddingModelLogoDark +} from '@renderer/assets/images/models/embedding.png' import FlashaudioModelLogo from '@renderer/assets/images/models/flashaudio.png' import FlashaudioModelLogoDark from '@renderer/assets/images/models/flashaudio_dark.png' import FluxModelLogo from '@renderer/assets/images/models/flux.png' @@ -44,14 +46,15 @@ import GeminiModelLogo from '@renderer/assets/images/models/gemini.png' import GeminiModelLogoDark from '@renderer/assets/images/models/gemini_dark.png' import GemmaModelLogo from '@renderer/assets/images/models/gemma.png' import GemmaModelLogoDark from '@renderer/assets/images/models/gemma_dark.png' -import GoogleModelLogo from '@renderer/assets/images/models/google.png' -import GoogleModelLogoDark from '@renderer/assets/images/models/google.png' +import { default as GoogleModelLogo, default as GoogleModelLogoDark } from '@renderer/assets/images/models/google.png' import ChatGPT35ModelLogo from '@renderer/assets/images/models/gpt_3.5.png' import ChatGPT4ModelLogo from '@renderer/assets/images/models/gpt_4.png' -import ChatGptModelLogoDakr from '@renderer/assets/images/models/gpt_dark.png' -import ChatGPT35ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png' -import ChatGPT4ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png' -import ChatGPTo1ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png' +import { + default as ChatGPT4ModelLogoDark, + default as ChatGPT35ModelLogoDark, + default as ChatGptModelLogoDakr, + default as ChatGPTo1ModelLogoDark +} from '@renderer/assets/images/models/gpt_dark.png' import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png' import GrokModelLogo from '@renderer/assets/images/models/grok.png' import GrokModelLogoDark from '@renderer/assets/images/models/grok_dark.png' @@ -86,22 +89,28 @@ import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png' import MicrosoftModelLogoDark from '@renderer/assets/images/models/microsoft_dark.png' import MidjourneyModelLogo from '@renderer/assets/images/models/midjourney.png' import MidjourneyModelLogoDark from '@renderer/assets/images/models/midjourney_dark.png' -import MinicpmModelLogo from '@renderer/assets/images/models/minicpm.webp' -import MinicpmModelLogoDark from '@renderer/assets/images/models/minicpm.webp' +import { + default as MinicpmModelLogo, + default as MinicpmModelLogoDark +} from '@renderer/assets/images/models/minicpm.webp' import MinimaxModelLogo from '@renderer/assets/images/models/minimax.png' import MinimaxModelLogoDark from '@renderer/assets/images/models/minimax_dark.png' import MistralModelLogo from '@renderer/assets/images/models/mixtral.png' import MistralModelLogoDark from '@renderer/assets/images/models/mixtral_dark.png' import MoonshotModelLogo from '@renderer/assets/images/models/moonshot.png' import MoonshotModelLogoDark from '@renderer/assets/images/models/moonshot_dark.png' -import NousResearchModelLogo from '@renderer/assets/images/models/nousresearch.png' -import NousResearchModelLogoDark from '@renderer/assets/images/models/nousresearch.png' +import { + default as NousResearchModelLogo, + default as NousResearchModelLogoDark +} from '@renderer/assets/images/models/nousresearch.png' import NvidiaModelLogo from '@renderer/assets/images/models/nvidia.png' import NvidiaModelLogoDark from '@renderer/assets/images/models/nvidia_dark.png' import PalmModelLogo from '@renderer/assets/images/models/palm.png' import PalmModelLogoDark from '@renderer/assets/images/models/palm_dark.png' -import PerplexityModelLogo from '@renderer/assets/images/models/perplexity.png' -import PerplexityModelLogoDark from '@renderer/assets/images/models/perplexity.png' +import { + default as PerplexityModelLogo, + default as PerplexityModelLogoDark +} from '@renderer/assets/images/models/perplexity.png' import PixtralModelLogo from '@renderer/assets/images/models/pixtral.png' import PixtralModelLogoDark from '@renderer/assets/images/models/pixtral_dark.png' import QwenModelLogo from '@renderer/assets/images/models/qwen.png' @@ -118,6 +127,8 @@ import SunoModelLogo from '@renderer/assets/images/models/suno.png' import SunoModelLogoDark from '@renderer/assets/images/models/suno_dark.png' import TeleModelLogo from '@renderer/assets/images/models/tele.png' import TeleModelLogoDark from '@renderer/assets/images/models/tele_dark.png' +import TokenFluxModelLogo from '@renderer/assets/images/models/tokenflux.png' +import TokenFluxModelLogoDark from '@renderer/assets/images/models/tokenflux_dark.png' import UpstageModelLogo from '@renderer/assets/images/models/upstage.png' import UpstageModelLogoDark from '@renderer/assets/images/models/upstage_dark.png' import ViduModelLogo from '@renderer/assets/images/models/vidu.png' @@ -369,7 +380,8 @@ export function getModelLogo(modelId: string) { perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark, sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark, 'bge-': BgeModelLogo, - 'voyage-': VoyageModelLogo + 'voyage-': VoyageModelLogo, + tokenflux: isLight ? TokenFluxModelLogo : TokenFluxModelLogoDark } for (const key in logoMap) { @@ -2160,6 +2172,68 @@ export const SYSTEM_MODELS: Record = { name: 'Qwen2.5 72B Instruct', group: 'Qwen' } + ], + tokenflux: [ + { + id: 'gpt-4.1', + provider: 'tokenflux', + name: 'GPT-4.1', + group: 'GPT-4.1' + }, + { + id: 'gpt-4.1-mini', + provider: 'tokenflux', + name: 'GPT-4.1 Mini', + group: 'GPT-4.1' + }, + { + id: 'claude-sonnet-4', + provider: 'tokenflux', + name: 'Claude Sonnet 4', + group: 'Claude' + }, + { + id: 'claude-3-7-sonnet', + provider: 'tokenflux', + name: 'Claude 3.7 Sonnet', + group: 'Claude' + }, + { + id: 'gemini-2.5-pro', + provider: 'tokenflux', + name: 'Gemini 2.5 Pro', + group: 'Gemini' + }, + { + id: 'gemini-2.5-flash', + provider: 'tokenflux', + name: 'Gemini 2.5 Flash', + group: 'Gemini' + }, + { + id: 'deepseek-r1', + provider: 'tokenflux', + name: 'DeepSeek R1', + group: 'DeepSeek' + }, + { + id: 'deepseek-v3', + provider: 'tokenflux', + name: 'DeepSeek V3', + group: 'DeepSeek' + }, + { + id: 'qwen-max', + provider: 'tokenflux', + name: 'Qwen Max', + group: 'Qwen' + }, + { + id: 'qwen-plus', + provider: 'tokenflux', + name: 'Qwen Plus', + group: 'Qwen' + } ] } diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index e995e20df0..506eea3ad1 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -38,12 +38,15 @@ import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.p import StepProviderLogo from '@renderer/assets/images/providers/step.png' import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png' import TogetherProviderLogo from '@renderer/assets/images/providers/together.png' +import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png' import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png' import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png' import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png' import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png' import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' +import { TOKENFLUX_HOST } from './constant' + const PROVIDER_LOGO_MAP = { openai: OpenAiProviderLogo, silicon: SiliconFlowProviderLogo, @@ -90,7 +93,8 @@ const PROVIDER_LOGO_MAP = { gpustack: GPUStackProviderLogo, alayanew: AlayaNewProviderLogo, voyageai: VoyageAIProviderLogo, - qiniu: QiniuProviderLogo + qiniu: QiniuProviderLogo, + tokenflux: TokenFluxProviderLogo } as const export function getProviderLogo(providerId: string) { @@ -597,5 +601,16 @@ export const PROVIDER_CONFIG = { docs: 'https://developer.qiniu.com/aitokenapi', models: 'https://developer.qiniu.com/aitokenapi/12883/model-list' } + }, + tokenflux: { + api: { + url: TOKENFLUX_HOST + }, + websites: { + official: TOKENFLUX_HOST, + apiKey: `${TOKENFLUX_HOST}/dashboard/api-keys`, + docs: `${TOKENFLUX_HOST}/docs`, + models: `${TOKENFLUX_HOST}/models` + } } } diff --git a/src/renderer/src/hooks/useProvider.ts b/src/renderer/src/hooks/useProvider.ts index 95a8a8fa0b..0e044602cb 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 { useAppDispatch, useAppSelector } from '@renderer/store' +import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { addModel, addProvider, @@ -10,6 +10,7 @@ import { updateProviders } from '@renderer/store/llm' import { Assistant, Model, Provider } from '@renderer/types' +import { IpcChannel } from '@shared/IpcChannel' import { useDefaultModel } from './useAssistant' @@ -63,3 +64,17 @@ 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 cdd373decc..0c70a0535a 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -939,7 +939,8 @@ "zhinao": "360AI", "zhipu": "ZHIPU AI", "voyageai": "Voyage AI", - "qiniu": "Qiniu" + "qiniu": "Qiniu", + "tokenflux": "TokenFlux" }, "restore": { "confirm": "Are you sure you want to restore data?", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 4288692c09..0a6d3e9405 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -940,7 +940,8 @@ "zhinao": "360智脳", "zhipu": "智譜AI", "voyageai": "Voyage AI", - "qiniu": "七牛云" + "qiniu": "七牛云", + "tokenflux": "TokenFlux" }, "restore": { "confirm": "データを復元しますか?", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 40297a0edc..fc1a54c0d0 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -940,7 +940,8 @@ "zhinao": "360AI", "zhipu": "ZHIPU AI", "voyageai": "Voyage AI", - "qiniu": "Qiniu" + "qiniu": "Qiniu", + "tokenflux": "TokenFlux" }, "restore": { "confirm": "Вы уверены, что хотите восстановить данные?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6be1edec07..ce882f6e4c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -939,7 +939,8 @@ "zhinao": "360智脑", "zhipu": "智谱AI", "voyageai": "Voyage AI", - "qiniu": "七牛云" + "qiniu": "七牛云", + "tokenflux": "TokenFlux" }, "restore": { "confirm": "确定要恢复数据吗?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 88b7145435..cb670a51c9 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -940,7 +940,8 @@ "zhinao": "360 智腦", "zhipu": "智譜 AI", "voyageai": "Voyage AI", - "qiniu": "七牛雲" + "qiniu": "七牛雲", + "tokenflux": "TokenFlux" }, "restore": { "confirm": "確定要復原資料嗎?", diff --git a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx index 663b593280..f25a1c8735 100644 --- a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx @@ -1,12 +1,13 @@ import { TopView } from '@renderer/components/TopView' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { MCPServer } from '@renderer/types' +import type { MCPServer } from '@renderer/types' import { Button, Form, Input, Modal, Select } from 'antd' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { getModelScopeToken, saveModelScopeToken, syncModelScopeServers } from './modelscopeSyncUtils' +import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_HOST } from './providers/tokenflux' // Provider configuration interface interface ProviderConfig { @@ -33,6 +34,17 @@ const providers: ProviderConfig[] = [ getToken: getModelScopeToken, saveToken: saveModelScopeToken, syncServers: syncModelScopeServers + }, + { + key: 'tokenflux', + name: 'TokenFlux', + description: 'TokenFlux 平台 MCP 服务', + discoverUrl: `${TOKENFLUX_HOST}/mcps`, + apiKeyUrl: `${TOKENFLUX_HOST}/dashboard/api-keys`, + tokenFieldName: 'tokenfluxToken', + getToken: getTokenFluxToken, + saveToken: saveTokenFluxToken, + syncServers: syncTokenFluxServers } ] @@ -83,7 +95,10 @@ const PopupContainer: React.FC = ({ resolve, existingServers }) => { // Save token if present if (token) { selectedProvider.saveToken(token) - setTokens((prev) => ({ ...prev, [selectedProvider.tokenFieldName]: token })) + setTokens((prev) => ({ + ...prev, + [selectedProvider.tokenFieldName]: token + })) } // Sync servers @@ -196,11 +211,19 @@ const PopupContainer: React.FC = ({ resolve, existingServers }) => { {t('settings.mcp.sync.setToken', 'Enter Your Token')} + rules={[ + { + required: true, + message: t('settings.mcp.sync.tokenRequired', 'API Token is required') + } + ]}> { - setTokens((prev) => ({ ...prev, [selectedProvider.tokenFieldName]: e.target.value })) + setTokens((prev) => ({ + ...prev, + [selectedProvider.tokenFieldName]: e.target.value + })) }} /> diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/tokenflux.ts b/src/renderer/src/pages/settings/MCPSettings/providers/tokenflux.ts new file mode 100644 index 0000000000..7b039cda79 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/providers/tokenflux.ts @@ -0,0 +1,146 @@ +import { nanoid } from '@reduxjs/toolkit' +import type { MCPServer } from '@renderer/types' +import i18next from 'i18next' + +// Token storage constants and utilities +const TOKEN_STORAGE_KEY = 'tokenflux_token' +export const TOKENFLUX_HOST = 'https://tokenflux.ai' + +export const saveTokenFluxToken = (token: string): void => { + localStorage.setItem(TOKEN_STORAGE_KEY, token) +} + +export const getTokenFluxToken = (): string | null => { + return localStorage.getItem(TOKEN_STORAGE_KEY) +} + +export const clearTokenFluxToken = (): void => { + localStorage.removeItem(TOKEN_STORAGE_KEY) +} + +export const hasTokenFluxToken = (): boolean => { + return !!getTokenFluxToken() +} + +interface TokenFluxServerAuthSchemaApiKey { + location: string + name: string + prefix: string +} + +interface TokenFluxServer { + name: string + display_name?: string + description?: string + version: string + categories?: string[] + logo?: string + security_schemes?: Record +} + +interface TokenFluxSyncResult { + success: boolean + message: string + addedServers: MCPServer[] + errorDetails?: string +} + +// Function to fetch and process TokenFlux servers +export const syncTokenFluxServers = async ( + token: string, + existingServers: MCPServer[] +): Promise => { + const t = i18next.t + + try { + const response = await fetch(`${TOKENFLUX_HOST}/v1/mcps?enabled=true`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + + // Handle authentication errors + if (response.status === 401 || response.status === 403) { + clearTokenFluxToken() + return { + success: false, + message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), + addedServers: [] + } + } + + // Handle server errors + if (response.status === 500 || !response.ok) { + return { + success: false, + message: t('settings.mcp.sync.error'), + addedServers: [], + errorDetails: `Status: ${response.status}` + } + } + + // Process successful response + const data = await response.json() + const servers: TokenFluxServer[] = data.data || [] + + if (servers.length === 0) { + return { + success: true, + message: t('settings.mcp.sync.noServersAvailable', 'No MCP servers available'), + addedServers: [] + } + } + + // Transform TokenFlux servers to MCP servers format + const addedServers: MCPServer[] = [] + + for (const server of servers) { + try { + // Skip if server already exists + if (existingServers.some((s) => s.id === `@tokenflux/${server.name}`)) continue + + const authHeaders = {} + if (server.security_schemes && server.security_schemes.api_key) { + const keyAuth = server.security_schemes.api_key as TokenFluxServerAuthSchemaApiKey + if (keyAuth.location === 'header') { + authHeaders[keyAuth.name] = `${keyAuth.prefix || ''} {set your key here}`.trim() + } + } + + const mcpServer: MCPServer = { + id: `@tokenflux/${server.name}`, + name: server.display_name || server.name || `TokenFlux Server ${nanoid()}`, + description: server.description || '', + type: 'streamableHttp', + baseUrl: `${TOKENFLUX_HOST}/v1/mcps/${server.name}`, + isActive: true, + provider: 'TokenFlux', + providerUrl: `${TOKENFLUX_HOST}/mcps/${server.name}`, + logoUrl: server.logo || '', + tags: server.categories || [], + headers: authHeaders + } + + addedServers.push(mcpServer) + } catch (err) { + console.error('Error processing TokenFlux server:', err) + } + } + + return { + success: true, + message: t('settings.mcp.sync.success', { count: addedServers.length }), + addedServers + } + } catch (error) { + console.error('TokenFlux sync error:', error) + return { + success: false, + message: t('settings.mcp.sync.error'), + addedServers: [], + errorDetails: String(error) + } + } +} diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx index 2ff44b2876..cf6397f1cc 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx @@ -1,5 +1,6 @@ import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png' +import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png' import { HStack } from '@renderer/components/Layout' import OAuthButton from '@renderer/components/OAuth/OAuthButton' import { PROVIDER_CONFIG } from '@renderer/config/providers' @@ -7,8 +8,7 @@ import { Provider } from '@renderer/types' import { providerBills, providerCharge } from '@renderer/utils/oauth' import { Button } from 'antd' import { isEmpty } from 'lodash' -import { ReceiptText } from 'lucide-react' -import { CircleDollarSign } from 'lucide-react' +import { CircleDollarSign, ReceiptText } from 'lucide-react' import { FC } from 'react' import { Trans, useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -20,7 +20,8 @@ interface Props { const PROVIDER_LOGO_MAP = { silicon: SiliconFlowProviderLogo, - aihubmix: AiHubMixProviderLogo + aihubmix: AiHubMixProviderLogo, + tokenflux: TokenFluxProviderLogo } const ProviderOAuth: FC = ({ provider, setApiKey }) => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 4a87b97b36..c382ffa415 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -10,6 +10,7 @@ import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd' import { Search, UserPen } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'react-router-dom' import styled from 'styled-components' import AddProviderPopup from './AddProviderPopup' @@ -17,6 +18,7 @@ import ModelNotesPopup from './ModelNotesPopup' import ProviderSetting from './ProviderSetting' const ProvidersList: FC = () => { + const [searchParams] = useSearchParams() const providers = useAllProviders() const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders() const [selectedProvider, setSelectedProvider] = useState(providers[0]) @@ -46,6 +48,18 @@ const ProvidersList: FC = () => { loadAllLogos() }, [providers]) + useEffect(() => { + if (searchParams.get('id')) { + const providerId = searchParams.get('id') + const provider = providers.find((p) => p.id === providerId) + if (provider) { + setSelectedProvider(provider) + } else { + setSelectedProvider(providers[0]) + } + } + }, [providers, searchParams]) + const onDragEnd = (result: DropResult) => { setDragging(false) if (result.destination) { diff --git a/src/renderer/src/services/ProviderService.ts b/src/renderer/src/services/ProviderService.ts index 11fe640afd..ef973d7265 100644 --- a/src/renderer/src/services/ProviderService.ts +++ b/src/renderer/src/services/ProviderService.ts @@ -16,7 +16,7 @@ export function getProviderName(id: string) { } export function isProviderSupportAuth(provider: Provider) { - const supportProviders = ['silicon', 'aihubmix'] + const supportProviders = ['silicon', 'aihubmix', 'tokenflux'] return supportProviders.includes(provider.id) } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 851341143a..644ebcccde 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -46,7 +46,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 105, + version: 106, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index 31508e5150..e2f2250324 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -487,6 +487,16 @@ export const INITIAL_PROVIDERS: Provider[] = [ models: SYSTEM_MODELS.voyageai, isSystem: true, enabled: false + }, + { + id: 'tokenflux', + name: 'TokenFlux', + type: 'openai', + apiKey: '', + apiHost: 'https://tokenflux.ai', + models: SYSTEM_MODELS.tokenflux, + isSystem: true, + enabled: false } ] diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 7cdc568d27..65ca22922f 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1436,6 +1436,15 @@ const migrateConfig = { } catch (error) { return state } + }, + '106': (state: RootState) => { + try { + addProvider(state, 'tokenflux') + state.llm.providers = moveProvider(state.llm.providers, 'tokenflux', 15) + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/utils/oauth.ts b/src/renderer/src/utils/oauth.ts index a96adea572..4b3ec09845 100644 --- a/src/renderer/src/utils/oauth.ts +++ b/src/renderer/src/utils/oauth.ts @@ -1,6 +1,6 @@ -import { SILICON_CLIENT_ID } from '@renderer/config/constant' -import { getLanguageCode } from '@renderer/i18n' -import i18n from '@renderer/i18n' +import { SILICON_CLIENT_ID, TOKENFLUX_HOST } from '@renderer/config/constant' +import i18n, { getLanguageCode } from '@renderer/i18n' + export const oauthWithSiliconFlow = async (setKey) => { const authUrl = `https://account.siliconflow.cn/oauth?client_id=${SILICON_CLIENT_ID}` @@ -58,6 +58,22 @@ export const oauthWithAihubmix = async (setKey) => { window.addEventListener('message', messageHandler) } +export const oauthWithTokenFlux = async () => { + const callbackUrl = `${TOKENFLUX_HOST}/auth/callback?redirect_to=/dashboard/api-keys` + const resp = await fetch(`${TOKENFLUX_HOST}/api/auth/auth-url?type=login&callback=${callbackUrl}`, {}) + if (!resp.ok) { + window.message.error(i18n.t('oauth.error')) + return + } + const data = await resp.json() + const authUrl = data.data.url + window.open( + authUrl, + 'oauth', + 'width=720,height=720,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,alwaysOnTop=yes,alwaysRaised=yes' + ) +} + export const providerCharge = async (provider: string) => { const chargeUrlMap = { silicon: { @@ -69,6 +85,11 @@ export const providerCharge = async (provider: string) => { url: `https://aihubmix.com/topup?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, width: 720, height: 900 + }, + tokenflux: { + url: `https://tokenflux.ai/dashboard/billing`, + width: 900, + height: 700 } } @@ -92,6 +113,11 @@ export const providerBills = async (provider: string) => { url: `https://aihubmix.com/statistics?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, width: 900, height: 700 + }, + tokenflux: { + url: `https://tokenflux.ai/dashboard/billing`, + width: 900, + height: 700 } }