From c940e0bc92ade80ba2e95859cb9e9f6d7d0d01d5 Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Fri, 5 Sep 2025 17:43:20 +0800 Subject: [PATCH] feat: add Anthropic OAuth settings UI and logic (#8905) * feat: add Anthropic OAuth settings UI and logic Introduces AnthropicSettings component for managing Anthropic OAuth authentication in provider settings. Adds Anthropic OAuth logic in a new anthropicOAuth.ts file, including PKCE flow, token exchange, and credential management stubs. Integrates AnthropicSettings into ProviderSetting to enable UI for login, logout, and code entry. * feat: add Anthropic OAuth authentication support Introduces OAuth authentication for Anthropic provider, including UI changes for selecting authentication method and handling authorization code input. Updates i18n files with new Anthropic OAuth-related strings in multiple languages and adds the 'authType' property to the Provider type. * fix: oauth * refactor: Anthropic OAuth to main process service Moved Anthropic OAuth logic from renderer to main process as a singleton service. Updated IPC channels and preload API to support Anthropic OAuth actions. Refactored AnthropicSettings component to use new IPC-based API for authentication flow. * fix: add 'authenticating' translation and update AnthropicSettings Added the 'authenticating' key to Anthropic provider translations across multiple languages. Updated AnthropicSettings.tsx to remove the unused 'authenticating_detail' description and set the modal to be centered. * fix: add reference * Update AnthropicAPIClient.ts * fix: update credentials path and improve OAuth handling in AnthropicAPIClient * feat: add support for Anthropic OAuth provider handling in ProviderSetting * feat: enhance OAuth authentication messages in multiple languages * feat: add support for Anthropic provider with OAuth authentication and system message handling for new aisdk provider * fix: update credential path and use net.fetch for OAuth token requests * fix: setting page ui --------- Co-authored-by: Vaayne --- packages/shared/IpcChannel.ts | 8 + src/main/ipc.ts | 11 + src/main/services/AnthropicService.ts | 226 ++++++++++++++++++ src/preload/index.ts | 8 + src/renderer/src/aiCore/index_new.ts | 11 + .../clients/anthropic/AnthropicAPIClient.ts | 82 ++++++- .../src/aiCore/provider/config/anthropic.ts | 24 ++ .../src/aiCore/provider/providerConfig.ts | 61 +++-- src/renderer/src/i18n/locales/en-us.json | 21 ++ src/renderer/src/i18n/locales/zh-cn.json | 21 ++ src/renderer/src/i18n/locales/zh-tw.json | 21 ++ src/renderer/src/i18n/translate/el-gr.json | 17 ++ src/renderer/src/i18n/translate/es-es.json | 17 ++ src/renderer/src/i18n/translate/fr-fr.json | 17 ++ src/renderer/src/i18n/translate/ja-jp.json | 21 ++ src/renderer/src/i18n/translate/pt-pt.json | 17 ++ src/renderer/src/i18n/translate/ru-ru.json | 21 ++ .../ProviderSettings/AnthropicSettings.tsx | 168 +++++++++++++ .../ProviderSettings/ProviderSetting.tsx | 24 +- src/renderer/src/types/index.ts | 1 + 20 files changed, 765 insertions(+), 32 deletions(-) create mode 100644 src/main/services/AnthropicService.ts create mode 100644 src/renderer/src/aiCore/provider/config/anthropic.ts create mode 100644 src/renderer/src/pages/settings/ProviderSettings/AnthropicSettings.tsx diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index ebd33065af..a2a33e738e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -302,6 +302,14 @@ export enum IpcChannel { TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData', TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage', + // Anthropic OAuth + Anthropic_StartOAuthFlow = 'anthropic:start-oauth-flow', + Anthropic_CompleteOAuthWithCode = 'anthropic:complete-oauth-with-code', + Anthropic_CancelOAuthFlow = 'anthropic:cancel-oauth-flow', + Anthropic_GetAccessToken = 'anthropic:get-access-token', + Anthropic_HasCredentials = 'anthropic:has-credentials', + Anthropic_ClearCredentials = 'anthropic:clear-credentials', + // CodeTools CodeTools_Run = 'code-tools:run', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index bb094a8bac..8132353aab 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -5,6 +5,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryin' +import anthropicService from '@main/services/AnthropicService' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' @@ -781,6 +782,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { addStreamMessage(spanId, modelName, context, msg) ) + // Anthropic OAuth + ipcMain.handle(IpcChannel.Anthropic_StartOAuthFlow, () => anthropicService.startOAuthFlow()) + ipcMain.handle(IpcChannel.Anthropic_CompleteOAuthWithCode, (_, code: string) => + anthropicService.completeOAuthWithCode(code) + ) + ipcMain.handle(IpcChannel.Anthropic_CancelOAuthFlow, () => anthropicService.cancelOAuthFlow()) + ipcMain.handle(IpcChannel.Anthropic_GetAccessToken, () => anthropicService.getValidAccessToken()) + ipcMain.handle(IpcChannel.Anthropic_HasCredentials, () => anthropicService.hasCredentials()) + ipcMain.handle(IpcChannel.Anthropic_ClearCredentials, () => anthropicService.clearCredentials()) + // CodeTools ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) diff --git a/src/main/services/AnthropicService.ts b/src/main/services/AnthropicService.ts new file mode 100644 index 0000000000..980673d28b --- /dev/null +++ b/src/main/services/AnthropicService.ts @@ -0,0 +1,226 @@ +/** + * Reference: + * This code is adapted from https://github.com/ThinkInAIXYZ/deepchat + * Original file: src/main/presenter/anthropicOAuth.ts + */ +import path from 'node:path' + +import { loggerService } from '@logger' +import { getConfigDir } from '@main/utils/file' +import * as crypto from 'crypto' +import { net, shell } from 'electron' +import { promises } from 'fs' +import { dirname } from 'path' + +const logger = loggerService.withContext('AnthropicOAuth') + +// Constants +const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' +const CREDS_PATH = path.join(getConfigDir(), 'oauth', 'anthropic.json') + +// Types +interface Credentials { + access_token: string + refresh_token: string + expires_at: number +} + +interface PKCEPair { + verifier: string + challenge: string +} + +class AnthropicService extends Error { + private currentPKCE: PKCEPair | null = null + + // 1. Generate PKCE pair + private generatePKCE(): PKCEPair { + const verifier = crypto.randomBytes(32).toString('base64url') + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') + + return { verifier, challenge } + } + + // 2. Get OAuth authorization URL + private getAuthorizationURL(pkce: PKCEPair): string { + const url = new URL('https://claude.ai/oauth/authorize') + + url.searchParams.set('code', 'true') + url.searchParams.set('client_id', CLIENT_ID) + url.searchParams.set('response_type', 'code') + url.searchParams.set('redirect_uri', 'https://console.anthropic.com/oauth/code/callback') + url.searchParams.set('scope', 'org:create_api_key user:profile user:inference') + url.searchParams.set('code_challenge', pkce.challenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', pkce.verifier) + + return url.toString() + } + + // 3. Exchange authorization code for tokens + private async exchangeCodeForTokens(code: string, verifier: string): Promise { + // Handle both legacy format (code#state) and new format (pure code) + const authCode = code.includes('#') ? code.split('#')[0] : code + const state = code.includes('#') ? code.split('#')[1] : verifier + + const response = await net.fetch('https://console.anthropic.com/v1/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: authCode, + state: state, + grant_type: 'authorization_code', + client_id: CLIENT_ID, + redirect_uri: 'https://console.anthropic.com/oauth/code/callback', + code_verifier: verifier + }) + }) + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.statusText}`) + } + + const data = await response.json() + + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: Date.now() + data.expires_in * 1000 + } + } + + // 4. Refresh access token + private async refreshAccessToken(refreshToken: string): Promise { + const response = await net.fetch('https://console.anthropic.com/v1/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: CLIENT_ID + }) + }) + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.statusText}`) + } + + const data = await response.json() + + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: Date.now() + data.expires_in * 1000 + } + } + + // 5. Save credentials + private async saveCredentials(creds: Credentials): Promise { + await promises.mkdir(dirname(CREDS_PATH), { recursive: true }) + await promises.writeFile(CREDS_PATH, JSON.stringify(creds, null, 2)) + await promises.chmod(CREDS_PATH, 0o600) // Read/write for owner only + } + + // 6. Load credentials + private async loadCredentials(): Promise { + try { + const data = await promises.readFile(CREDS_PATH, 'utf-8') + return JSON.parse(data) + } catch { + return null + } + } + + // 7. Get valid access token (refresh if needed) + public async getValidAccessToken(): Promise { + const creds = await this.loadCredentials() + if (!creds) return null + + // If token is still valid, return it + if (creds.expires_at > Date.now() + 60000) { + // 1 minute buffer + return creds.access_token + } + + // Otherwise, refresh it + try { + const newCreds = await this.refreshAccessToken(creds.refresh_token) + await this.saveCredentials(newCreds) + return newCreds.access_token + } catch { + return null + } + } + + // 8. Start OAuth flow with external browser + public async startOAuthFlow(): Promise { + // Try to get existing valid token + const existingToken = await this.getValidAccessToken() + if (existingToken) return existingToken + + // Generate PKCE pair and store it for later use + this.currentPKCE = this.generatePKCE() + + // Build authorization URL + const authUrl = this.getAuthorizationURL(this.currentPKCE) + logger.debug(authUrl) + + // Open URL in external browser + await shell.openExternal(authUrl) + + // Return the URL for UI to show (optional) + return authUrl + } + + // 9. Complete OAuth flow with manual code input + public async completeOAuthWithCode(code: string): Promise { + if (!this.currentPKCE) { + throw new Error('OAuth flow not started. Please call startOAuthFlow first.') + } + + try { + // Exchange code for tokens using stored PKCE verifier + const credentials = await this.exchangeCodeForTokens(code, this.currentPKCE.verifier) + await this.saveCredentials(credentials) + + // Clear stored PKCE after successful exchange + this.currentPKCE = null + + return credentials.access_token + } catch (error) { + logger.error('OAuth code exchange failed:', error as Error) + // Clear PKCE on error + this.currentPKCE = null + throw error + } + } + + // 10. Cancel current OAuth flow + public cancelOAuthFlow(): void { + if (this.currentPKCE) { + logger.info('Cancelling OAuth flow') + this.currentPKCE = null + } + } + + // 11. Clear stored credentials + public async clearCredentials(): Promise { + try { + await promises.unlink(CREDS_PATH) + logger.info('Credentials cleared') + } catch (error) { + // File doesn't exist, which is fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + } + + // 12. Check if credentials exist + public async hasCredentials(): Promise { + const creds = await this.loadCredentials() + return creds !== null + } +} + +export default new AnthropicService() diff --git a/src/preload/index.ts b/src/preload/index.ts index f049710937..b1c6a215e5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -422,6 +422,14 @@ const api = { addStreamMessage: (spanId: string, modelName: string, context: string, message: any) => ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message) }, + anthropic_oauth: { + startOAuthFlow: () => ipcRenderer.invoke(IpcChannel.Anthropic_StartOAuthFlow), + completeOAuthWithCode: (code: string) => ipcRenderer.invoke(IpcChannel.Anthropic_CompleteOAuthWithCode, code), + cancelOAuthFlow: () => ipcRenderer.invoke(IpcChannel.Anthropic_CancelOAuthFlow), + getAccessToken: () => ipcRenderer.invoke(IpcChannel.Anthropic_GetAccessToken), + hasCredentials: () => ipcRenderer.invoke(IpcChannel.Anthropic_HasCredentials), + clearCredentials: () => ipcRenderer.invoke(IpcChannel.Anthropic_ClearCredentials) + }, codeTools: { run: ( cliTool: string, diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 9991dffd1f..897fc1c48a 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -21,6 +21,7 @@ import LegacyAiProvider from './legacy/index' import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas' import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder' import { buildPlugins } from './plugins/PluginBuilder' +import { buildClaudeCodeSystemMessage } from './provider/config/anthropic' import { createAiSdkProvider } from './provider/factory' import { getActualProvider, @@ -120,6 +121,16 @@ export default class ModernAiProvider { } } + if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') { + const claudeCodeSystemMessage = buildClaudeCodeSystemMessage(params.system) + params.system = undefined // 清除原有system,避免重复 + if (Array.isArray(params.messages)) { + params.messages = [...claudeCodeSystemMessage, ...params.messages] + } else { + params.messages = claudeCodeSystemMessage + } + } + if (config.topicId && getEnableDeveloperMode()) { // TypeScript类型窄化:确保topicId是string类型 const traceConfig = { diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts index 5bc4237b1f..dea98f2808 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts @@ -85,6 +85,8 @@ export class AnthropicAPIClient extends BaseApiClient< ToolUseBlock, ToolUnion > { + oauthToken: string | undefined = undefined + isOAuthMode: boolean = false sdkInstance: Anthropic | AnthropicVertex | undefined = undefined constructor(provider: Provider) { super(provider) @@ -94,22 +96,79 @@ export class AnthropicAPIClient extends BaseApiClient< if (this.sdkInstance) { return this.sdkInstance } - this.sdkInstance = new Anthropic({ - apiKey: this.apiKey, - baseURL: this.getBaseURL(), - dangerouslyAllowBrowser: true, - defaultHeaders: { - 'anthropic-beta': 'output-128k-2025-02-19', - ...this.provider.extra_headers + + if (this.provider.authType === 'oauth') { + if (!this.oauthToken) { + throw new Error('OAuth token is not available') } - }) + this.sdkInstance = new Anthropic({ + authToken: this.oauthToken, + baseURL: 'https://api.anthropic.com', + dangerouslyAllowBrowser: true, + defaultHeaders: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'oauth-2025-04-20' + // ...this.provider.extra_headers + } + }) + } else { + this.sdkInstance = new Anthropic({ + apiKey: this.apiKey, + baseURL: this.getBaseURL(), + dangerouslyAllowBrowser: true, + defaultHeaders: { + 'anthropic-beta': 'output-128k-2025-02-19', + ...this.provider.extra_headers + } + }) + } + return this.sdkInstance } + private buildClaudeCodeSystemMessage(system?: string | Array): string | Array { + const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.` + if (!system) { + return defaultClaudeCodeSystem + } + + if (typeof system === 'string') { + if (system.trim() === defaultClaudeCodeSystem) { + return system + } + return [ + { + type: 'text', + text: defaultClaudeCodeSystem + }, + { + type: 'text', + text: system + } + ] + } + + if (system[0].text.trim() != defaultClaudeCodeSystem) { + system.unshift({ + type: 'text', + text: defaultClaudeCodeSystem + }) + } + + return system + } + override async createCompletions( payload: AnthropicSdkParams, options?: Anthropic.RequestOptions ): Promise { + if (this.provider.authType === 'oauth') { + this.oauthToken = await window.api.anthropic_oauth.getAccessToken() + this.isOAuthMode = true + logger.info('[Anthropic Provider] Using OAuth token for authentication') + payload.system = this.buildClaudeCodeSystemMessage(payload.system) + } const sdk = (await this.getSdkInstance()) as Anthropic if (payload.stream) { return sdk.messages.stream(payload, options) @@ -124,8 +183,14 @@ export class AnthropicAPIClient extends BaseApiClient< } override async listModels(): Promise { + if (this.provider.authType === 'oauth') { + this.oauthToken = await window.api.anthropic_oauth.getAccessToken() + this.isOAuthMode = true + logger.info('[Anthropic Provider] Using OAuth token for authentication') + } const sdk = (await this.getSdkInstance()) as Anthropic const response = await sdk.models.list() + return response.data } @@ -194,7 +259,6 @@ export class AnthropicAPIClient extends BaseApiClient< /** * Get the message parameter * @param message - The message - * @param model - The model * @returns The message parameter */ public async convertMessageToSdkParam(message: Message): Promise { diff --git a/src/renderer/src/aiCore/provider/config/anthropic.ts b/src/renderer/src/aiCore/provider/config/anthropic.ts new file mode 100644 index 0000000000..5adce2cfb3 --- /dev/null +++ b/src/renderer/src/aiCore/provider/config/anthropic.ts @@ -0,0 +1,24 @@ +import { SystemModelMessage } from 'ai' + +export function buildClaudeCodeSystemMessage(system?: string): Array { + const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.` + if (!system || system.trim() === defaultClaudeCodeSystem) { + return [ + { + role: 'system', + content: defaultClaudeCodeSystem + } + ] + } + + return [ + { + role: 'system', + content: defaultClaudeCodeSystem + }, + { + role: 'system', + content: system + } + ] +} diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 63d1b6ed54..ddbb178f3f 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -240,27 +240,48 @@ export async function prepareSpecialProviderConfig( provider: Provider, config: ReturnType ) { - if (provider.id === 'copilot') { - const defaultHeaders = store.getState().copilot.defaultHeaders - const { token } = await window.api.copilot.getToken(defaultHeaders) - config.options.apiKey = token - } - if (provider.id === 'cherryin') { - config.options.fetch = async (url, options) => { - // 在这里对最终参数进行签名 - const signature = await window.api.cherryin.generateSignature({ - method: 'POST', - path: '/chat/completions', - query: '', - body: JSON.parse(options.body) - }) - return fetch(url, { - ...options, - headers: { - ...options.headers, - ...signature + switch (provider.id) { + case 'copilot': { + const defaultHeaders = store.getState().copilot.defaultHeaders + const { token } = await window.api.copilot.getToken(defaultHeaders) + config.options.apiKey = token + break + } + case 'cherryin': { + config.options.fetch = async (url, options) => { + // 在这里对最终参数进行签名 + const signature = await window.api.cherryin.generateSignature({ + method: 'POST', + path: '/chat/completions', + query: '', + body: JSON.parse(options.body) + }) + return fetch(url, { + ...options, + headers: { + ...options.headers, + ...signature + } + }) + } + break + } + case 'anthropic': { + if (provider.authType === 'oauth') { + const oauthToken = await window.api.anthropic_oauth.getAccessToken() + config.options = { + ...config.options, + headers: { + ...(config.options.headers ? config.options.headers : {}), + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'oauth-2025-04-20', + Authorization: `Bearer ${oauthToken}` + }, + baseURL: 'https://api.anthropic.com/v1', + apiKey: '' } - }) + } } } return config diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e9a1a5c576..1ac59c39a0 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3649,6 +3649,27 @@ "title": "Add Provider", "type": "Provider Type" }, + "anthropic": { + "apikey": "API key", + "auth_failed": "Anthropic authentication failed", + "auth_method": "Authentication method", + "auth_success": "Anthropic OAuth authentication successful", + "authenticated": "Verified", + "authenticating": "Authenticating", + "cancel": "Cancel", + "code_error": "Invalid authorization code, please try again", + "code_placeholder": "Please enter the authorization code displayed in your browser", + "code_required": "Authorization code cannot be empty", + "description": "OAuth authentication", + "description_detail": "You need to subscribe to Claude Pro or a higher version to use this authentication method", + "enter_auth_code": "Authorization code", + "logout": "Log out", + "logout_failed": "Logout failed, please try again", + "logout_success": "Successfully logged out of Anthropic", + "oauth": "Web OAuth", + "start_auth": "Start authorization", + "submit_code": "Complete login" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ea1ebf2452..c87fdc5634 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3649,6 +3649,27 @@ "title": "添加提供商", "type": "提供商类型" }, + "anthropic": { + "apikey": "API 密钥", + "auth_failed": "Anthropic 身份认证失败", + "auth_method": "认证方式", + "auth_success": "Anthropic OAuth 认证成功", + "authenticated": "已认证", + "authenticating": "正在认证", + "cancel": "取消", + "code_error": "无效的授权码,请重试", + "code_placeholder": "请输入浏览器中显示的授权码", + "code_required": "授权码不能为空", + "description": "OAuth 身份认证", + "description_detail": "你需要订阅 Claude Pro 或以上版本才能使用此认证方式", + "enter_auth_code": "授权码", + "logout": "退出登录", + "logout_failed": "退出登录失败,请重试", + "logout_success": "成功退出 Anthropic 登录", + "oauth": "网页 OAuth", + "start_auth": "开始授权", + "submit_code": "完成登录" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 31e9b1162c..061a62e069 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3649,6 +3649,27 @@ "title": "新增提供者", "type": "供應商類型" }, + "anthropic": { + "apikey": "API 密鑰", + "auth_failed": "Anthropic 身份驗證失敗", + "auth_method": "认证方式", + "auth_success": "Anthropic OAuth 認證成功", + "authenticated": "已認證", + "authenticating": "正在認證", + "cancel": "取消", + "code_error": "無效的授權碼,請重試", + "code_placeholder": "請輸入瀏覽器中顯示的授權碼", + "code_required": "授權碼不能為空", + "description": "OAuth 身份認證", + "description_detail": "您需要訂閱 Claude Pro 或更高版本才能使用此認證方式", + "enter_auth_code": "授權碼", + "logout": "登出", + "logout_failed": "登出失敗,請重試", + "logout_success": "成功登出 Anthropic", + "oauth": "網頁 OAuth", + "start_auth": "開始授權", + "submit_code": "完成登錄" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 10fd4c2e54..3b4c5052b5 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3649,6 +3649,23 @@ "title": "Προσθήκη παρόχου", "type": "Τύπος παρόχου" }, + "anthropic": { + "apikey": "Κλειδί API", + "auth_failed": "Αποτυχία πιστοποίησης ταυτότητας Anthropic", + "auth_method": "Τρόπος πιστοποίησης", + "authenticated": "Επαληθευμένο", + "authenticating": "Επαλήθευση σε εξέλιξη", + "cancel": "Ακύρωση", + "code_placeholder": "Παρακαλώ εισαγάγετε τον κωδικό εξουσιοδότησης που εμφανίζεται στον περιηγητή σας", + "code_required": "Ο κωδικός εξουσιοδότησης δεν μπορεί να είναι κενός", + "description": "Πιστοποίηση OAuth", + "description_detail": "Για να χρησιμοποιήσετε αυτόν τον τρόπο επαλήθευσης, πρέπει να είστε συνδρομητής Claude Pro ή έκδοσης μεγαλύτερης από αυτήν", + "enter_auth_code": "κωδικός εξουσιοδότησης", + "logout": "Αποσύνδεση λογαριασμού", + "oauth": "ιστοσελίδα OAuth", + "start_auth": "Έναρξη εξουσιοδότησης", + "submit_code": "Ολοκληρώστε την σύνδεση" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 1f51c13632..bf52e2f089 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3649,6 +3649,23 @@ "title": "Agregar proveedor", "type": "Tipo de proveedor" }, + "anthropic": { + "apikey": "Clave de API", + "auth_failed": "Error de autenticación de Anthropic", + "auth_method": "Método de autenticación", + "authenticated": "Verificado", + "authenticating": "Autenticando", + "cancel": "Cancelar", + "code_placeholder": "Introduzca el código de autorización que se muestra en el navegador", + "code_required": "El código de autorización no puede estar vacío", + "description": "Autenticación OAuth", + "description_detail": "Necesitas suscribirte a Claude Pro o a una versión superior para utilizar este método de autenticación", + "enter_auth_code": "Código de autorización", + "logout": "Cerrar sesión", + "oauth": "Web OAuth", + "start_auth": "Comenzar autorización", + "submit_code": "Iniciar sesión completado" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 9b4a314c53..b57d51cb2a 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3649,6 +3649,23 @@ "title": "Ajouter un fournisseur", "type": "Type de fournisseur" }, + "anthropic": { + "apikey": "Clé API", + "auth_failed": "Échec de l'authentification Anthropic", + "auth_method": "Mode d'authentification", + "authenticated": "Certifié", + "authenticating": "Authentification en cours", + "cancel": "Annuler", + "code_placeholder": "Veuillez saisir le code d'autorisation affiché dans le navigateur", + "code_required": "Le code d'autorisation ne peut pas être vide", + "description": "Authentification OAuth", + "description_detail": "Vous devez souscrire à Claude Pro ou à une version supérieure pour pouvoir utiliser cette méthode d'authentification.", + "enter_auth_code": "code d'autorisation", + "logout": "Déconnexion", + "oauth": "Authentification OAuth web", + "start_auth": "Commencer l'autorisation", + "submit_code": "Terminer la connexion" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 46a66b2c5e..b276740d62 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3649,6 +3649,27 @@ "title": "プロバイダーを追加", "type": "プロバイダータイプ" }, + "anthropic": { + "apikey": "API キー", + "auth_failed": "Anthropic 身份验证に失敗しました", + "auth_method": "認証方法", + "auth_success": "Anthropic OAuth 認証が成功しました", + "authenticated": "認証済み", + "authenticating": "認証中です", + "cancel": "取消", + "code_error": "無効な認証コードです。もう一度お試しください", + "code_placeholder": "ブラウザに表示されている認証コードを入力してください", + "code_required": "認証コードは空にできません", + "description": "OAuth 認証", + "description_detail": "Claude Pro 以上にサブスクライブする必要があります。この認証方法を使用するには。", + "enter_auth_code": "認証コード", + "logout": "ログアウト", + "logout_failed": "ログアウトに失敗しました。もう一度お試しください", + "logout_success": "Anthropic からログアウトしました", + "oauth": "WebページOAuth", + "start_auth": "開始承認", + "submit_code": "ログインを完了する" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 37bbb3b6cc..498cd9dea5 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3649,6 +3649,23 @@ "title": "Adicionar Fornecedor", "type": "Tipo de Fornecedor" }, + "anthropic": { + "apikey": "Chave da API", + "auth_failed": "Falha na autenticação da Anthropic", + "auth_method": "Método de autenticação", + "authenticated": "[retranslating]: Verificado", + "authenticating": "A autenticar", + "cancel": "Cancelar", + "code_placeholder": "Introduza o código de autorização exibido no browser", + "code_required": "O código de autorização não pode estar vazio", + "description": "Autenticação OAuth", + "description_detail": "Precisa de uma subscrição Claude Pro ou superior para utilizar este método de autenticação", + "enter_auth_code": "Código de autorização", + "logout": "Sair da sessão", + "oauth": "OAuth da Página Web", + "start_auth": "Iniciar autorização", + "submit_code": "Concluir login" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 6b5fb95cc5..0331b4bfd9 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3649,6 +3649,27 @@ "title": "Добавить провайдер", "type": "Тип провайдера" }, + "anthropic": { + "apikey": "API-ключ", + "auth_failed": "Ошибка аутентификации Anthropic", + "auth_method": "Способ аутентификации", + "auth_success": "Аутентификация Anthropic OAuth успешна", + "authenticated": "Подтверждено", + "authenticating": "Выполняется аутентификация", + "cancel": "Отмена", + "code_error": "Неверный код авторизации, попробуйте еще раз", + "code_placeholder": "Пожалуйста, введите код авторизации, отображаемый в браузере", + "code_required": "Код авторизации не может быть пустым", + "description": "OAuth аутентификация", + "description_detail": "Чтобы использовать этот способ аутентификации, вам необходимо подписаться на Claude Pro или более высокую версию.", + "enter_auth_code": "Код авторизации", + "logout": "Выйти", + "logout_failed": "Ошибка выхода, попробуйте еще раз", + "logout_success": "Успешно вышли из Anthropic", + "oauth": "веб OAuth", + "start_auth": "Начать авторизацию", + "submit_code": "Завершить вход" + }, "api": { "key": { "check": { diff --git a/src/renderer/src/pages/settings/ProviderSettings/AnthropicSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/AnthropicSettings.tsx new file mode 100644 index 0000000000..e625b06308 --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/AnthropicSettings.tsx @@ -0,0 +1,168 @@ +import { ExclamationCircleOutlined } from '@ant-design/icons' +import { loggerService } from '@logger' +import { Alert, Button, Input, Modal } from 'antd' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const logger = loggerService.withContext('AnthropicSettings') + +enum AuthStatus { + NOT_STARTED, + AUTHENTICATING, + AUTHENTICATED +} + +const AnthropicSettings = () => { + const { t } = useTranslation() + const [authStatus, setAuthStatus] = useState(AuthStatus.NOT_STARTED) + const [loading, setLoading] = useState(false) + const [codeModalVisible, setCodeModalVisible] = useState(false) + const [authCode, setAuthCode] = useState('') + + // 初始化检查认证状态 + useEffect(() => { + const checkAuthStatus = async () => { + try { + const hasCredentials = await window.api.anthropic_oauth.hasCredentials() + + if (hasCredentials) { + setAuthStatus(AuthStatus.AUTHENTICATED) + } + } catch (error) { + logger.error('Failed to check authentication status:', error as Error) + } + } + + checkAuthStatus() + }, []) + + // 处理OAuth重定向 + const handleRedirectOAuth = async () => { + try { + setLoading(true) + await window.api.anthropic_oauth.startOAuthFlow() + setAuthStatus(AuthStatus.AUTHENTICATING) + setCodeModalVisible(true) + } catch (error) { + logger.error('OAuth redirect failed:', error as Error) + window.message.error(t('settings.provider.anthropic.auth_failed')) + } finally { + setLoading(false) + } + } + + // 处理授权码提交 + const handleSubmitCode = async () => { + logger.info('Submitting auth code') + try { + setLoading(true) + await window.api.anthropic_oauth.completeOAuthWithCode(authCode) + setAuthStatus(AuthStatus.AUTHENTICATED) + setCodeModalVisible(false) + window.message.success(t('settings.provider.anthropic.auth_success')) + } catch (error) { + logger.error('Code submission failed:', error as Error) + window.message.error(t('settings.provider.anthropic.code_error')) + } finally { + setLoading(false) + } + } + + // 处理取消认证 + const handleCancelAuth = () => { + window.api.anthropic_oauth.cancelOAuthFlow() + setAuthStatus(AuthStatus.NOT_STARTED) + setCodeModalVisible(false) + setAuthCode('') + } + + // 处理登出 + const handleLogout = async () => { + try { + await window.api.anthropic_oauth.clearCredentials() + setAuthStatus(AuthStatus.NOT_STARTED) + window.message.success(t('settings.provider.anthropic.logout_success')) + } catch (error) { + logger.error('Logout failed:', error as Error) + window.message.error(t('settings.provider.anthropic.logout_failed')) + } + } + + // 渲染认证内容 + const renderAuthContent = () => { + switch (authStatus) { + case AuthStatus.AUTHENTICATED: + return ( + + + {t('settings.provider.anthropic.logout')} + + } + showIcon + icon={} + /> + + ) + case AuthStatus.AUTHENTICATING: + return ( + + } + /> + + setAuthCode(e.target.value)} + placeholder={t('settings.provider.anthropic.code_placeholder')} + /> + + + ) + default: + return ( + + + {t('settings.provider.anthropic.start_auth')} + + } + showIcon + icon={} + /> + + ) + } + } + + return {renderAuthContent()} +} + +const Container = styled.div` + padding-top: 10px; +` + +const StartContainer = styled.div` + margin-bottom: 10px; +` + +export default AnthropicSettings diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index e32b69f5e2..75f31ed12e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -8,6 +8,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' +import AnthropicSettings from '@renderer/pages/settings/ProviderSettings/AnthropicSettings' import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList' import { checkApi } from '@renderer/services/ApiService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' @@ -17,7 +18,7 @@ import { isSystemProvider } from '@renderer/types' import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck' import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' -import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' +import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { debounce, isEmpty } from 'lodash' import { Bolt, Check, Settings2, SquareArrowOutUpRight, TriangleAlert } from 'lucide-react' @@ -240,6 +241,8 @@ const ProviderSetting: FC = ({ providerId }) => { setApiHost(provider.apiHost) }, [provider.apiHost, provider.id]) + const isAnthropicOAuth = () => provider.id === 'anthropic' && provider.authType === 'oauth' + return ( @@ -276,7 +279,22 @@ const ProviderSetting: FC = ({ providerId }) => { {isProviderSupportAuth(provider) && } {provider.id === 'openai' && } {isDmxapi && } - {!hideApiInput && ( + {provider.id === 'anthropic' && ( + <> + {t('settings.provider.anthropic.auth_method')} +