import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname } from 'node:path' import { z } from 'zod' import { continueModeSchema, defaultModelProfileId, defaultRuntimeSettings, imageGenerationQualitySchema, isAgentRuntimeModelProtocol, modelAuthenticationSchema, modelProtocolSchema, runtimeModelSourceSchema, runtimePathSchema, runtimeProviderSchema, runtimeSandboxModeSchema, toolApprovalPolicySchema, RuntimeSettings, type RuntimeSettingsInput } from '../shared/contracts' const credentialSchema = z .object({ formatVersion: z.literal(1), scheme: z.literal('electron-safe-storage'), ciphertextBase64: z.string() }) .optional() const version4StoredSettingsSchema = z.object({ version: z.literal(4), provider: runtimeProviderSchema, modelBaseUrl: z.string(), modelName: z.string(), opencodeBaseUrl: z.string().default(''), opencodeEmbedded: z.boolean().default(false), opencodeBinaryPath: runtimePathSchema.default(''), opencodeConfigPath: runtimePathSchema.default(''), continueBinaryPath: runtimePathSchema.default(''), continueConfigPath: runtimePathSchema.default(''), continueMode: continueModeSchema.default('chat'), workspacePath: z.string().default(''), credential: credentialSchema, toolApproval: toolApprovalPolicySchema }) const version5StoredModelProfileSchema = z.object({ id: z.string().uuid(), name: z.string(), baseUrl: z.string(), modelName: z.string(), credential: credentialSchema }) const version5StoredSettingsSchema = z.object({ version: z.literal(5), provider: runtimeProviderSchema, modelProfiles: z.array(version5StoredModelProfileSchema).min(1).max(20), defaultModelProfileId: z.string().uuid(), opencodeModelSource: runtimeModelSourceSchema, continueModelSource: runtimeModelSourceSchema, opencodeBaseUrl: z.string().default(''), opencodeEmbedded: z.boolean().default(false), opencodeBinaryPath: runtimePathSchema.default(''), opencodeConfigPath: runtimePathSchema.default(''), continueBinaryPath: runtimePathSchema.default(''), continueConfigPath: runtimePathSchema.default(''), continueMode: continueModeSchema.default('chat'), workspacePath: z.string().default(''), toolApproval: toolApprovalPolicySchema }) const version6StoredModelProfileSchema = version5StoredModelProfileSchema.extend({ protocol: modelProtocolSchema, authentication: modelAuthenticationSchema }) const version6StoredSettingsSchema = version5StoredSettingsSchema .omit({ version: true, modelProfiles: true }) .extend({ version: z.literal(6), modelProfiles: z .array(version6StoredModelProfileSchema) .min(1) .max(20), runtimeSandboxMode: runtimeSandboxModeSchema.default('auto'), knowledgeEmbeddingEnabled: z.boolean().default(false), knowledgeEmbeddingBaseUrl: z .string() .default('http://127.0.0.1:11434'), knowledgeEmbeddingModel: z.string().default('nomic-embed-text') }) const version7StoredSettingsSchema = version6StoredSettingsSchema .omit({ version: true }) .extend({ version: z.literal(7), knowledgeEmbeddingCredential: credentialSchema }) const storedModelProfileSchema = version6StoredModelProfileSchema.extend({ imageGenerationQuality: imageGenerationQualitySchema }) const version8StoredSettingsSchema = version7StoredSettingsSchema .omit({ version: true, modelProfiles: true }) .extend({ version: z.literal(8), modelProfiles: z.array(storedModelProfileSchema).min(1).max(20) }) const version9StoredSettingsSchema = version8StoredSettingsSchema .omit({ version: true }) .extend({ version: z.literal(9), subagentSmartRoutingEnabled: z.boolean() }) const version10StoredSettingsSchema = version9StoredSettingsSchema .omit({ version: true }) .extend({ version: z.literal(10), intranetCompatibilityEnabled: z.boolean() }) const version11StoredSettingsSchema = version10StoredSettingsSchema .omit({ version: true }) .extend({ version: z.literal(11) }) const version12StoredSettingsSchema = version11StoredSettingsSchema .omit({ version: true, intranetCompatibilityEnabled: true }) .extend({ version: z.literal(12) }) const currentStoredModelProfileSchema = storedModelProfileSchema.extend({ supportsImageInput: z.boolean() }) const storedSettingsSchema = version12StoredSettingsSchema .omit({ version: true, modelProfiles: true }) .extend({ version: z.literal(13), modelProfiles: z .array(currentStoredModelProfileSchema) .min(1) .max(20) }) class UnsupportedRuntimeSettingsVersionError extends Error {} type StoredSettings = z.infer type Version10StoredSettings = z.infer< typeof version10StoredSettingsSchema > type Version11StoredSettings = z.infer< typeof version11StoredSettingsSchema > type Version12StoredSettings = z.infer< typeof version12StoredSettingsSchema > const version3StoredSettingsSchema = version4StoredSettingsSchema .omit({ version: true, continueMode: true }) .extend({ version: z.literal(3) }) const version2StoredSettingsSchema = z.object({ version: z.literal(2), provider: runtimeProviderSchema, modelBaseUrl: z.string(), modelName: z.string(), opencodeBaseUrl: z.string().default(''), opencodeEmbedded: z.boolean().default(false), continueCommand: runtimePathSchema.default('cn'), workspacePath: z.string().default(''), credential: credentialSchema, toolApproval: toolApprovalPolicySchema }) const legacyStoredSettingsSchema = z.object({ version: z.literal(1), provider: z.enum(['auto', 'bigtoken', 'opencode', 'continue']), bigtokenBaseUrl: z.string(), bigtokenModel: z.string(), opencodeBaseUrl: z.string().default(''), opencodeEmbedded: z.boolean().default(false), continueCommand: runtimePathSchema.default('cn'), workspacePath: z.string().default(''), credential: credentialSchema, toolApproval: toolApprovalPolicySchema }) const credentialPayloadSchema = z.object({ version: z.literal(1), apiKey: z.string(), origin: z.string() }) const embeddingCredentialPayloadSchema = z.object({ version: z.literal(1), apiKey: z.string(), endpoint: z.string() }) export type CredentialCipher = { isAvailable: () => boolean encrypt: (value: string) => Buffer decrypt: (value: Buffer) => string } export type ResolvedRuntimeSettings = { provider: RuntimeSettings['provider'] modelBaseUrl: string modelName: string modelProtocol: RuntimeSettings['modelProtocol'] modelAuthentication: RuntimeSettings['modelAuthentication'] supportsImageInput?: boolean imageGenerationQuality: RuntimeSettings['imageGenerationQuality'] apiKey?: string modelProfiles: ResolvedModelProfile[] defaultModelProfileId: string opencodeModelProfile?: ResolvedModelProfile continueModelProfile?: ResolvedModelProfile opencodeBaseUrl: string opencodeEmbedded: boolean opencodeBinaryPath: string opencodeConfigPath: string continueBinaryPath: string continueConfigPath: string continueMode: RuntimeSettings['continueMode'] runtimeSandboxMode: RuntimeSettings['runtimeSandboxMode'] subagentSmartRoutingEnabled: boolean knowledgeEmbeddingEnabled: boolean knowledgeEmbeddingBaseUrl: string knowledgeEmbeddingModel: string knowledgeEmbeddingApiKey?: string workspacePath: string toolApproval: RuntimeSettings['toolApproval'] } export type ResolvedModelProfile = { id: string name: string baseUrl: string modelName: string protocol: RuntimeSettings['modelProtocol'] authentication: RuntimeSettings['modelAuthentication'] supportsImageInput?: boolean imageGenerationQuality?: RuntimeSettings['imageGenerationQuality'] apiKey?: string } const defaultSettings: StoredSettings = { version: 13, provider: defaultRuntimeSettings.provider, modelProfiles: [ { id: defaultModelProfileId, name: '默认模型', baseUrl: defaultRuntimeSettings.modelBaseUrl, modelName: defaultRuntimeSettings.modelName, protocol: defaultRuntimeSettings.modelProtocol, authentication: defaultRuntimeSettings.modelAuthentication, supportsImageInput: defaultRuntimeSettings.supportsImageInput, imageGenerationQuality: defaultRuntimeSettings.imageGenerationQuality } ], defaultModelProfileId, opencodeModelSource: { kind: 'profile', profileId: defaultModelProfileId }, continueModelSource: { kind: 'profile', profileId: defaultModelProfileId }, opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl, opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded, opencodeBinaryPath: defaultRuntimeSettings.opencodeBinaryPath, opencodeConfigPath: defaultRuntimeSettings.opencodeConfigPath, continueBinaryPath: defaultRuntimeSettings.continueBinaryPath, continueConfigPath: defaultRuntimeSettings.continueConfigPath, continueMode: defaultRuntimeSettings.continueMode, runtimeSandboxMode: defaultRuntimeSettings.runtimeSandboxMode, subagentSmartRoutingEnabled: defaultRuntimeSettings.subagentSmartRoutingEnabled, knowledgeEmbeddingEnabled: defaultRuntimeSettings.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: defaultRuntimeSettings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: defaultRuntimeSettings.knowledgeEmbeddingModel, workspacePath: defaultRuntimeSettings.workspacePath, toolApproval: defaultRuntimeSettings.toolApproval } function migrateContinueCommand(command: string): string { const value = command.trim() return value === 'cn' ? '' : value } function compatibleTextProfileId( settings: Pick< Version10StoredSettings, 'modelProfiles' | 'defaultModelProfileId' > ): string | undefined { const defaultProfile = settings.modelProfiles.find( (profile) => profile.id === settings.defaultModelProfileId ) if ( defaultProfile && defaultProfile.protocol !== 'openai-images-generations' ) { return defaultProfile.id } return settings.modelProfiles.find( (profile) => profile.protocol !== 'openai-images-generations' )?.id } function migrateVersion11( settings: Version11StoredSettings ): StoredSettings { const { intranetCompatibilityEnabled: _obsolete, ...current } = settings void _obsolete return migrateVersion12({ ...current, version: 12 }) } function migrateVersion12( settings: Version12StoredSettings ): StoredSettings { return { ...settings, version: 13, modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, supportsImageInput: false })) } } function migrateVersion10( settings: Version10StoredSettings ): StoredSettings { const profileId = compatibleTextProfileId(settings) const preserveOpenCodePlatform = settings.opencodeModelSource.kind === 'platform' && (settings.provider === 'opencode' || Boolean(settings.opencodeBaseUrl.trim()) || Boolean(settings.opencodeConfigPath.trim())) const preserveContinuePlatform = settings.continueModelSource.kind === 'platform' && (settings.provider === 'continue' || Boolean(settings.continueConfigPath.trim())) return migrateVersion11({ ...settings, version: 11, provider: settings.provider === 'auto' ? 'model' : settings.provider, opencodeModelSource: settings.opencodeModelSource.kind === 'profile' || preserveOpenCodePlatform || !profileId ? settings.opencodeModelSource : { kind: 'profile', profileId }, continueModelSource: settings.continueModelSource.kind === 'profile' || preserveContinuePlatform || !profileId ? settings.continueModelSource : { kind: 'profile', profileId }, opencodeEmbedded: !settings.opencodeBaseUrl.trim() }) } function normalizeStoredSettings(settings: StoredSettings): StoredSettings { const fallbackProfileId = compatibleTextProfileId(settings) const normalizeSource = ( source: RuntimeSettings['opencodeModelSource'] ): RuntimeSettings['opencodeModelSource'] => { if (source.kind === 'platform') { return source } const profile = settings.modelProfiles.find( (candidate) => candidate.id === source.profileId ) if (profile && isAgentRuntimeModelProtocol(profile.protocol)) { return source } return fallbackProfileId ? { kind: 'profile', profileId: fallbackProfileId } : { kind: 'platform' } } const opencodeBaseUrl = settings.opencodeBaseUrl.trim() const defaultModelProfileId = settings.modelProfiles.some( (profile) => profile.id === settings.defaultModelProfileId ) ? settings.defaultModelProfileId : settings.modelProfiles[0]!.id return { ...settings, provider: settings.provider === 'auto' ? 'model' : settings.provider, defaultModelProfileId, opencodeModelSource: opencodeBaseUrl ? { kind: 'platform' } : normalizeSource(settings.opencodeModelSource), continueModelSource: normalizeSource( settings.continueModelSource ), opencodeBaseUrl, opencodeEmbedded: !opencodeBaseUrl } } function migrateVersion4( settings: z.infer ): StoredSettings { return migrateVersion10({ version: 10, provider: settings.provider, modelProfiles: [ { id: defaultModelProfileId, name: '默认模型', baseUrl: settings.modelBaseUrl, modelName: settings.modelName, protocol: 'anthropic-messages', authentication: 'api-key', imageGenerationQuality: defaultRuntimeSettings.imageGenerationQuality, credential: settings.credential } ], defaultModelProfileId, opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, opencodeBaseUrl: settings.opencodeBaseUrl, opencodeEmbedded: settings.opencodeEmbedded, opencodeBinaryPath: settings.opencodeBinaryPath, opencodeConfigPath: settings.opencodeConfigPath, continueBinaryPath: settings.continueBinaryPath, continueConfigPath: settings.continueConfigPath, continueMode: settings.continueMode, runtimeSandboxMode: defaultRuntimeSettings.runtimeSandboxMode, subagentSmartRoutingEnabled: defaultRuntimeSettings.subagentSmartRoutingEnabled, intranetCompatibilityEnabled: true, knowledgeEmbeddingEnabled: defaultRuntimeSettings.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: defaultRuntimeSettings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: defaultRuntimeSettings.knowledgeEmbeddingModel, workspacePath: settings.workspacePath, toolApproval: settings.toolApproval }) } function migrateVersion5( settings: z.infer ): StoredSettings { return migrateVersion10({ ...settings, version: 10, runtimeSandboxMode: defaultRuntimeSettings.runtimeSandboxMode, subagentSmartRoutingEnabled: defaultRuntimeSettings.subagentSmartRoutingEnabled, intranetCompatibilityEnabled: true, knowledgeEmbeddingEnabled: defaultRuntimeSettings.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: defaultRuntimeSettings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: defaultRuntimeSettings.knowledgeEmbeddingModel, modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, protocol: 'anthropic-messages', authentication: 'api-key', imageGenerationQuality: defaultRuntimeSettings.imageGenerationQuality })) }) } function migrateVersion6( settings: z.infer ): StoredSettings { const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl) endpoint.pathname = `${endpoint.pathname.replace(/\/+$/u, '')}/v1/embeddings` return migrateVersion10({ ...settings, version: 10, subagentSmartRoutingEnabled: defaultRuntimeSettings.subagentSmartRoutingEnabled, intranetCompatibilityEnabled: true, knowledgeEmbeddingBaseUrl: endpoint.toString(), modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, imageGenerationQuality: defaultRuntimeSettings.imageGenerationQuality })) }) } function migrateVersion7( settings: z.infer ): StoredSettings { return migrateVersion10({ ...settings, version: 10, subagentSmartRoutingEnabled: defaultRuntimeSettings.subagentSmartRoutingEnabled, intranetCompatibilityEnabled: true, modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, imageGenerationQuality: defaultRuntimeSettings.imageGenerationQuality })) }) } function migrateVersion8( settings: z.infer ): StoredSettings { return migrateVersion10({ ...settings, version: 10, subagentSmartRoutingEnabled: false, intranetCompatibilityEnabled: true }) } function migrateVersion9( settings: z.infer ): StoredSettings { return migrateVersion10({ ...settings, version: 10, intranetCompatibilityEnabled: true }) } function normalizeModelBaseUrl(value: string): string { const url = new URL(value) url.pathname = url.pathname.replace(/\/+$/u, '') return url.toString().replace(/\/$/u, '') } export class RuntimeSettingsStore { private settings?: StoredSettings private loadWarning?: string private updateQueue: Promise = Promise.resolve() constructor( private readonly filePath: string, private readonly cipher: CredentialCipher, private readonly environment: NodeJS.ProcessEnv = process.env ) {} private async load(): Promise { if (this.settings) { return this.settings } try { const contents = await readFile(this.filePath, 'utf8') const parsed: unknown = JSON.parse(contents) if ( parsed && typeof parsed === 'object' && 'version' in parsed && typeof parsed.version === 'number' && parsed.version > 13 ) { throw new UnsupportedRuntimeSettingsVersionError( `当前 GoodBuddy 不支持 Runtime 设置版本 ${parsed.version},请升级应用后重试` ) } const current = storedSettingsSchema.safeParse(parsed) if (current.success) { this.settings = current.data } else { const version12 = version12StoredSettingsSchema.safeParse(parsed) if (version12.success) { this.settings = migrateVersion12(version12.data) } else { const version11 = version11StoredSettingsSchema.safeParse(parsed) if (version11.success) { this.settings = migrateVersion11(version11.data) } else { const version10 = version10StoredSettingsSchema.safeParse(parsed) if (version10.success) { this.settings = migrateVersion10(version10.data) } else { const version9 = version9StoredSettingsSchema.safeParse(parsed) if (version9.success) { this.settings = migrateVersion9(version9.data) } else { const version8 = version8StoredSettingsSchema.safeParse(parsed) if (version8.success) { this.settings = migrateVersion8(version8.data) } else { const version7 = version7StoredSettingsSchema.safeParse(parsed) if (version7.success) { this.settings = migrateVersion7(version7.data) } else { const version6 = version6StoredSettingsSchema.safeParse(parsed) if (version6.success) { this.settings = migrateVersion6(version6.data) } else { const version5 = version5StoredSettingsSchema.safeParse(parsed) if (version5.success) { this.settings = migrateVersion5(version5.data) } else { const version4 = version4StoredSettingsSchema.safeParse(parsed) if (version4.success) { this.settings = migrateVersion4(version4.data) } else { const version3 = version3StoredSettingsSchema.safeParse(parsed) if (version3.success) { this.settings = migrateVersion4({ ...version3.data, version: 4, continueMode: 'chat' }) } else { const version2 = version2StoredSettingsSchema.safeParse(parsed) if (version2.success) { this.settings = migrateVersion4({ version: 4, provider: version2.data.provider, modelBaseUrl: version2.data.modelBaseUrl, modelName: version2.data.modelName, opencodeBaseUrl: version2.data.opencodeBaseUrl, opencodeEmbedded: version2.data.opencodeEmbedded, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: migrateContinueCommand( version2.data.continueCommand ), continueConfigPath: '', continueMode: 'chat', workspacePath: version2.data.workspacePath, credential: version2.data.credential, toolApproval: version2.data.toolApproval }) } else { const legacy = legacyStoredSettingsSchema.parse(parsed) this.settings = migrateVersion4({ version: 4, provider: legacy.provider === 'bigtoken' ? 'model' : legacy.provider, modelBaseUrl: legacy.bigtokenBaseUrl, modelName: legacy.bigtokenModel, opencodeBaseUrl: legacy.opencodeBaseUrl, opencodeEmbedded: legacy.opencodeEmbedded, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: migrateContinueCommand( legacy.continueCommand ), continueConfigPath: '', continueMode: 'chat', workspacePath: legacy.workspacePath, credential: legacy.credential, toolApproval: legacy.toolApproval }) } } } } } } } } } } } } this.settings = normalizeStoredSettings(this.settings) } catch (error) { if (error instanceof UnsupportedRuntimeSettingsVersionError) { throw error } if ( !( error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' ) ) { this.loadWarning = 'Runtime 设置文件已损坏,已隔离原文件并恢复默认设置' await rename( this.filePath, `${this.filePath}.corrupt-${Date.now()}` ).catch(() => undefined) } this.settings = { ...defaultSettings } } return this.settings } private getStoredApiKey( profile: StoredSettings['modelProfiles'][number] ): string | undefined { if (!profile.credential || !this.cipher.isAvailable()) { return undefined } try { const payload = credentialPayloadSchema.parse( JSON.parse( this.cipher.decrypt( Buffer.from(profile.credential.ciphertextBase64, 'base64') ) ) ) if (payload.origin !== new URL(profile.baseUrl).origin) { this.loadWarning = `模型连接“${profile.name}”的服务地址与已保存 API Key 不匹配,请重新输入或清除 API Key` return undefined } return payload.apiKey } catch { return undefined } } private getStoredEmbeddingApiKey( settings: StoredSettings ): string | undefined { if ( !settings.knowledgeEmbeddingCredential || !this.cipher.isAvailable() ) { return undefined } try { const payload = embeddingCredentialPayloadSchema.parse( JSON.parse( this.cipher.decrypt( Buffer.from( settings.knowledgeEmbeddingCredential.ciphertextBase64, 'base64' ) ) ) ) return payload.endpoint === settings.knowledgeEmbeddingBaseUrl ? payload.apiKey : undefined } catch { return undefined } } private getEnvironmentApiKey(): string | undefined { return ( this.environment.GOODBUDDY_MODEL_API_KEY?.trim() || this.environment.GOODBUDDY_BIGTOKEN_API_KEY?.trim() || undefined ) } private resolveEffectiveModelSettings(settings: StoredSettings): { apiKey?: string baseUrl: string model: string protocol: RuntimeSettings['modelProtocol'] authentication: RuntimeSettings['modelAuthentication'] supportsImageInput: boolean imageGenerationQuality: RuntimeSettings['imageGenerationQuality'] credentialSource: RuntimeSettings['credentialSource'] } { const profile = settings.modelProfiles.find( (candidate) => candidate.id === settings.defaultModelProfileId ) ?? settings.modelProfiles[0] if (!profile) { throw new Error('默认模型连接不存在') } const environmentApiKey = profile.authentication === 'api-key' ? this.getEnvironmentApiKey() : undefined const storedApiKey = profile.authentication === 'api-key' ? this.getStoredApiKey(profile) : undefined const environmentBaseUrl = this.environment.GOODBUDDY_MODEL_BASE_URL?.trim() || this.environment.GOODBUDDY_BIGTOKEN_BASE_URL?.trim() const environmentModel = this.environment.GOODBUDDY_MODEL_NAME?.trim() || this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim() const baseUrl = environmentApiKey ? environmentBaseUrl || defaultRuntimeSettings.modelBaseUrl : profile.baseUrl const model = environmentApiKey ? environmentModel || defaultRuntimeSettings.modelName : profile.modelName return { apiKey: environmentApiKey ?? storedApiKey, baseUrl, model, protocol: profile.protocol, authentication: profile.authentication, supportsImageInput: profile.supportsImageInput, imageGenerationQuality: profile.imageGenerationQuality, credentialSource: environmentApiKey ? 'environment' : storedApiKey ? 'encrypted' : 'none' } } private resolveProfile( settings: StoredSettings, profileId: string ): ResolvedModelProfile | undefined { const profile = settings.modelProfiles.find( (candidate) => candidate.id === profileId ) if (!profile) { return undefined } if (profile.id === settings.defaultModelProfileId) { const effective = this.resolveEffectiveModelSettings(settings) return { id: profile.id, name: profile.name, baseUrl: effective.baseUrl, modelName: effective.model, protocol: effective.protocol, authentication: effective.authentication, supportsImageInput: effective.supportsImageInput, imageGenerationQuality: effective.imageGenerationQuality, apiKey: effective.apiKey } } return { id: profile.id, name: profile.name, baseUrl: profile.baseUrl, modelName: profile.modelName, protocol: profile.protocol, authentication: profile.authentication, supportsImageInput: profile.supportsImageInput, imageGenerationQuality: profile.imageGenerationQuality, apiKey: profile.authentication === 'api-key' ? this.getStoredApiKey(profile) : undefined } } private resolveAgentSettings(settings: StoredSettings): { opencodeBaseUrl: string opencodeEmbedded: boolean opencodeBinaryPath: string opencodeConfigPath: string continueBinaryPath: string continueConfigPath: string continueMode: RuntimeSettings['continueMode'] runtimeSandboxMode: RuntimeSettings['runtimeSandboxMode'] workspacePath: string } { const continueBinaryEnvironment = this.environment.GOODBUDDY_CONTINUE_BINARY?.trim() const legacyContinueCommand = this.environment.GOODBUDDY_CONTINUE_COMMAND?.trim() const opencodeBaseUrl = this.environment.GOODBUDDY_OPENCODE_URL?.trim() ?? settings.opencodeBaseUrl return { opencodeBaseUrl, opencodeEmbedded: !opencodeBaseUrl, opencodeBinaryPath: this.environment.GOODBUDDY_OPENCODE_BINARY?.trim() || settings.opencodeBinaryPath, opencodeConfigPath: this.environment.GOODBUDDY_OPENCODE_CONFIG?.trim() || settings.opencodeConfigPath, continueBinaryPath: continueBinaryEnvironment || (legacyContinueCommand ? migrateContinueCommand(legacyContinueCommand) : '') || settings.continueBinaryPath, continueConfigPath: this.environment.GOODBUDDY_CONTINUE_CONFIG?.trim() || settings.continueConfigPath, continueMode: settings.continueMode, runtimeSandboxMode: settings.runtimeSandboxMode, workspacePath: this.environment.GOODBUDDY_WORKSPACE?.trim() || settings.workspacePath || homedir() } } private toPublicSettings(settings: StoredSettings): RuntimeSettings { const effective = this.resolveEffectiveModelSettings(settings) const agent = this.resolveAgentSettings(settings) const modelProfiles = settings.modelProfiles.map((profile) => { const isDefault = profile.id === settings.defaultModelProfileId const apiKey = profile.authentication === 'api-key' ? this.getStoredApiKey(profile) : undefined return { id: profile.id, name: profile.name, baseUrl: isDefault ? effective.baseUrl : profile.baseUrl, modelName: isDefault ? effective.model : profile.modelName, protocol: isDefault ? effective.protocol : profile.protocol, authentication: isDefault ? effective.authentication : profile.authentication, supportsImageInput: isDefault ? effective.supportsImageInput : profile.supportsImageInput, imageGenerationQuality: isDefault ? effective.imageGenerationQuality : profile.imageGenerationQuality, apiKeyConfigured: isDefault ? Boolean(effective.apiKey) : Boolean(apiKey), credentialSource: isDefault ? effective.credentialSource : apiKey ? ('encrypted' as const) : ('none' as const) } }) const embeddingEnvironmentApiKey = this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() const embeddingStoredApiKey = this.getStoredEmbeddingApiKey(settings) return { provider: settings.provider, modelBaseUrl: effective.baseUrl, modelName: effective.model, modelProtocol: effective.protocol, modelAuthentication: effective.authentication, supportsImageInput: effective.supportsImageInput, imageGenerationQuality: effective.imageGenerationQuality, opencodeBaseUrl: agent.opencodeBaseUrl, opencodeEmbedded: agent.opencodeEmbedded, opencodeBinaryPath: agent.opencodeBinaryPath, opencodeConfigPath: agent.opencodeConfigPath, continueBinaryPath: agent.continueBinaryPath, continueConfigPath: agent.continueConfigPath, continueMode: agent.continueMode, runtimeSandboxMode: agent.runtimeSandboxMode, subagentSmartRoutingEnabled: settings.subagentSmartRoutingEnabled, knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel, knowledgeEmbeddingApiKeyConfigured: Boolean( embeddingEnvironmentApiKey ?? embeddingStoredApiKey ), knowledgeEmbeddingCredentialSource: embeddingEnvironmentApiKey ? 'environment' : embeddingStoredApiKey ? 'encrypted' : 'none', workspacePath: agent.workspacePath, apiKeyConfigured: Boolean(effective.apiKey), credentialSource: effective.credentialSource, modelProfiles, defaultModelProfileId: settings.defaultModelProfileId, opencodeModelSource: agent.opencodeBaseUrl ? { kind: 'platform' } : settings.opencodeModelSource, continueModelSource: settings.continueModelSource, secureStorageAvailable: this.cipher.isAvailable(), toolApproval: settings.toolApproval, warning: this.loadWarning } } async getPublicSettings(): Promise { return this.toPublicSettings(await this.load()) } async getResolvedSettings(): Promise { const settings = await this.load() const effective = this.resolveEffectiveModelSettings(settings) const agent = this.resolveAgentSettings(settings) const opencodeModelProfile = !agent.opencodeBaseUrl && settings.opencodeModelSource.kind === 'profile' ? this.resolveProfile( settings, settings.opencodeModelSource.profileId ) : undefined const continueModelProfile = settings.continueModelSource.kind === 'profile' ? this.resolveProfile( settings, settings.continueModelSource.profileId ) : undefined return { provider: settings.provider, modelBaseUrl: effective.baseUrl, modelName: effective.model, modelProtocol: effective.protocol, modelAuthentication: effective.authentication, supportsImageInput: effective.supportsImageInput, imageGenerationQuality: effective.imageGenerationQuality, apiKey: effective.apiKey, modelProfiles: settings.modelProfiles.map((profile) => { const resolved = this.resolveProfile(settings, profile.id) if (!resolved) { throw new Error(`模型连接不存在:${profile.id}`) } return resolved }), defaultModelProfileId: settings.defaultModelProfileId, opencodeModelProfile, continueModelProfile, ...agent, subagentSmartRoutingEnabled: settings.subagentSmartRoutingEnabled, knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel, knowledgeEmbeddingApiKey: this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() || this.getStoredEmbeddingApiKey(settings), toolApproval: settings.toolApproval } } update(input: RuntimeSettingsInput): Promise { const operation = this.updateQueue.then(() => this.performUpdate(input)) this.updateQueue = operation.then( () => undefined, () => undefined ) return operation } private async performUpdate( input: RuntimeSettingsInput ): Promise { const current = await this.load() const currentDefault = current.modelProfiles.find( (profile) => profile.id === current.defaultModelProfileId ) ?? current.modelProfiles[0] if (!currentDefault) { throw new Error('默认模型连接不存在') } const profileInputs = input.modelProfiles ?? current.modelProfiles.map((profile) => profile.id === currentDefault.id ? { id: profile.id, name: profile.name, baseUrl: input.modelBaseUrl, modelName: input.modelName, protocol: input.modelProtocol, authentication: input.modelAuthentication, supportsImageInput: profile.supportsImageInput, imageGenerationQuality: input.imageGenerationQuality, apiKey: input.apiKey } : { id: profile.id, name: profile.name, baseUrl: profile.baseUrl, modelName: profile.modelName, protocol: profile.protocol, authentication: profile.authentication, supportsImageInput: profile.supportsImageInput, imageGenerationQuality: profile.imageGenerationQuality, apiKey: { action: 'keep' as const } } ) if ( ( profileInputs.some( (profile) => profile.authentication === 'api-key' && profile.apiKey.action === 'replace' ) || input.knowledgeEmbeddingApiKey?.action === 'replace' ) && !this.cipher.isAvailable() ) { throw new Error( '当前系统安全存储不可用,API Key 未保存。请启用系统密钥服务或使用环境变量。' ) } const modelProfiles: StoredSettings['modelProfiles'] = profileInputs.map((profile) => { const existing = current.modelProfiles.find( (candidate) => candidate.id === profile.id ) const normalizedBaseUrl = normalizeModelBaseUrl(profile.baseUrl) if ( profile.authentication === 'api-key' && profile.apiKey.action === 'keep' && existing?.credential && new URL(existing.baseUrl).origin !== new URL(normalizedBaseUrl).origin ) { throw new Error( `模型连接“${profile.name}”的服务地址已更改,请重新输入或清除 API Key` ) } const nextProfile: StoredSettings['modelProfiles'][number] = { id: profile.id, name: profile.name, baseUrl: normalizedBaseUrl, modelName: profile.modelName, protocol: profile.protocol, authentication: profile.authentication, supportsImageInput: profile.supportsImageInput ?? false, imageGenerationQuality: profile.imageGenerationQuality } if ( profile.authentication === 'api-key' && profile.apiKey.action === 'keep' && existing?.credential ) { nextProfile.credential = existing.credential } else if ( profile.authentication === 'api-key' && profile.apiKey.action === 'replace' ) { nextProfile.credential = { formatVersion: 1, scheme: 'electron-safe-storage', ciphertextBase64: this.cipher .encrypt( JSON.stringify({ version: 1, apiKey: profile.apiKey.value, origin: new URL(normalizedBaseUrl).origin }) ) .toString('base64') } } return nextProfile }) const embeddingEndpoint = new URL( input.knowledgeEmbeddingBaseUrl ).toString() const embeddingApiKeyUpdate = input.knowledgeEmbeddingApiKey ?? { action: 'keep' as const } if ( embeddingApiKeyUpdate.action === 'keep' && current.knowledgeEmbeddingCredential && current.knowledgeEmbeddingBaseUrl !== embeddingEndpoint ) { throw new Error( '向量接口 URL 已更改,请重新输入或清除 API Key' ) } let knowledgeEmbeddingCredential: StoredSettings['knowledgeEmbeddingCredential'] if ( embeddingApiKeyUpdate.action === 'keep' && current.knowledgeEmbeddingCredential ) { knowledgeEmbeddingCredential = current.knowledgeEmbeddingCredential } else if (embeddingApiKeyUpdate.action === 'replace') { knowledgeEmbeddingCredential = { formatVersion: 1, scheme: 'electron-safe-storage', ciphertextBase64: this.cipher .encrypt( JSON.stringify({ version: 1, apiKey: embeddingApiKeyUpdate.value, endpoint: embeddingEndpoint }) ) .toString('base64') } } const [ opencodeBinaryPath, opencodeConfigPath, continueBinaryPath, continueConfigPath ] = await Promise.all([ this.canonicalizeRuntimeFile( input.opencodeBinaryPath, 'OpenCode 可执行文件' ), this.canonicalizeRuntimeFile( input.opencodeConfigPath, 'OpenCode 配置文件' ), this.canonicalizeRuntimeFile( input.continueBinaryPath, 'Continue 可执行文件' ), this.canonicalizeRuntimeFile( input.continueConfigPath, 'Continue 配置文件' ) ]) const defaultModelProfileId = input.defaultModelProfileId ?? (input.modelProfiles ? modelProfiles[0]!.id : current.defaultModelProfileId) if ( !modelProfiles.some( (profile) => profile.id === defaultModelProfileId ) ) { throw new Error('默认模型连接不存在') } const validateRuntimeSource = ( source: RuntimeSettings['opencodeModelSource'], runtimeLabel: 'OpenCode' | 'Continue' ): void => { if (source.kind === 'platform') { return } const profile = modelProfiles.find( (candidate) => candidate.id === source.profileId ) if (!profile) { throw new Error(`${runtimeLabel} 引用的模型连接不存在`) } if (!isAgentRuntimeModelProtocol(profile.protocol)) { throw new Error( `${runtimeLabel} 模型连接仅支持文本对话协议,不支持图像生成协议` ) } } const opencodeBaseUrl = input.opencodeBaseUrl ? normalizeModelBaseUrl(input.opencodeBaseUrl) : '' const fallbackRuntimeProfileId = modelProfiles.find( (profile) => isAgentRuntimeModelProtocol(profile.protocol) )?.id const repairRuntimeSource = ( source: RuntimeSettings['opencodeModelSource'] ): RuntimeSettings['opencodeModelSource'] => { if (source.kind === 'platform') { return source } const profile = modelProfiles.find( (candidate) => candidate.id === source.profileId ) if (profile && isAgentRuntimeModelProtocol(profile.protocol)) { return source } return fallbackRuntimeProfileId ? { kind: 'profile', profileId: fallbackRuntimeProfileId } : { kind: 'platform' } } const requestedOpenCodeSource = input.opencodeModelSource ? input.opencodeModelSource : repairRuntimeSource(current.opencodeModelSource) const opencodeModelSource = opencodeBaseUrl ? ({ kind: 'platform' } as const) : requestedOpenCodeSource const continueModelSource = input.continueModelSource ? input.continueModelSource : repairRuntimeSource(current.continueModelSource) validateRuntimeSource(opencodeModelSource, 'OpenCode') validateRuntimeSource(continueModelSource, 'Continue') const next: StoredSettings = { ...current, version: 13, provider: input.provider, modelProfiles, defaultModelProfileId, opencodeModelSource, continueModelSource, opencodeBaseUrl, opencodeEmbedded: !opencodeBaseUrl, opencodeBinaryPath, opencodeConfigPath, continueBinaryPath, continueConfigPath, continueMode: input.continueMode, runtimeSandboxMode: input.runtimeSandboxMode, subagentSmartRoutingEnabled: input.subagentSmartRoutingEnabled ?? current.subagentSmartRoutingEnabled, knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: embeddingEndpoint, knowledgeEmbeddingModel: input.knowledgeEmbeddingModel, knowledgeEmbeddingCredential, workspacePath: input.workspacePath, toolApproval: input.toolApproval } await mkdir(dirname(this.filePath), { recursive: true }) const temporaryPath = `${this.filePath}.${process.pid}.tmp` try { await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }) await rename(temporaryPath, this.filePath) } finally { await rm(temporaryPath, { force: true }) } this.settings = next this.loadWarning = undefined return this.toPublicSettings(next) } private async canonicalizeRuntimeFile( filePath: string, label: string ): Promise { if (!filePath) { return '' } try { const canonicalPath = await realpath(filePath) if (!(await stat(canonicalPath)).isFile()) { throw new Error('Not a regular file') } return canonicalPath } catch { throw new Error(`${label}不存在、不可访问或不是普通文件`) } } }