From c3c577dff439533b09d0e0a935b6c4390d1a48ab Mon Sep 17 00:00:00 2001 From: suyao Date: Thu, 27 Nov 2025 01:15:22 +0800 Subject: [PATCH 01/15] feat: add Volcengine integration with settings and API client - Implement Volcengine configuration in multiple languages (el-gr, es-es, fr-fr, ja-jp, pt-pt, ru-ru). - Add Volcengine settings component to manage access key ID, secret access key, and region. - Create Volcengine service for API interactions, including credential management and model listing. - Extend OpenAI API client to support Volcengine's signed API for model retrieval. - Update Redux store to handle Volcengine settings and credentials. - Implement migration for Volcengine settings in the store. - Add hooks for accessing and managing Volcengine settings in the application. --- packages/shared/IpcChannel.ts | 10 +- src/main/ipc.ts | 9 + src/main/services/VolcengineService.ts | 711 ++++++++++++++++++ src/preload/index.ts | 31 + .../aiCore/legacy/clients/ApiClientFactory.ts | 7 + .../clients/volcengine/VolcengineAPIClient.ts | 61 ++ src/renderer/src/hooks/useVolcengine.ts | 31 + src/renderer/src/i18n/locales/en-us.json | 15 + src/renderer/src/i18n/locales/zh-cn.json | 15 + src/renderer/src/i18n/locales/zh-tw.json | 15 + src/renderer/src/i18n/translate/de-de.json | 15 + src/renderer/src/i18n/translate/el-gr.json | 15 + src/renderer/src/i18n/translate/es-es.json | 15 + src/renderer/src/i18n/translate/fr-fr.json | 15 + src/renderer/src/i18n/translate/ja-jp.json | 15 + src/renderer/src/i18n/translate/pt-pt.json | 15 + src/renderer/src/i18n/translate/ru-ru.json | 15 + .../ProviderSettings/ProviderSetting.tsx | 2 + .../ProviderSettings/VolcengineSettings.tsx | 139 ++++ .../src/services/__tests__/ApiService.test.ts | 5 + src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/llm.ts | 22 + src/renderer/src/store/migrate.ts | 13 + 23 files changed, 1191 insertions(+), 2 deletions(-) create mode 100644 src/main/services/VolcengineService.ts create mode 100644 src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts create mode 100644 src/renderer/src/hooks/useVolcengine.ts create mode 100644 src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 67bd137b8..e42227aec 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -374,5 +374,13 @@ export enum IpcChannel { WebSocket_Stop = 'webSocket:stop', WebSocket_Status = 'webSocket:status', WebSocket_SendFile = 'webSocket:send-file', - WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' + WebSocket_GetAllCandidates = 'webSocket:get-all-candidates', + + // Volcengine + Volcengine_SaveCredentials = 'volcengine:save-credentials', + Volcengine_HasCredentials = 'volcengine:has-credentials', + Volcengine_ClearCredentials = 'volcengine:clear-credentials', + Volcengine_ListModels = 'volcengine:list-models', + Volcengine_GetAuthHeaders = 'volcengine:get-auth-headers', + Volcengine_MakeRequest = 'volcengine:make-request' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e537b8526..c7ffddf14 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -73,6 +73,7 @@ import { import storeSyncService from './services/StoreSyncService' import { themeService } from './services/ThemeService' import VertexAIService from './services/VertexAIService' +import VolcengineService from './services/VolcengineService' import WebSocketService from './services/WebSocketService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' @@ -1077,6 +1078,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) + // Volcengine + ipcMain.handle(IpcChannel.Volcengine_SaveCredentials, VolcengineService.saveCredentials) + ipcMain.handle(IpcChannel.Volcengine_HasCredentials, VolcengineService.hasCredentials) + ipcMain.handle(IpcChannel.Volcengine_ClearCredentials, VolcengineService.clearCredentials) + ipcMain.handle(IpcChannel.Volcengine_ListModels, VolcengineService.listModels) + ipcMain.handle(IpcChannel.Volcengine_GetAuthHeaders, VolcengineService.getAuthHeaders) + ipcMain.handle(IpcChannel.Volcengine_MakeRequest, VolcengineService.makeRequest) + ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => { mainWindow.webContents.forcefullyCrashRenderer() }) diff --git a/src/main/services/VolcengineService.ts b/src/main/services/VolcengineService.ts new file mode 100644 index 000000000..f14cbc9ee --- /dev/null +++ b/src/main/services/VolcengineService.ts @@ -0,0 +1,711 @@ +import { loggerService } from '@logger' +import crypto from 'crypto' +import { app, net, safeStorage } from 'electron' +import fs from 'fs' +import path from 'path' +import * as z from 'zod' + +import { getConfigDir } from '../utils/file' + +const logger = loggerService.withContext('VolcengineService') + +// Configuration constants +const CONFIG = { + ALGORITHM: 'HMAC-SHA256', + REQUEST_TYPE: 'request', + DEFAULT_REGION: 'cn-beijing', + SERVICE_NAME: 'ark', + DEFAULT_HEADERS: { + 'content-type': 'application/json', + accept: 'application/json' + }, + API_URLS: { + ARK_HOST: 'open.volcengineapi.com' + }, + CREDENTIALS_FILE_NAME: '.volcengine_credentials', + API_VERSION: '2024-01-01', + DEFAULT_PAGE_SIZE: 100 +} as const + +// Request schemas +const ListFoundationModelsRequestSchema = z.object({ + PageNumber: z.optional(z.number()), + PageSize: z.optional(z.number()) +}) + +const ListEndpointsRequestSchema = z.object({ + PageNumber: z.optional(z.number()), + PageSize: z.optional(z.number()) +}) + +// Response schemas - only keep fields needed for model list +const FoundationModelItemSchema = z.object({ + Name: z.string(), + DisplayName: z.optional(z.string()), + Description: z.optional(z.string()) +}) + +const EndpointItemSchema = z.object({ + Id: z.string(), + Name: z.optional(z.string()), + Description: z.optional(z.string()), + ModelReference: z.optional( + z.object({ + FoundationModel: z.optional( + z.object({ + Name: z.optional(z.string()), + ModelVersion: z.optional(z.string()) + }) + ), + CustomModelId: z.optional(z.string()) + }) + ) +}) + +const ListFoundationModelsResponseSchema = z.object({ + Result: z.object({ + TotalCount: z.number(), + Items: z.array(FoundationModelItemSchema) + }) +}) + +const ListEndpointsResponseSchema = z.object({ + Result: z.object({ + TotalCount: z.number(), + Items: z.array(EndpointItemSchema) + }) +}) + +// Infer types from schemas +type ListFoundationModelsRequest = z.infer +type ListEndpointsRequest = z.infer +type ListFoundationModelsResponse = z.infer +type ListEndpointsResponse = z.infer + +// ============= Internal Type Definitions ============= + +interface VolcengineCredentials { + accessKeyId: string + secretAccessKey: string +} + +interface SignedRequestParams { + method: 'GET' | 'POST' + host: string + path: string + query: Record + headers: Record + body?: string + service: string + region: string +} + +interface SignedHeaders { + Authorization: string + 'X-Date': string + 'X-Content-Sha256': string + Host: string +} + +interface ModelInfo { + id: string + name: string + description?: string + created?: number +} + +interface ListModelsResult { + models: ModelInfo[] + total?: number +} + +// Custom error class +class VolcengineServiceError extends Error { + constructor( + message: string, + public readonly cause?: unknown + ) { + super(message) + this.name = 'VolcengineServiceError' + } +} + +/** + * Volcengine API Signing Service + * + * Implements HMAC-SHA256 signing algorithm for Volcengine API authentication. + * Securely stores credentials using Electron's safeStorage. + */ +class VolcengineService { + private readonly credentialsFilePath: string + + constructor() { + this.credentialsFilePath = this.getCredentialsFilePath() + } + + /** + * Get the path for storing encrypted credentials + */ + private getCredentialsFilePath(): string { + const oldPath = path.join(app.getPath('userData'), CONFIG.CREDENTIALS_FILE_NAME) + if (fs.existsSync(oldPath)) { + return oldPath + } + return path.join(getConfigDir(), CONFIG.CREDENTIALS_FILE_NAME) + } + + // ============= Cryptographic Helper Methods ============= + + /** + * Calculate SHA256 hash of data and return hex encoded string + */ + private sha256Hash(data: string | Buffer): string { + return crypto.createHash('sha256').update(data).digest('hex') + } + + /** + * Calculate HMAC-SHA256 and return buffer + */ + private hmacSha256(key: Buffer | string, data: string): Buffer { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest() + } + + /** + * Calculate HMAC-SHA256 and return hex encoded string + */ + private hmacSha256Hex(key: Buffer | string, data: string): string { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex') + } + + /** + * URL encode according to RFC3986 + */ + private uriEncode(str: string, encodeSlash: boolean = true): string { + if (!str) return '' + + return str + .split('') + .map((char) => { + if ( + (char >= 'A' && char <= 'Z') || + (char >= 'a' && char <= 'z') || + (char >= '0' && char <= '9') || + char === '_' || + char === '-' || + char === '~' || + char === '.' + ) { + return char + } + if (char === '/' && !encodeSlash) { + return char + } + return encodeURIComponent(char) + }) + .join('') + } + + // ============= Signing Implementation ============= + + /** + * Get current UTC time in ISO8601 format (YYYYMMDD'T'HHMMSS'Z') + */ + private getIso8601DateTime(): string { + const now = new Date() + return now + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, '') + } + + /** + * Get date portion from datetime (YYYYMMDD) + */ + private getDateFromDateTime(dateTime: string): string { + return dateTime.substring(0, 8) + } + + /** + * Build canonical query string from query parameters + */ + private buildCanonicalQueryString(query: Record): string { + if (!query || Object.keys(query).length === 0) { + return '' + } + + return Object.keys(query) + .sort() + .map((key) => `${this.uriEncode(key)}=${this.uriEncode(query[key])}`) + .join('&') + } + + /** + * Build canonical headers string + */ + private buildCanonicalHeaders(headers: Record): { + canonicalHeaders: string + signedHeaders: string + } { + const sortedKeys = Object.keys(headers) + .map((k) => k.toLowerCase()) + .sort() + + const canonicalHeaders = sortedKeys.map((key) => `${key}:${headers[key]?.trim() || ''}`).join('\n') + '\n' + + const signedHeaders = sortedKeys.join(';') + + return { canonicalHeaders, signedHeaders } + } + + /** + * Create the signing key through a series of HMAC operations + * + * kSecret = SecretAccessKey + * kDate = HMAC(kSecret, Date) + * kRegion = HMAC(kDate, Region) + * kService = HMAC(kRegion, Service) + * kSigning = HMAC(kService, "request") + */ + private deriveSigningKey(secretKey: string, date: string, region: string, service: string): Buffer { + const kDate = this.hmacSha256(secretKey, date) + const kRegion = this.hmacSha256(kDate, region) + const kService = this.hmacSha256(kRegion, service) + const kSigning = this.hmacSha256(kService, CONFIG.REQUEST_TYPE) + return kSigning + } + + /** + * Create canonical request string + * + * CanonicalRequest = + * HTTPRequestMethod + '\n' + + * CanonicalURI + '\n' + + * CanonicalQueryString + '\n' + + * CanonicalHeaders + '\n' + + * SignedHeaders + '\n' + + * HexEncode(Hash(RequestPayload)) + */ + private createCanonicalRequest( + method: string, + canonicalUri: string, + canonicalQueryString: string, + canonicalHeaders: string, + signedHeaders: string, + payloadHash: string + ): string { + return [method, canonicalUri, canonicalQueryString, canonicalHeaders, signedHeaders, payloadHash].join('\n') + } + + /** + * Create string to sign + * + * StringToSign = + * Algorithm + '\n' + + * RequestDateTime + '\n' + + * CredentialScope + '\n' + + * HexEncode(Hash(CanonicalRequest)) + */ + private createStringToSign(dateTime: string, credentialScope: string, canonicalRequest: string): string { + const hashedCanonicalRequest = this.sha256Hash(canonicalRequest) + return [CONFIG.ALGORITHM, dateTime, credentialScope, hashedCanonicalRequest].join('\n') + } + + /** + * Generate signature for the request + */ + private generateSignature(params: SignedRequestParams, credentials: VolcengineCredentials): SignedHeaders { + const { method, host, path: requestPath, query, body, service, region } = params + + // Step 1: Prepare datetime + const dateTime = this.getIso8601DateTime() + const date = this.getDateFromDateTime(dateTime) + + // Step 2: Calculate payload hash + const payloadHash = this.sha256Hash(body || '') + + // Step 3: Prepare headers for signing + const headersToSign: Record = { + host: host, + 'x-date': dateTime, + 'x-content-sha256': payloadHash, + 'content-type': 'application/json' + } + + // Step 4: Build canonical components + const canonicalUri = this.uriEncode(requestPath, false) || '/' + const canonicalQueryString = this.buildCanonicalQueryString(query) + const { canonicalHeaders, signedHeaders } = this.buildCanonicalHeaders(headersToSign) + + // Step 5: Create canonical request + const canonicalRequest = this.createCanonicalRequest( + method.toUpperCase(), + canonicalUri, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + payloadHash + ) + + // Step 6: Create credential scope and string to sign + const credentialScope = `${date}/${region}/${service}/${CONFIG.REQUEST_TYPE}` + const stringToSign = this.createStringToSign(dateTime, credentialScope, canonicalRequest) + + // Step 7: Calculate signature + const signingKey = this.deriveSigningKey(credentials.secretAccessKey, date, region, service) + const signature = this.hmacSha256Hex(signingKey, stringToSign) + + // Step 8: Build authorization header + const authorization = `${CONFIG.ALGORITHM} Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` + + return { + Authorization: authorization, + 'X-Date': dateTime, + 'X-Content-Sha256': payloadHash, + Host: host + } + } + + // ============= Credential Management ============= + + /** + * Save credentials securely using Electron's safeStorage + */ + public saveCredentials = async ( + _: Electron.IpcMainInvokeEvent, + accessKeyId: string, + secretAccessKey: string + ): Promise => { + try { + if (!accessKeyId || !secretAccessKey) { + throw new VolcengineServiceError('Access Key ID and Secret Access Key are required') + } + + const credentials: VolcengineCredentials = { accessKeyId, secretAccessKey } + const credentialsJson = JSON.stringify(credentials) + const encryptedData = safeStorage.encryptString(credentialsJson) + + // Ensure directory exists + const dir = path.dirname(this.credentialsFilePath) + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }) + } + + await fs.promises.writeFile(this.credentialsFilePath, encryptedData) + logger.info('Volcengine credentials saved successfully') + } catch (error) { + logger.error('Failed to save Volcengine credentials:', error as Error) + throw new VolcengineServiceError('Failed to save credentials', error) + } + } + + /** + * Load credentials from encrypted storage + */ + private async loadCredentials(): Promise { + try { + if (!fs.existsSync(this.credentialsFilePath)) { + return null + } + + const encryptedData = await fs.promises.readFile(this.credentialsFilePath) + const decryptedJson = safeStorage.decryptString(Buffer.from(encryptedData)) + return JSON.parse(decryptedJson) as VolcengineCredentials + } catch (error) { + logger.error('Failed to load Volcengine credentials:', error as Error) + return null + } + } + + /** + * Check if credentials exist + */ + public hasCredentials = async (): Promise => { + return fs.existsSync(this.credentialsFilePath) + } + + /** + * Clear stored credentials + */ + public clearCredentials = async (): Promise => { + try { + if (fs.existsSync(this.credentialsFilePath)) { + await fs.promises.unlink(this.credentialsFilePath) + logger.info('Volcengine credentials cleared') + } + } catch (error) { + logger.error('Failed to clear Volcengine credentials:', error as Error) + throw new VolcengineServiceError('Failed to clear credentials', error) + } + } + + // ============= API Methods ============= + + /** + * Make a signed request to Volcengine API + */ + private async makeSignedRequest( + method: 'GET' | 'POST', + host: string, + path: string, + action: string, + version: string, + query?: Record, + body?: Record, + service: string = CONFIG.SERVICE_NAME, + region: string = CONFIG.DEFAULT_REGION + ): Promise { + const credentials = await this.loadCredentials() + if (!credentials) { + throw new VolcengineServiceError('No credentials found. Please save credentials first.') + } + + const fullQuery: Record = { + Action: action, + Version: version, + ...query + } + + const bodyString = body ? JSON.stringify(body) : '' + + const signedHeaders = this.generateSignature( + { + method, + host, + path, + query: fullQuery, + headers: {}, + body: bodyString, + service, + region + }, + credentials + ) + + // Build URL with query string (use simple encoding for URL, canonical encoding is only for signature) + const urlParams = new URLSearchParams(fullQuery) + const url = `https://${host}${path}?${urlParams.toString()}` + + const requestHeaders: Record = { + ...CONFIG.DEFAULT_HEADERS, + Authorization: signedHeaders.Authorization, + 'X-Date': signedHeaders['X-Date'], + 'X-Content-Sha256': signedHeaders['X-Content-Sha256'] + } + + logger.debug('Making Volcengine API request', { url, method, action }) + + try { + const response = await net.fetch(url, { + method, + headers: requestHeaders, + body: method === 'POST' && bodyString ? bodyString : undefined + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`Volcengine API error: ${response.status}`, { errorText }) + throw new VolcengineServiceError(`API request failed: ${response.status} - ${errorText}`) + } + + return (await response.json()) as T + } catch (error) { + if (error instanceof VolcengineServiceError) { + throw error + } + logger.error('Volcengine API request failed:', error as Error) + throw new VolcengineServiceError('API request failed', error) + } + } + + /** + * List foundation models from Volcengine ARK + */ + private async listFoundationModels(): Promise { + const requestBody: ListFoundationModelsRequest = { + PageNumber: 1, + PageSize: CONFIG.DEFAULT_PAGE_SIZE + } + + const response = await this.makeSignedRequest( + 'POST', + CONFIG.API_URLS.ARK_HOST, + '/', + 'ListFoundationModels', + CONFIG.API_VERSION, + {}, + requestBody, + CONFIG.SERVICE_NAME, + CONFIG.DEFAULT_REGION + ) + + return ListFoundationModelsResponseSchema.parse(response) + } + + /** + * List user-created endpoints from Volcengine ARK + */ + private async listEndpoints(): Promise { + const requestBody: ListEndpointsRequest = { + PageNumber: 1, + PageSize: CONFIG.DEFAULT_PAGE_SIZE + } + + const response = await this.makeSignedRequest( + 'POST', + CONFIG.API_URLS.ARK_HOST, + '/', + 'ListEndpoints', + CONFIG.API_VERSION, + {}, + requestBody, + CONFIG.SERVICE_NAME, + CONFIG.DEFAULT_REGION + ) + + return ListEndpointsResponseSchema.parse(response) + } + + /** + * List all available models from Volcengine ARK + * Combines foundation models and user-created endpoints + */ + public listModels = async (): Promise => { + try { + const [foundationModelsResult, endpointsResult] = await Promise.allSettled([ + this.listFoundationModels(), + this.listEndpoints() + ]) + + const models: ModelInfo[] = [] + + if (foundationModelsResult.status === 'fulfilled') { + const foundationModels = foundationModelsResult.value + for (const item of foundationModels.Result.Items) { + models.push({ + id: item.Name, + name: item.DisplayName || item.Name, + description: item.Description + }) + } + logger.info(`Found ${foundationModels.Result.Items.length} foundation models`) + } else { + logger.warn('Failed to fetch foundation models:', foundationModelsResult.reason) + } + + // Process endpoints + if (endpointsResult.status === 'fulfilled') { + const endpoints = endpointsResult.value + for (const item of endpoints.Result.Items) { + const modelRef = item.ModelReference + const foundationModelName = modelRef?.FoundationModel?.Name + const modelVersion = modelRef?.FoundationModel?.ModelVersion + const customModelId = modelRef?.CustomModelId + + let displayName = item.Name || item.Id + if (foundationModelName) { + displayName = modelVersion ? `${foundationModelName} (${modelVersion})` : foundationModelName + } else if (customModelId) { + displayName = customModelId + } + + models.push({ + id: item.Id, + name: displayName, + description: item.Description + }) + } + logger.info(`Found ${endpoints.Result.Items.length} endpoints`) + } else { + logger.warn('Failed to fetch endpoints:', endpointsResult.reason) + } + + // If both failed, throw error + if (foundationModelsResult.status === 'rejected' && endpointsResult.status === 'rejected') { + throw new VolcengineServiceError('Failed to fetch both foundation models and endpoints') + } + + const total = + (foundationModelsResult.status === 'fulfilled' ? foundationModelsResult.value.Result.TotalCount : 0) + + (endpointsResult.status === 'fulfilled' ? endpointsResult.value.Result.TotalCount : 0) + + logger.info(`Total models found: ${models.length}`) + + return { + models, + total + } + } catch (error) { + logger.error('Failed to list Volcengine models:', error as Error) + throw new VolcengineServiceError('Failed to list models', error) + } + } + + /** + * Get authorization headers for external use + * This allows the renderer process to make direct API calls with proper authentication + */ + public getAuthHeaders = async ( + _: Electron.IpcMainInvokeEvent, + params: { + method: 'GET' | 'POST' + host: string + path: string + query?: Record + body?: string + service?: string + region?: string + } + ): Promise => { + const credentials = await this.loadCredentials() + if (!credentials) { + throw new VolcengineServiceError('No credentials found. Please save credentials first.') + } + + return this.generateSignature( + { + method: params.method, + host: params.host, + path: params.path, + query: params.query || {}, + headers: {}, + body: params.body, + service: params.service || CONFIG.SERVICE_NAME, + region: params.region || CONFIG.DEFAULT_REGION + }, + credentials + ) + } + + /** + * Make a generic signed API request + * This is a more flexible method that allows custom API calls + */ + public makeRequest = async ( + _: Electron.IpcMainInvokeEvent, + params: { + method: 'GET' | 'POST' + host: string + path: string + action: string + version: string + query?: Record + body?: Record + service?: string + region?: string + } + ): Promise => { + return this.makeSignedRequest( + params.method, + params.host, + params.path, + params.action, + params.version, + params.query, + params.body, + params.service || CONFIG.SERVICE_NAME, + params.region || CONFIG.DEFAULT_REGION + ) + } +} + +export default new VolcengineService() diff --git a/src/preload/index.ts b/src/preload/index.ts index 92f44075a..c34181e8f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -572,6 +572,37 @@ const api = { status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status), sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath), getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates) + }, + volcengine: { + saveCredentials: (accessKeyId: string, secretAccessKey: string): Promise => + ipcRenderer.invoke(IpcChannel.Volcengine_SaveCredentials, accessKeyId, secretAccessKey), + hasCredentials: (): Promise => ipcRenderer.invoke(IpcChannel.Volcengine_HasCredentials), + clearCredentials: (): Promise => ipcRenderer.invoke(IpcChannel.Volcengine_ClearCredentials), + listModels: (): Promise<{ + models: Array<{ id: string; name: string; description?: string; created?: number }> + total?: number + }> => ipcRenderer.invoke(IpcChannel.Volcengine_ListModels), + getAuthHeaders: (params: { + method: 'GET' | 'POST' + host: string + path: string + query?: Record + body?: string + service?: string + region?: string + }): Promise<{ Authorization: string; 'X-Date': string; 'X-Content-Sha256': string; Host: string }> => + ipcRenderer.invoke(IpcChannel.Volcengine_GetAuthHeaders, params), + makeRequest: (params: { + method: 'GET' | 'POST' + host: string + path: string + action: string + version: string + query?: Record + body?: Record + service?: string + region?: string + }): Promise => ipcRenderer.invoke(IpcChannel.Volcengine_MakeRequest, params) } } diff --git a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts index ee878f586..03409e8f7 100644 --- a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts @@ -14,6 +14,7 @@ import { OpenAIAPIClient } from './openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' import { OVMSClient } from './ovms/OVMSClient' import { PPIOAPIClient } from './ppio/PPIOAPIClient' +import { VolcengineAPIClient } from './volcengine/VolcengineAPIClient' import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient' const logger = loggerService.withContext('ApiClientFactory') @@ -64,6 +65,12 @@ export class ApiClientFactory { return instance } + if (provider.id === 'doubao') { + logger.debug(`Creating VolcengineAPIClient for provider: ${provider.id}`) + instance = new VolcengineAPIClient(provider) as BaseApiClient + return instance + } + if (provider.id === 'ovms') { logger.debug(`Creating OVMSClient for provider: ${provider.id}`) instance = new OVMSClient(provider) as BaseApiClient diff --git a/src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts new file mode 100644 index 000000000..ac7aa8865 --- /dev/null +++ b/src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts @@ -0,0 +1,61 @@ +import type OpenAI from '@cherrystudio/openai' +import { loggerService } from '@logger' +import type { Provider } from '@renderer/types' + +import { OpenAIAPIClient } from '../openai/OpenAIApiClient' + +const logger = loggerService.withContext('VolcengineAPIClient') + +/** + * Volcengine (Doubao) API Client + * + * Extends OpenAIAPIClient for standard chat completions (OpenAI-compatible), + * but overrides listModels to use Volcengine's signed API via IPC. + */ +export class VolcengineAPIClient extends OpenAIAPIClient { + constructor(provider: Provider) { + super(provider) + } + + /** + * List models using Volcengine's signed API + * This calls the main process VolcengineService which handles HMAC-SHA256 signing + */ + override async listModels(): Promise { + try { + const hasCredentials = await window.api.volcengine.hasCredentials() + + if (!hasCredentials) { + logger.info('Volcengine credentials not configured, falling back to OpenAI-compatible list') + // Fall back to standard OpenAI-compatible API if no Volcengine credentials + return super.listModels() + } + + logger.info('Fetching models from Volcengine API using signed request') + + const response = await window.api.volcengine.listModels() + + if (!response || !response.models) { + logger.warn('Empty response from Volcengine listModels') + return [] + } + + const models: OpenAI.Models.Model[] = response.models.map((model) => ({ + id: model.id, + object: 'model' as const, + created: model.created || Math.floor(Date.now() / 1000), + owned_by: 'volcengine', + // @ts-ignore - description is used by UI to display model name + name: model.name || model.id + })) + + logger.info(`Found ${models.length} models from Volcengine API`) + return models + } catch (error) { + logger.error('Failed to list Volcengine models:', error as Error) + // Fall back to standard OpenAI-compatible API on error + logger.info('Falling back to OpenAI-compatible model list') + return super.listModels() + } + } +} diff --git a/src/renderer/src/hooks/useVolcengine.ts b/src/renderer/src/hooks/useVolcengine.ts new file mode 100644 index 000000000..148f984cf --- /dev/null +++ b/src/renderer/src/hooks/useVolcengine.ts @@ -0,0 +1,31 @@ +import store, { useAppSelector } from '@renderer/store' +import { setVolcengineAccessKeyId, setVolcengineRegion, setVolcengineSecretAccessKey } from '@renderer/store/llm' +import { useDispatch } from 'react-redux' + +export function useVolcengineSettings() { + const settings = useAppSelector((state) => state.llm.settings.volcengine) + const dispatch = useDispatch() + + return { + ...settings, + setAccessKeyId: (accessKeyId: string) => dispatch(setVolcengineAccessKeyId(accessKeyId)), + setSecretAccessKey: (secretAccessKey: string) => dispatch(setVolcengineSecretAccessKey(secretAccessKey)), + setRegion: (region: string) => dispatch(setVolcengineRegion(region)) + } +} + +export function getVolcengineSettings() { + return store.getState().llm.settings.volcengine +} + +export function getVolcengineAccessKeyId() { + return store.getState().llm.settings.volcengine.accessKeyId +} + +export function getVolcengineSecretAccessKey() { + return store.getState().llm.settings.volcengine.secretAccessKey +} + +export function getVolcengineRegion() { + return store.getState().llm.settings.volcengine.region +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a57c549c7..7fa4d5ded 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "Enter Service Account private key", "title": "Service Account Configuration" } + }, + "volcengine": { + "access_key_id": "Access Key ID", + "access_key_id_help": "Your Volcengine Access Key ID", + "clear_credentials": "Clear Credentials", + "credentials_cleared": "Credentials cleared", + "credentials_required": "Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "Credentials saved", + "description": "Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Please use IAM user's Access Key for authentication, do not use the root user credentials.", + "region": "Region", + "region_help": "Service region, e.g., cn-beijing", + "save_credentials": "Save Credentials", + "secret_access_key": "Secret Access Key", + "secret_access_key_help": "Your Volcengine Secret Access Key, please keep it secure", + "title": "Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8f1d81aab..d44819841 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "请输入 Service Account 私钥", "title": "Service Account 配置" } + }, + "volcengine": { + "access_key_id": "Access Key ID", + "access_key_id_help": "您的火山引擎 Access Key ID", + "clear_credentials": "清除凭证", + "credentials_cleared": "凭证已清除", + "credentials_required": "请填写 Access Key ID 和 Secret Access Key", + "credentials_saved": "凭证已保存", + "description": "火山引擎是字节跳动旗下的云服务平台,提供豆包等大语言模型服务。请使用 IAM 子用户的 Access Key 进行身份验证,不要使用主账号的根用户密钥。", + "region": "地域", + "region_help": "服务地域,例如 cn-beijing", + "save_credentials": "保存凭证", + "secret_access_key": "Secret Access Key", + "secret_access_key_help": "您的火山引擎 Secret Access Key,请妥善保管", + "title": "火山引擎配置" } }, "proxy": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index acda928d3..c057e9d0b 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "輸入服務帳戶私密金鑰", "title": "服務帳戶設定" } + }, + "volcengine": { + "access_key_id": "Access Key ID", + "access_key_id_help": "您的火山引擎 Access Key ID", + "clear_credentials": "清除憑證", + "credentials_cleared": "憑證已清除", + "credentials_required": "請填寫 Access Key ID 和 Secret Access Key", + "credentials_saved": "憑證已儲存", + "description": "火山引擎是字節跳動旗下的雲端服務平台,提供豆包等大型語言模型服務。請使用 IAM 子用戶的 Access Key 進行身份驗證,不要使用主帳號的根用戶密鑰。", + "region": "地區", + "region_help": "服務地區,例如 cn-beijing", + "save_credentials": "儲存憑證", + "secret_access_key": "Secret Access Key", + "secret_access_key_help": "您的火山引擎 Secret Access Key,請妥善保管", + "title": "火山引擎設定" } }, "proxy": { diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 94c338ba3..dde8b7fa6 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "Service Account-Privat-Schlüssel eingeben", "title": "Service Account-Konfiguration" } + }, + "volcengine": { + "access_key_id": "[to be translated]:Access Key ID", + "access_key_id_help": "[to be translated]:Your Volcengine Access Key ID", + "clear_credentials": "[to be translated]:Clear Credentials", + "credentials_cleared": "[to be translated]:Credentials cleared", + "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "[to be translated]:Credentials saved", + "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "region": "[to be translated]:Region", + "region_help": "[to be translated]:Service region, e.g., cn-beijing", + "save_credentials": "[to be translated]:Save Credentials", + "secret_access_key": "[to be translated]:Secret Access Key", + "secret_access_key_help": "[to be translated]:Your Volcengine Secret Access Key, please keep it secure", + "title": "[to be translated]:Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index b13975d21..3b6d231d5 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "Παρακαλώ εισάγετε το ιδιωτικό κλειδί του λογαριασμού υπηρεσίας", "title": "Διαμόρφωση λογαριασμού υπηρεσίας" } + }, + "volcengine": { + "access_key_id": "[to be translated]:Access Key ID", + "access_key_id_help": "[to be translated]:Your Volcengine Access Key ID", + "clear_credentials": "[to be translated]:Clear Credentials", + "credentials_cleared": "[to be translated]:Credentials cleared", + "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "[to be translated]:Credentials saved", + "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "region": "[to be translated]:Region", + "region_help": "[to be translated]:Service region, e.g., cn-beijing", + "save_credentials": "[to be translated]:Save Credentials", + "secret_access_key": "[to be translated]:Secret Access Key", + "secret_access_key_help": "[to be translated]:Your Volcengine Secret Access Key, please keep it secure", + "title": "[to be translated]:Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 08b90da9a..cf2d94e99 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "Ingrese la clave privada de Service Account", "title": "Configuración de Service Account" } + }, + "volcengine": { + "access_key_id": "[to be translated]:Access Key ID", + "access_key_id_help": "[to be translated]:Your Volcengine Access Key ID", + "clear_credentials": "[to be translated]:Clear Credentials", + "credentials_cleared": "[to be translated]:Credentials cleared", + "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "[to be translated]:Credentials saved", + "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "region": "[to be translated]:Region", + "region_help": "[to be translated]:Service region, e.g., cn-beijing", + "save_credentials": "[to be translated]:Save Credentials", + "secret_access_key": "[to be translated]:Secret Access Key", + "secret_access_key_help": "[to be translated]:Your Volcengine Secret Access Key, please keep it secure", + "title": "[to be translated]:Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 9a744c2d5..44c6ede29 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "Veuillez saisir la clé privée du compte de service", "title": "Configuration du compte de service" } + }, + "volcengine": { + "access_key_id": "[to be translated]:Access Key ID", + "access_key_id_help": "[to be translated]:Your Volcengine Access Key ID", + "clear_credentials": "[to be translated]:Clear Credentials", + "credentials_cleared": "[to be translated]:Credentials cleared", + "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "[to be translated]:Credentials saved", + "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "region": "[to be translated]:Region", + "region_help": "[to be translated]:Service region, e.g., cn-beijing", + "save_credentials": "[to be translated]:Save Credentials", + "secret_access_key": "[to be translated]:Secret Access Key", + "secret_access_key_help": "[to be translated]:Your Volcengine Secret Access Key, please keep it secure", + "title": "[to be translated]:Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 98c571adb..c8c758db6 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "サービスアカウントの秘密鍵を入力してください", "title": "サービスアカウント設定" } + }, + "volcengine": { + "access_key_id": "[to be translated]:Access Key ID", + "access_key_id_help": "[to be translated]:Your Volcengine Access Key ID", + "clear_credentials": "[to be translated]:Clear Credentials", + "credentials_cleared": "[to be translated]:Credentials cleared", + "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "[to be translated]:Credentials saved", + "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "region": "[to be translated]:Region", + "region_help": "[to be translated]:Service region, e.g., cn-beijing", + "save_credentials": "[to be translated]:Save Credentials", + "secret_access_key": "[to be translated]:Secret Access Key", + "secret_access_key_help": "[to be translated]:Your Volcengine Secret Access Key, please keep it secure", + "title": "[to be translated]:Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index ae993eaf1..25d0f531b 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "Por favor, insira a chave privada da Conta de Serviço", "title": "Configuração da Conta de Serviço" } + }, + "volcengine": { + "access_key_id": "[to be translated]:Access Key ID", + "access_key_id_help": "[to be translated]:Your Volcengine Access Key ID", + "clear_credentials": "[to be translated]:Clear Credentials", + "credentials_cleared": "[to be translated]:Credentials cleared", + "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "[to be translated]:Credentials saved", + "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "region": "[to be translated]:Region", + "region_help": "[to be translated]:Service region, e.g., cn-beijing", + "save_credentials": "[to be translated]:Save Credentials", + "secret_access_key": "[to be translated]:Secret Access Key", + "secret_access_key_help": "[to be translated]:Your Volcengine Secret Access Key, please keep it secure", + "title": "[to be translated]:Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 931dcb317..7ceed795f 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4510,6 +4510,21 @@ "private_key_placeholder": "Введите приватный ключ Service Account", "title": "Конфигурация Service Account" } + }, + "volcengine": { + "access_key_id": "[to be translated]:Access Key ID", + "access_key_id_help": "[to be translated]:Your Volcengine Access Key ID", + "clear_credentials": "[to be translated]:Clear Credentials", + "credentials_cleared": "[to be translated]:Credentials cleared", + "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", + "credentials_saved": "[to be translated]:Credentials saved", + "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "region": "[to be translated]:Region", + "region_help": "[to be translated]:Service region, e.g., cn-beijing", + "save_credentials": "[to be translated]:Save Credentials", + "secret_access_key": "[to be translated]:Secret Access Key", + "secret_access_key_help": "[to be translated]:Your Volcengine Secret Access Key, please keep it secure", + "title": "[to be translated]:Volcengine Configuration" } }, "proxy": { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index f341ac922..365c15ff1 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -67,6 +67,7 @@ import OVMSSettings from './OVMSSettings' import ProviderOAuth from './ProviderOAuth' import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' +import VolcengineSettings from './VolcengineSettings' interface Props { providerId: string @@ -593,6 +594,7 @@ const ProviderSetting: FC = ({ providerId }) => { {provider.id === 'copilot' && } {provider.id === 'aws-bedrock' && } {provider.id === 'vertexai' && } + {provider.id === 'doubao' && } ) diff --git a/src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx new file mode 100644 index 000000000..4d4692a94 --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx @@ -0,0 +1,139 @@ +import { HStack } from '@renderer/components/Layout' +import { PROVIDER_URLS } from '@renderer/config/providers' +import { useVolcengineSettings } from '@renderer/hooks/useVolcengine' +import { Alert, Button, Input, Space } from 'antd' +import type { FC } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..' + +const VolcengineSettings: FC = () => { + const { t } = useTranslation() + const { accessKeyId, secretAccessKey, region, setAccessKeyId, setSecretAccessKey, setRegion } = + useVolcengineSettings() + + const providerConfig = PROVIDER_URLS['doubao'] + const apiKeyWebsite = providerConfig?.websites?.apiKey + + const [localAccessKeyId, setLocalAccessKeyId] = useState(accessKeyId) + const [localSecretAccessKey, setLocalSecretAccessKey] = useState(secretAccessKey) + const [localRegion, setLocalRegion] = useState(region) + const [saving, setSaving] = useState(false) + const [hasCredentials, setHasCredentials] = useState(false) + + // Check if credentials exist on mount + useEffect(() => { + window.api.volcengine.hasCredentials().then(setHasCredentials) + }, []) + + // Sync local state with store + useEffect(() => { + setLocalAccessKeyId(accessKeyId) + setLocalSecretAccessKey(secretAccessKey) + setLocalRegion(region) + }, [accessKeyId, secretAccessKey, region]) + + const handleSaveCredentials = useCallback(async () => { + if (!localAccessKeyId || !localSecretAccessKey) { + window.toast.error(t('settings.provider.volcengine.credentials_required')) + return + } + + setSaving(true) + try { + // Save to Redux store + setAccessKeyId(localAccessKeyId) + setSecretAccessKey(localSecretAccessKey) + setRegion(localRegion) + + // Save to secure storage via IPC + await window.api.volcengine.saveCredentials(localAccessKeyId, localSecretAccessKey) + setHasCredentials(true) + window.toast.success(t('settings.provider.volcengine.credentials_saved')) + } catch (error) { + window.toast.error(String(error)) + } finally { + setSaving(false) + } + }, [localAccessKeyId, localSecretAccessKey, localRegion, setAccessKeyId, setSecretAccessKey, setRegion, t]) + + const handleClearCredentials = useCallback(async () => { + try { + await window.api.volcengine.clearCredentials() + setAccessKeyId('') + setSecretAccessKey('') + setLocalAccessKeyId('') + setLocalSecretAccessKey('') + setHasCredentials(false) + window.toast.success(t('settings.provider.volcengine.credentials_cleared')) + } catch (error) { + window.toast.error(String(error)) + } + }, [setAccessKeyId, setSecretAccessKey, t]) + + return ( + <> + {t('settings.provider.volcengine.title')} + + + {t('settings.provider.volcengine.access_key_id')} + setLocalAccessKeyId(e.target.value)} + onBlur={() => setAccessKeyId(localAccessKeyId)} + style={{ marginTop: 5 }} + spellCheck={false} + /> + + {t('settings.provider.volcengine.access_key_id_help')} + + + {t('settings.provider.volcengine.secret_access_key')} + setLocalSecretAccessKey(e.target.value)} + onBlur={() => setSecretAccessKey(localSecretAccessKey)} + style={{ marginTop: 5 }} + spellCheck={false} + /> + + + {apiKeyWebsite && ( + + {t('settings.provider.get_api_key')} + + )} + + {t('settings.provider.volcengine.secret_access_key_help')} + + + {t('settings.provider.volcengine.region')} + setLocalRegion(e.target.value)} + onBlur={() => setRegion(localRegion)} + style={{ marginTop: 5 }} + /> + + {t('settings.provider.volcengine.region_help')} + + + + + {hasCredentials && ( + + )} + + + ) +} + +export default VolcengineSettings diff --git a/src/renderer/src/services/__tests__/ApiService.test.ts b/src/renderer/src/services/__tests__/ApiService.test.ts index 1e9792cdc..07b05695c 100644 --- a/src/renderer/src/services/__tests__/ApiService.test.ts +++ b/src/renderer/src/services/__tests__/ApiService.test.ts @@ -242,6 +242,11 @@ vi.mock('@renderer/store/llm.ts', () => { secretAccessKey: '', apiKey: '', region: '' + }, + volcengine: { + accessKeyId: '', + secretAccessKey: '', + region: 'cn-beijing' } } } satisfies LlmState diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 5c562885b..94b51474b 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 179, + version: 180, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index 15f256382..2c3c6dc49 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -31,6 +31,11 @@ type LlmSettings = { apiKey: string region: string } + volcengine: { + accessKeyId: string + secretAccessKey: string + region: string + } } export interface LlmState { @@ -75,6 +80,11 @@ export const initialState: LlmState = { secretAccessKey: '', apiKey: '', region: '' + }, + volcengine: { + accessKeyId: '', + secretAccessKey: '', + region: 'cn-beijing' } } } @@ -216,6 +226,15 @@ const llmSlice = createSlice({ setAwsBedrockRegion: (state, action: PayloadAction) => { state.settings.awsBedrock.region = action.payload }, + setVolcengineAccessKeyId: (state, action: PayloadAction) => { + state.settings.volcengine.accessKeyId = action.payload + }, + setVolcengineSecretAccessKey: (state, action: PayloadAction) => { + state.settings.volcengine.secretAccessKey = action.payload + }, + setVolcengineRegion: (state, action: PayloadAction) => { + state.settings.volcengine.region = action.payload + }, updateModel: ( state, action: PayloadAction<{ @@ -257,6 +276,9 @@ export const { setAwsBedrockSecretAccessKey, setAwsBedrockApiKey, setAwsBedrockRegion, + setVolcengineAccessKeyId, + setVolcengineSecretAccessKey, + setVolcengineRegion, updateModel } = llmSlice.actions diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 4b2e4cef8..bb0c8d2fb 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2906,6 +2906,19 @@ const migrateConfig = { logger.error('migrate 179 error', error as Error) return state } + }, + '180': (state: RootState) => { + try { + // Initialize volcengine settings + if (!state.llm.settings.volcengine) { + state.llm.settings.volcengine = llmInitialState.settings.volcengine + } + logger.info('migrate 180 success') + return state + } catch (error) { + logger.error('migrate 180 error', error as Error) + return state + } } } From 531101742e97d41b27d0059cee50012646256c49 Mon Sep 17 00:00:00 2001 From: suyao Date: Thu, 27 Nov 2025 01:29:02 +0800 Subject: [PATCH 02/15] feat: add project name support for Volcengine integration --- src/main/services/VolcengineService.ts | 8 ++++--- src/preload/index.ts | 4 ++-- .../clients/volcengine/VolcengineAPIClient.ts | 4 +++- src/renderer/src/hooks/useVolcengine.ts | 14 +++++++++++-- src/renderer/src/i18n/locales/en-us.json | 2 ++ src/renderer/src/i18n/locales/zh-cn.json | 2 ++ src/renderer/src/i18n/locales/zh-tw.json | 2 ++ src/renderer/src/i18n/translate/de-de.json | 2 ++ src/renderer/src/i18n/translate/el-gr.json | 2 ++ src/renderer/src/i18n/translate/es-es.json | 2 ++ src/renderer/src/i18n/translate/fr-fr.json | 2 ++ src/renderer/src/i18n/translate/ja-jp.json | 2 ++ src/renderer/src/i18n/translate/pt-pt.json | 2 ++ src/renderer/src/i18n/translate/ru-ru.json | 2 ++ .../ProviderSettings/VolcengineSettings.tsx | 21 ++++++++++++++++--- .../src/services/__tests__/ApiService.test.ts | 3 ++- src/renderer/src/store/llm.ts | 8 ++++++- 17 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/main/services/VolcengineService.ts b/src/main/services/VolcengineService.ts index f14cbc9ee..8f67383f6 100644 --- a/src/main/services/VolcengineService.ts +++ b/src/main/services/VolcengineService.ts @@ -34,6 +34,7 @@ const ListFoundationModelsRequestSchema = z.object({ }) const ListEndpointsRequestSchema = z.object({ + ProjectName: z.optional(z.string()), PageNumber: z.optional(z.number()), PageSize: z.optional(z.number()) }) @@ -544,8 +545,9 @@ class VolcengineService { /** * List user-created endpoints from Volcengine ARK */ - private async listEndpoints(): Promise { + private async listEndpoints(projectName?: string): Promise { const requestBody: ListEndpointsRequest = { + ProjectName: projectName || 'default', PageNumber: 1, PageSize: CONFIG.DEFAULT_PAGE_SIZE } @@ -569,11 +571,11 @@ class VolcengineService { * List all available models from Volcengine ARK * Combines foundation models and user-created endpoints */ - public listModels = async (): Promise => { + public listModels = async (_?: Electron.IpcMainInvokeEvent, projectName?: string): Promise => { try { const [foundationModelsResult, endpointsResult] = await Promise.allSettled([ this.listFoundationModels(), - this.listEndpoints() + this.listEndpoints(projectName) ]) const models: ModelInfo[] = [] diff --git a/src/preload/index.ts b/src/preload/index.ts index c34181e8f..b713d5360 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -578,10 +578,10 @@ const api = { ipcRenderer.invoke(IpcChannel.Volcengine_SaveCredentials, accessKeyId, secretAccessKey), hasCredentials: (): Promise => ipcRenderer.invoke(IpcChannel.Volcengine_HasCredentials), clearCredentials: (): Promise => ipcRenderer.invoke(IpcChannel.Volcengine_ClearCredentials), - listModels: (): Promise<{ + listModels: (projectName?: string): Promise<{ models: Array<{ id: string; name: string; description?: string; created?: number }> total?: number - }> => ipcRenderer.invoke(IpcChannel.Volcengine_ListModels), + }> => ipcRenderer.invoke(IpcChannel.Volcengine_ListModels, projectName), getAuthHeaders: (params: { method: 'GET' | 'POST' host: string diff --git a/src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts index ac7aa8865..fc08f6806 100644 --- a/src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/volcengine/VolcengineAPIClient.ts @@ -1,5 +1,6 @@ import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' +import { getVolcengineProjectName } from '@renderer/hooks/useVolcengine' import type { Provider } from '@renderer/types' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' @@ -33,7 +34,8 @@ export class VolcengineAPIClient extends OpenAIAPIClient { logger.info('Fetching models from Volcengine API using signed request') - const response = await window.api.volcengine.listModels() + const projectName = getVolcengineProjectName() + const response = await window.api.volcengine.listModels(projectName) if (!response || !response.models) { logger.warn('Empty response from Volcengine listModels') diff --git a/src/renderer/src/hooks/useVolcengine.ts b/src/renderer/src/hooks/useVolcengine.ts index 148f984cf..635fa6912 100644 --- a/src/renderer/src/hooks/useVolcengine.ts +++ b/src/renderer/src/hooks/useVolcengine.ts @@ -1,5 +1,10 @@ import store, { useAppSelector } from '@renderer/store' -import { setVolcengineAccessKeyId, setVolcengineRegion, setVolcengineSecretAccessKey } from '@renderer/store/llm' +import { + setVolcengineAccessKeyId, + setVolcengineProjectName, + setVolcengineRegion, + setVolcengineSecretAccessKey +} from '@renderer/store/llm' import { useDispatch } from 'react-redux' export function useVolcengineSettings() { @@ -10,7 +15,8 @@ export function useVolcengineSettings() { ...settings, setAccessKeyId: (accessKeyId: string) => dispatch(setVolcengineAccessKeyId(accessKeyId)), setSecretAccessKey: (secretAccessKey: string) => dispatch(setVolcengineSecretAccessKey(secretAccessKey)), - setRegion: (region: string) => dispatch(setVolcengineRegion(region)) + setRegion: (region: string) => dispatch(setVolcengineRegion(region)), + setProjectName: (projectName: string) => dispatch(setVolcengineProjectName(projectName)) } } @@ -29,3 +35,7 @@ export function getVolcengineSecretAccessKey() { export function getVolcengineRegion() { return store.getState().llm.settings.volcengine.region } + +export function getVolcengineProjectName() { + return store.getState().llm.settings.volcengine.projectName +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 7fa4d5ded..d21908d45 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4519,6 +4519,8 @@ "credentials_required": "Please fill in Access Key ID and Secret Access Key", "credentials_saved": "Credentials saved", "description": "Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Please use IAM user's Access Key for authentication, do not use the root user credentials.", + "project_name": "Project Name", + "project_name_help": "Project name for endpoint filtering, default is 'default'", "region": "Region", "region_help": "Service region, e.g., cn-beijing", "save_credentials": "Save Credentials", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index d44819841..ab19d073d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4519,6 +4519,8 @@ "credentials_required": "请填写 Access Key ID 和 Secret Access Key", "credentials_saved": "凭证已保存", "description": "火山引擎是字节跳动旗下的云服务平台,提供豆包等大语言模型服务。请使用 IAM 子用户的 Access Key 进行身份验证,不要使用主账号的根用户密钥。", + "project_name": "项目名称", + "project_name_help": "用于筛选推理接入点的项目名称,默认为 'default'", "region": "地域", "region_help": "服务地域,例如 cn-beijing", "save_credentials": "保存凭证", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index c057e9d0b..fba17f5aa 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4519,6 +4519,8 @@ "credentials_required": "請填寫 Access Key ID 和 Secret Access Key", "credentials_saved": "憑證已儲存", "description": "火山引擎是字節跳動旗下的雲端服務平台,提供豆包等大型語言模型服務。請使用 IAM 子用戶的 Access Key 進行身份驗證,不要使用主帳號的根用戶密鑰。", + "project_name": "專案名稱", + "project_name_help": "用於篩選推論接入點的專案名稱,預設為 'default'", "region": "地區", "region_help": "服務地區,例如 cn-beijing", "save_credentials": "儲存憑證", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index dde8b7fa6..15fc5c5ce 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -4519,6 +4519,8 @@ "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", "credentials_saved": "[to be translated]:Credentials saved", "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "project_name": "[to be translated]:Project Name", + "project_name_help": "[to be translated]:Project name for endpoint filtering, default is 'default'", "region": "[to be translated]:Region", "region_help": "[to be translated]:Service region, e.g., cn-beijing", "save_credentials": "[to be translated]:Save Credentials", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 3b6d231d5..07ebec631 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4519,6 +4519,8 @@ "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", "credentials_saved": "[to be translated]:Credentials saved", "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "project_name": "[to be translated]:Project Name", + "project_name_help": "[to be translated]:Project name for endpoint filtering, default is 'default'", "region": "[to be translated]:Region", "region_help": "[to be translated]:Service region, e.g., cn-beijing", "save_credentials": "[to be translated]:Save Credentials", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index cf2d94e99..83909a02f 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4519,6 +4519,8 @@ "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", "credentials_saved": "[to be translated]:Credentials saved", "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "project_name": "[to be translated]:Project Name", + "project_name_help": "[to be translated]:Project name for endpoint filtering, default is 'default'", "region": "[to be translated]:Region", "region_help": "[to be translated]:Service region, e.g., cn-beijing", "save_credentials": "[to be translated]:Save Credentials", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 44c6ede29..73f5a8f99 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4519,6 +4519,8 @@ "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", "credentials_saved": "[to be translated]:Credentials saved", "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "project_name": "[to be translated]:Project Name", + "project_name_help": "[to be translated]:Project name for endpoint filtering, default is 'default'", "region": "[to be translated]:Region", "region_help": "[to be translated]:Service region, e.g., cn-beijing", "save_credentials": "[to be translated]:Save Credentials", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index c8c758db6..6df12fd67 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4519,6 +4519,8 @@ "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", "credentials_saved": "[to be translated]:Credentials saved", "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "project_name": "[to be translated]:Project Name", + "project_name_help": "[to be translated]:Project name for endpoint filtering, default is 'default'", "region": "[to be translated]:Region", "region_help": "[to be translated]:Service region, e.g., cn-beijing", "save_credentials": "[to be translated]:Save Credentials", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 25d0f531b..2e6a9c630 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -4519,6 +4519,8 @@ "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", "credentials_saved": "[to be translated]:Credentials saved", "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "project_name": "[to be translated]:Project Name", + "project_name_help": "[to be translated]:Project name for endpoint filtering, default is 'default'", "region": "[to be translated]:Region", "region_help": "[to be translated]:Service region, e.g., cn-beijing", "save_credentials": "[to be translated]:Save Credentials", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 7ceed795f..4e13c5aee 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4519,6 +4519,8 @@ "credentials_required": "[to be translated]:Please fill in Access Key ID and Secret Access Key", "credentials_saved": "[to be translated]:Credentials saved", "description": "[to be translated]:Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Use Access Key for authentication to fetch model list.", + "project_name": "[to be translated]:Project Name", + "project_name_help": "[to be translated]:Project name for endpoint filtering, default is 'default'", "region": "[to be translated]:Region", "region_help": "[to be translated]:Service region, e.g., cn-beijing", "save_credentials": "[to be translated]:Save Credentials", diff --git a/src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx index 4d4692a94..76f6fbeea 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/VolcengineSettings.tsx @@ -10,7 +10,7 @@ import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } const VolcengineSettings: FC = () => { const { t } = useTranslation() - const { accessKeyId, secretAccessKey, region, setAccessKeyId, setSecretAccessKey, setRegion } = + const { accessKeyId, secretAccessKey, region, projectName, setAccessKeyId, setSecretAccessKey, setRegion, setProjectName } = useVolcengineSettings() const providerConfig = PROVIDER_URLS['doubao'] @@ -19,6 +19,7 @@ const VolcengineSettings: FC = () => { const [localAccessKeyId, setLocalAccessKeyId] = useState(accessKeyId) const [localSecretAccessKey, setLocalSecretAccessKey] = useState(secretAccessKey) const [localRegion, setLocalRegion] = useState(region) + const [localProjectName, setLocalProjectName] = useState(projectName) const [saving, setSaving] = useState(false) const [hasCredentials, setHasCredentials] = useState(false) @@ -32,7 +33,8 @@ const VolcengineSettings: FC = () => { setLocalAccessKeyId(accessKeyId) setLocalSecretAccessKey(secretAccessKey) setLocalRegion(region) - }, [accessKeyId, secretAccessKey, region]) + setLocalProjectName(projectName) + }, [accessKeyId, secretAccessKey, region, projectName]) const handleSaveCredentials = useCallback(async () => { if (!localAccessKeyId || !localSecretAccessKey) { @@ -46,6 +48,7 @@ const VolcengineSettings: FC = () => { setAccessKeyId(localAccessKeyId) setSecretAccessKey(localSecretAccessKey) setRegion(localRegion) + setProjectName(localProjectName) // Save to secure storage via IPC await window.api.volcengine.saveCredentials(localAccessKeyId, localSecretAccessKey) @@ -56,7 +59,7 @@ const VolcengineSettings: FC = () => { } finally { setSaving(false) } - }, [localAccessKeyId, localSecretAccessKey, localRegion, setAccessKeyId, setSecretAccessKey, setRegion, t]) + }, [localAccessKeyId, localSecretAccessKey, localRegion, localProjectName, setAccessKeyId, setSecretAccessKey, setRegion, setProjectName, t]) const handleClearCredentials = useCallback(async () => { try { @@ -122,6 +125,18 @@ const VolcengineSettings: FC = () => { {t('settings.provider.volcengine.region_help')} + {t('settings.provider.volcengine.project_name')} + setLocalProjectName(e.target.value)} + onBlur={() => setProjectName(localProjectName)} + style={{ marginTop: 5 }} + /> + + {t('settings.provider.volcengine.project_name_help')} + +