Compare commits

...

4 Commits

Author SHA1 Message Date
suyao
76d48f9ccb fix: address PR review issues for Volcengine integration
- Fix region field being ignored: pass user-configured region to listFoundationModels and listEndpoints
- Add user notification before silent fallback when API fails
- Throw error on credential corruption instead of returning null
- Remove redundant credentials (accessKeyId, secretAccessKey) from Redux store (they're securely stored via safeStorage)
- Add warnings field to ListModelsResult for partial API failures
- Fix Redux/IPC order: save to secure storage first, then update Redux on success
- Update related tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 19:39:15 +08:00
suyao
115cd80432 fix: format 2025-11-27 01:33:17 +08:00
suyao
531101742e feat: add project name support for Volcengine integration 2025-11-27 01:29:02 +08:00
suyao
c3c577dff4 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.
2025-11-27 01:15:22 +08:00
23 changed files with 1247 additions and 2 deletions

View File

@@ -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'
}

View File

@@ -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()
})

View 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()

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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()
}
}
}

View 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
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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>
)

View File

@@ -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

View File

@@ -242,6 +242,10 @@ vi.mock('@renderer/store/llm.ts', () => {
secretAccessKey: '',
apiKey: '',
region: ''
},
volcengine: {
region: 'cn-beijing',
projectName: 'default'
}
}
} satisfies LlmState

View File

@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 179,
version: 180,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

View File

@@ -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

View File

@@ -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
}
}
}