Compare commits
4 Commits
main
...
feat/acces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d48f9ccb | ||
|
|
115cd80432 | ||
|
|
531101742e | ||
|
|
c3c577dff4 |
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
732
src/main/services/VolcengineService.ts
Normal file
732
src/main/services/VolcengineService.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
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({
|
||||
ProjectName: z.optional(z.string()),
|
||||
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<typeof ListFoundationModelsRequestSchema>
|
||||
type ListEndpointsRequest = z.infer<typeof ListEndpointsRequestSchema>
|
||||
type ListFoundationModelsResponse = z.infer<typeof ListFoundationModelsResponseSchema>
|
||||
type ListEndpointsResponse = z.infer<typeof ListEndpointsResponseSchema>
|
||||
|
||||
// ============= Internal Type Definitions =============
|
||||
|
||||
interface VolcengineCredentials {
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
interface SignedRequestParams {
|
||||
method: 'GET' | 'POST'
|
||||
host: string
|
||||
path: string
|
||||
query: Record<string, string>
|
||||
headers: Record<string, string>
|
||||
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
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
// 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, string>): 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<string, string>): {
|
||||
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<string, string> = {
|
||||
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<void> => {
|
||||
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
|
||||
* @throws VolcengineServiceError if credentials file exists but is corrupted
|
||||
*/
|
||||
private async loadCredentials(): Promise<VolcengineCredentials | null> {
|
||||
if (!fs.existsSync(this.credentialsFilePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
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)
|
||||
throw new VolcengineServiceError(
|
||||
'Credentials file exists but could not be loaded. Please re-enter your credentials.',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist
|
||||
*/
|
||||
public hasCredentials = async (): Promise<boolean> => {
|
||||
return fs.existsSync(this.credentialsFilePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored credentials
|
||||
*/
|
||||
public clearCredentials = async (): Promise<void> => {
|
||||
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<T>(
|
||||
method: 'GET' | 'POST',
|
||||
host: string,
|
||||
path: string,
|
||||
action: string,
|
||||
version: string,
|
||||
query?: Record<string, string>,
|
||||
body?: Record<string, unknown>,
|
||||
service: string = CONFIG.SERVICE_NAME,
|
||||
region: string = CONFIG.DEFAULT_REGION
|
||||
): Promise<T> {
|
||||
const credentials = await this.loadCredentials()
|
||||
if (!credentials) {
|
||||
throw new VolcengineServiceError('No credentials found. Please save credentials first.')
|
||||
}
|
||||
|
||||
const fullQuery: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
...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(region: string = CONFIG.DEFAULT_REGION): Promise<ListFoundationModelsResponse> {
|
||||
const requestBody: ListFoundationModelsRequest = {
|
||||
PageNumber: 1,
|
||||
PageSize: CONFIG.DEFAULT_PAGE_SIZE
|
||||
}
|
||||
|
||||
const response = await this.makeSignedRequest<unknown>(
|
||||
'POST',
|
||||
CONFIG.API_URLS.ARK_HOST,
|
||||
'/',
|
||||
'ListFoundationModels',
|
||||
CONFIG.API_VERSION,
|
||||
{},
|
||||
requestBody,
|
||||
CONFIG.SERVICE_NAME,
|
||||
region
|
||||
)
|
||||
|
||||
return ListFoundationModelsResponseSchema.parse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* List user-created endpoints from Volcengine ARK
|
||||
*/
|
||||
private async listEndpoints(
|
||||
projectName?: string,
|
||||
region: string = CONFIG.DEFAULT_REGION
|
||||
): Promise<ListEndpointsResponse> {
|
||||
const requestBody: ListEndpointsRequest = {
|
||||
ProjectName: projectName || 'default',
|
||||
PageNumber: 1,
|
||||
PageSize: CONFIG.DEFAULT_PAGE_SIZE
|
||||
}
|
||||
|
||||
const response = await this.makeSignedRequest<unknown>(
|
||||
'POST',
|
||||
CONFIG.API_URLS.ARK_HOST,
|
||||
'/',
|
||||
'ListEndpoints',
|
||||
CONFIG.API_VERSION,
|
||||
{},
|
||||
requestBody,
|
||||
CONFIG.SERVICE_NAME,
|
||||
region
|
||||
)
|
||||
|
||||
return ListEndpointsResponseSchema.parse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available models from Volcengine ARK
|
||||
* Combines foundation models and user-created endpoints
|
||||
*/
|
||||
public listModels = async (
|
||||
_?: Electron.IpcMainInvokeEvent,
|
||||
projectName?: string,
|
||||
region?: string
|
||||
): Promise<ListModelsResult> => {
|
||||
try {
|
||||
const effectiveRegion = region || CONFIG.DEFAULT_REGION
|
||||
const [foundationModelsResult, endpointsResult] = await Promise.allSettled([
|
||||
this.listFoundationModels(effectiveRegion),
|
||||
this.listEndpoints(projectName, effectiveRegion)
|
||||
])
|
||||
|
||||
const models: ModelInfo[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
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 {
|
||||
const errorMsg = `Failed to fetch foundation models: ${foundationModelsResult.reason}`
|
||||
logger.warn(errorMsg)
|
||||
warnings.push(errorMsg)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const errorMsg = `Failed to fetch endpoints: ${endpointsResult.reason}`
|
||||
logger.warn(errorMsg)
|
||||
warnings.push(errorMsg)
|
||||
}
|
||||
|
||||
// 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,
|
||||
warnings: warnings.length > 0 ? warnings : undefined
|
||||
}
|
||||
} 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<string, string>
|
||||
body?: string
|
||||
service?: string
|
||||
region?: string
|
||||
}
|
||||
): Promise<SignedHeaders> => {
|
||||
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<string, string>
|
||||
body?: Record<string, unknown>
|
||||
service?: string
|
||||
region?: string
|
||||
}
|
||||
): Promise<unknown> => {
|
||||
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()
|
||||
@@ -572,6 +572,41 @@ 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<void> =>
|
||||
ipcRenderer.invoke(IpcChannel.Volcengine_SaveCredentials, accessKeyId, secretAccessKey),
|
||||
hasCredentials: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.Volcengine_HasCredentials),
|
||||
clearCredentials: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Volcengine_ClearCredentials),
|
||||
listModels: (
|
||||
projectName?: string,
|
||||
region?: string
|
||||
): Promise<{
|
||||
models: Array<{ id: string; name: string; description?: string; created?: number }>
|
||||
total?: number
|
||||
warnings?: string[]
|
||||
}> => ipcRenderer.invoke(IpcChannel.Volcengine_ListModels, projectName, region),
|
||||
getAuthHeaders: (params: {
|
||||
method: 'GET' | 'POST'
|
||||
host: string
|
||||
path: string
|
||||
query?: Record<string, string>
|
||||
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<string, string>
|
||||
body?: Record<string, unknown>
|
||||
service?: string
|
||||
region?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke(IpcChannel.Volcengine_MakeRequest, params)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type OpenAI from '@cherrystudio/openai'
|
||||
import { loggerService } from '@logger'
|
||||
import { getVolcengineProjectName, getVolcengineRegion } from '@renderer/hooks/useVolcengine'
|
||||
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<OpenAI.Models.Model[]> {
|
||||
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 projectName = getVolcengineProjectName()
|
||||
const region = getVolcengineRegion()
|
||||
const response = await window.api.volcengine.listModels(projectName, region)
|
||||
|
||||
if (!response || !response.models) {
|
||||
logger.warn('Empty response from Volcengine listModels')
|
||||
return []
|
||||
}
|
||||
|
||||
// Notify user of any partial failures
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
for (const warning of response.warnings) {
|
||||
logger.warn(warning)
|
||||
}
|
||||
window.toast?.warning('Some Volcengine models could not be fetched. Check logs for details.')
|
||||
}
|
||||
|
||||
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)
|
||||
// Notify user before falling back
|
||||
window.toast?.warning('Failed to fetch Volcengine models. Check credentials if this persists.')
|
||||
// Fall back to standard OpenAI-compatible API on error
|
||||
logger.info('Falling back to OpenAI-compatible model list')
|
||||
return super.listModels()
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/renderer/src/hooks/useVolcengine.ts
Normal file
26
src/renderer/src/hooks/useVolcengine.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { setVolcengineProjectName, setVolcengineRegion } 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,
|
||||
setRegion: (region: string) => dispatch(setVolcengineRegion(region)),
|
||||
setProjectName: (projectName: string) => dispatch(setVolcengineProjectName(projectName))
|
||||
}
|
||||
}
|
||||
|
||||
export function getVolcengineSettings() {
|
||||
return store.getState().llm.settings.volcengine
|
||||
}
|
||||
|
||||
export function getVolcengineRegion() {
|
||||
return store.getState().llm.settings.volcengine.region
|
||||
}
|
||||
|
||||
export function getVolcengineProjectName() {
|
||||
return store.getState().llm.settings.volcengine.projectName
|
||||
}
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"secret_access_key": "Secret Access Key",
|
||||
"secret_access_key_help": "Your Volcengine Secret Access Key, please keep it secure",
|
||||
"title": "Volcengine Configuration"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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 进行身份验证,不要使用主账号的根用户密钥。",
|
||||
"project_name": "项目名称",
|
||||
"project_name_help": "用于筛选推理接入点的项目名称,默认为 'default'",
|
||||
"region": "地域",
|
||||
"region_help": "服务地域,例如 cn-beijing",
|
||||
"save_credentials": "保存凭证",
|
||||
"secret_access_key": "Secret Access Key",
|
||||
"secret_access_key_help": "您的火山引擎 Secret Access Key,请妥善保管",
|
||||
"title": "火山引擎配置"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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 進行身份驗證,不要使用主帳號的根用戶密鑰。",
|
||||
"project_name": "專案名稱",
|
||||
"project_name_help": "用於篩選推論接入點的專案名稱,預設為 'default'",
|
||||
"region": "地區",
|
||||
"region_help": "服務地區,例如 cn-beijing",
|
||||
"save_credentials": "儲存憑證",
|
||||
"secret_access_key": "Secret Access Key",
|
||||
"secret_access_key_help": "您的火山引擎 Secret Access Key,請妥善保管",
|
||||
"title": "火山引擎設定"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
|
||||
@@ -4510,6 +4510,23 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
|
||||
@@ -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<Props> = ({ providerId }) => {
|
||||
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
|
||||
{provider.id === 'aws-bedrock' && <AwsBedrockSettings />}
|
||||
{provider.id === 'vertexai' && <VertexAISettings />}
|
||||
{provider.id === 'doubao' && <VolcengineSettings />}
|
||||
<ModelList providerId={provider.id} />
|
||||
</SettingContainer>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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 { region, projectName, setRegion, setProjectName } = useVolcengineSettings()
|
||||
|
||||
const providerConfig = PROVIDER_URLS['doubao']
|
||||
const apiKeyWebsite = providerConfig?.websites?.apiKey
|
||||
|
||||
const [localAccessKeyId, setLocalAccessKeyId] = useState('')
|
||||
const [localSecretAccessKey, setLocalSecretAccessKey] = useState('')
|
||||
const [localRegion, setLocalRegion] = useState(region)
|
||||
const [localProjectName, setLocalProjectName] = useState(projectName)
|
||||
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 (only for region and projectName)
|
||||
useEffect(() => {
|
||||
setLocalRegion(region)
|
||||
setLocalProjectName(projectName)
|
||||
}, [region, projectName])
|
||||
|
||||
const handleSaveCredentials = useCallback(async () => {
|
||||
if (!localAccessKeyId || !localSecretAccessKey) {
|
||||
window.toast.error(t('settings.provider.volcengine.credentials_required'))
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// Save credentials to secure storage via IPC first
|
||||
await window.api.volcengine.saveCredentials(localAccessKeyId, localSecretAccessKey)
|
||||
|
||||
// Only update Redux after IPC success (for region and projectName only)
|
||||
setRegion(localRegion)
|
||||
setProjectName(localProjectName)
|
||||
|
||||
setHasCredentials(true)
|
||||
// Clear local credential state after successful save (they're now in secure storage)
|
||||
setLocalAccessKeyId('')
|
||||
setLocalSecretAccessKey('')
|
||||
window.toast.success(t('settings.provider.volcengine.credentials_saved'))
|
||||
} catch (error) {
|
||||
window.toast.error(String(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [localAccessKeyId, localSecretAccessKey, localRegion, localProjectName, setRegion, setProjectName, t])
|
||||
|
||||
const handleClearCredentials = useCallback(async () => {
|
||||
try {
|
||||
await window.api.volcengine.clearCredentials()
|
||||
setLocalAccessKeyId('')
|
||||
setLocalSecretAccessKey('')
|
||||
setHasCredentials(false)
|
||||
window.toast.success(t('settings.provider.volcengine.credentials_cleared'))
|
||||
} catch (error) {
|
||||
window.toast.error(String(error))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.volcengine.title')}</SettingSubtitle>
|
||||
<Alert type="info" style={{ marginTop: 5 }} message={t('settings.provider.volcengine.description')} showIcon />
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.volcengine.access_key_id')}</SettingSubtitle>
|
||||
<Input
|
||||
value={localAccessKeyId}
|
||||
placeholder="Access Key ID"
|
||||
onChange={(e) => setLocalAccessKeyId(e.target.value)}
|
||||
style={{ marginTop: 5 }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.access_key_id_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.volcengine.secret_access_key')}</SettingSubtitle>
|
||||
<Input.Password
|
||||
value={localSecretAccessKey}
|
||||
placeholder="Secret Access Key"
|
||||
onChange={(e) => setLocalSecretAccessKey(e.target.value)}
|
||||
style={{ marginTop: 5 }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.secret_access_key_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.volcengine.region')}</SettingSubtitle>
|
||||
<Input
|
||||
value={localRegion}
|
||||
placeholder="cn-beijing"
|
||||
onChange={(e) => setLocalRegion(e.target.value)}
|
||||
onBlur={() => setRegion(localRegion)}
|
||||
style={{ marginTop: 5 }}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.region_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.volcengine.project_name')}</SettingSubtitle>
|
||||
<Input
|
||||
value={localProjectName}
|
||||
placeholder="default"
|
||||
onChange={(e) => setLocalProjectName(e.target.value)}
|
||||
onBlur={() => setProjectName(localProjectName)}
|
||||
style={{ marginTop: 5 }}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.project_name_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<Space style={{ marginTop: 15 }}>
|
||||
<Button type="primary" onClick={handleSaveCredentials} loading={saving}>
|
||||
{t('settings.provider.volcengine.save_credentials')}
|
||||
</Button>
|
||||
{hasCredentials && (
|
||||
<Button danger onClick={handleClearCredentials}>
|
||||
{t('settings.provider.volcengine.clear_credentials')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VolcengineSettings
|
||||
@@ -242,6 +242,10 @@ vi.mock('@renderer/store/llm.ts', () => {
|
||||
secretAccessKey: '',
|
||||
apiKey: '',
|
||||
region: ''
|
||||
},
|
||||
volcengine: {
|
||||
region: 'cn-beijing',
|
||||
projectName: 'default'
|
||||
}
|
||||
}
|
||||
} satisfies LlmState
|
||||
|
||||
@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 179,
|
||||
version: 180,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@@ -31,6 +31,10 @@ type LlmSettings = {
|
||||
apiKey: string
|
||||
region: string
|
||||
}
|
||||
volcengine: {
|
||||
region: string
|
||||
projectName: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface LlmState {
|
||||
@@ -75,6 +79,10 @@ export const initialState: LlmState = {
|
||||
secretAccessKey: '',
|
||||
apiKey: '',
|
||||
region: ''
|
||||
},
|
||||
volcengine: {
|
||||
region: 'cn-beijing',
|
||||
projectName: 'default'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +224,12 @@ const llmSlice = createSlice({
|
||||
setAwsBedrockRegion: (state, action: PayloadAction<string>) => {
|
||||
state.settings.awsBedrock.region = action.payload
|
||||
},
|
||||
setVolcengineRegion: (state, action: PayloadAction<string>) => {
|
||||
state.settings.volcengine.region = action.payload
|
||||
},
|
||||
setVolcengineProjectName: (state, action: PayloadAction<string>) => {
|
||||
state.settings.volcengine.projectName = action.payload
|
||||
},
|
||||
updateModel: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
@@ -257,6 +271,8 @@ export const {
|
||||
setAwsBedrockSecretAccessKey,
|
||||
setAwsBedrockApiKey,
|
||||
setAwsBedrockRegion,
|
||||
setVolcengineRegion,
|
||||
setVolcengineProjectName,
|
||||
updateModel
|
||||
} = llmSlice.actions
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user