1791 lines
59 KiB
TypeScript
1791 lines
59 KiB
TypeScript
import {
|
|
readFile,
|
|
realpath,
|
|
stat
|
|
} from 'node:fs/promises'
|
|
import { homedir } from 'node:os'
|
|
import { z } from 'zod'
|
|
import {
|
|
continueModeSchema,
|
|
defaultModelProfileId,
|
|
defaultRuntimeSettings,
|
|
imageGenerationQualitySchema,
|
|
isAgentRuntimeModelProtocol,
|
|
isDeepSeekHarnessModelProfile,
|
|
modelAuthenticationSchema,
|
|
modelProtocolSchema,
|
|
runtimeModelSourceSchema,
|
|
runtimePathSchema,
|
|
runtimeProviderSchema,
|
|
runtimeSandboxModeSchema,
|
|
toolApprovalPolicySchema,
|
|
type RuntimeSettings,
|
|
type RuntimeSettingsInput
|
|
} from '../shared/contracts'
|
|
import {
|
|
settingsWarningsEqual,
|
|
type SettingsWarning
|
|
} from '../shared/settings-warning-contracts'
|
|
import {
|
|
assertSupportedSettingsVersion,
|
|
isolateCorruptSettingsFile,
|
|
isMissingFileError,
|
|
UnsupportedSettingsVersionError,
|
|
writeJsonFileAtomically
|
|
} from './settings-file-utils'
|
|
import {
|
|
decryptSettingsCredential,
|
|
encryptedSettingsCredentialSchema,
|
|
encryptSettingsCredential,
|
|
type SettingsCredentialCipher
|
|
} from './settings-credential-cipher'
|
|
|
|
const credentialSchema = encryptedSettingsCredentialSchema.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 version13StoredSettingsSchema = version12StoredSettingsSchema
|
|
.omit({ version: true, modelProfiles: true })
|
|
.extend({
|
|
version: z.literal(13),
|
|
modelProfiles: z
|
|
.array(currentStoredModelProfileSchema)
|
|
.min(1)
|
|
.max(20)
|
|
})
|
|
|
|
const version14StoredSettingsSchema = version13StoredSettingsSchema
|
|
.omit({ version: true })
|
|
.extend({
|
|
version: z.literal(14),
|
|
knowledgeRerankEnabled: z.boolean(),
|
|
knowledgeRerankEndpoint: z
|
|
.string()
|
|
.url()
|
|
.max(2_048)
|
|
.refine(
|
|
(value) => ['http:', 'https:'].includes(new URL(value).protocol)
|
|
),
|
|
knowledgeRerankModel: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(256)
|
|
.regex(/^[\w./:-]+$/u),
|
|
knowledgeRerankCredential: credentialSchema
|
|
})
|
|
|
|
const version15StoredSettingsSchema = version14StoredSettingsSchema
|
|
.omit({ version: true })
|
|
.extend({
|
|
version: z.literal(15),
|
|
deepseekHarnessModelSource: runtimeModelSourceSchema,
|
|
deepseekHarnessBinaryPath: runtimePathSchema.default('')
|
|
})
|
|
|
|
const storedSettingsSchema = version15StoredSettingsSchema
|
|
.omit({ version: true, deepseekHarnessBinaryPath: true })
|
|
.extend({
|
|
version: z.literal(16)
|
|
})
|
|
|
|
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
|
type Version15StoredSettings = z.infer<
|
|
typeof version15StoredSettingsSchema
|
|
>
|
|
type Version10StoredSettings = z.infer<
|
|
typeof version10StoredSettingsSchema
|
|
>
|
|
type Version11StoredSettings = z.infer<
|
|
typeof version11StoredSettingsSchema
|
|
>
|
|
type Version12StoredSettings = z.infer<
|
|
typeof version12StoredSettingsSchema
|
|
>
|
|
type Version13StoredSettings = z.infer<
|
|
typeof version13StoredSettingsSchema
|
|
>
|
|
type Version14StoredSettings = z.infer<
|
|
typeof version14StoredSettingsSchema
|
|
>
|
|
|
|
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()
|
|
})
|
|
|
|
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
|
|
const platformDeepSeekProfileId = 'goodbuddy-platform-deepseek'
|
|
|
|
export type CredentialCipher = SettingsCredentialCipher
|
|
|
|
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
|
|
deepseekHarnessModelProfile?: 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
|
|
knowledgeRerankEnabled: boolean
|
|
knowledgeRerankEndpoint: string
|
|
knowledgeRerankModel: string
|
|
knowledgeRerankApiKey?: string
|
|
workspacePath: string
|
|
toolApproval: RuntimeSettings['toolApproval']
|
|
}
|
|
|
|
export type RuntimePolicySettings = Pick<
|
|
ResolvedRuntimeSettings,
|
|
'subagentSmartRoutingEnabled' | '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: 16,
|
|
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,
|
|
deepseekHarnessModelSource: { kind: 'platform' },
|
|
runtimeSandboxMode: defaultRuntimeSettings.runtimeSandboxMode,
|
|
subagentSmartRoutingEnabled:
|
|
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
|
knowledgeEmbeddingEnabled:
|
|
defaultRuntimeSettings.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl:
|
|
defaultRuntimeSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel:
|
|
defaultRuntimeSettings.knowledgeEmbeddingModel,
|
|
knowledgeRerankEnabled:
|
|
defaultRuntimeSettings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint:
|
|
defaultRuntimeSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel:
|
|
defaultRuntimeSettings.knowledgeRerankModel,
|
|
workspacePath: defaultRuntimeSettings.workspacePath,
|
|
toolApproval: defaultRuntimeSettings.toolApproval
|
|
}
|
|
|
|
function migrateContinueCommand(command: string): string {
|
|
const value = command.trim()
|
|
return value === 'cn' ? '' : value
|
|
}
|
|
|
|
function normalizeModelBaseUrl(value: string): string {
|
|
const url = new URL(value)
|
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
throw new Error('模型服务地址必须使用 HTTP 或 HTTPS')
|
|
}
|
|
url.pathname = url.pathname.replace(/\/+$/u, '')
|
|
return url.toString().replace(/\/$/u, '')
|
|
}
|
|
|
|
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 migrateVersion13({
|
|
...settings,
|
|
version: 13,
|
|
modelProfiles: settings.modelProfiles.map((profile) => ({
|
|
...profile,
|
|
supportsImageInput: false
|
|
}))
|
|
})
|
|
}
|
|
|
|
function migrateVersion13(
|
|
settings: Version13StoredSettings
|
|
): StoredSettings {
|
|
return migrateVersion14({
|
|
...settings,
|
|
version: 14,
|
|
knowledgeRerankEnabled:
|
|
defaultRuntimeSettings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint:
|
|
defaultRuntimeSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel:
|
|
defaultRuntimeSettings.knowledgeRerankModel,
|
|
knowledgeRerankCredential: undefined
|
|
})
|
|
}
|
|
|
|
function migrateVersion14(
|
|
settings: Version14StoredSettings
|
|
): StoredSettings {
|
|
return {
|
|
...settings,
|
|
version: 16,
|
|
deepseekHarnessModelSource: { kind: 'platform' }
|
|
}
|
|
}
|
|
|
|
function migrateVersion15(
|
|
settings: Version15StoredSettings
|
|
): StoredSettings {
|
|
const {
|
|
deepseekHarnessBinaryPath: _obsolete,
|
|
...current
|
|
} = settings
|
|
void _obsolete
|
|
return {
|
|
...current,
|
|
version: 16
|
|
}
|
|
}
|
|
|
|
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 modelProfiles = settings.modelProfiles.map((profile) => ({
|
|
...profile,
|
|
baseUrl: normalizeModelBaseUrl(profile.baseUrl)
|
|
}))
|
|
const fallbackProfileId = compatibleTextProfileId({
|
|
modelProfiles,
|
|
defaultModelProfileId: settings.defaultModelProfileId
|
|
})
|
|
const normalizeSource = (
|
|
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 fallbackProfileId
|
|
? { kind: 'profile', profileId: fallbackProfileId }
|
|
: { kind: 'platform' }
|
|
}
|
|
const normalizeDeepSeekHarnessSource = (
|
|
source: RuntimeSettings['deepseekHarnessModelSource']
|
|
): NonNullable<RuntimeSettings['deepseekHarnessModelSource']> => {
|
|
if (!source || source.kind === 'platform') {
|
|
return { kind: 'platform' }
|
|
}
|
|
const profile = modelProfiles.find(
|
|
(candidate) => candidate.id === source.profileId
|
|
)
|
|
return profile && isDeepSeekHarnessModelProfile(profile)
|
|
? source
|
|
: { kind: 'platform' }
|
|
}
|
|
const opencodeBaseUrl = settings.opencodeBaseUrl.trim()
|
|
if (opencodeBaseUrl) {
|
|
const url = new URL(opencodeBaseUrl)
|
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
throw new Error('OpenCode 地址必须使用 HTTP 或 HTTPS')
|
|
}
|
|
}
|
|
const defaultModelProfileId = modelProfiles.some(
|
|
(profile) => profile.id === settings.defaultModelProfileId
|
|
)
|
|
? settings.defaultModelProfileId
|
|
: modelProfiles[0]!.id
|
|
|
|
return {
|
|
...settings,
|
|
modelProfiles,
|
|
provider:
|
|
settings.provider === 'auto' ? 'model' : settings.provider,
|
|
defaultModelProfileId,
|
|
opencodeModelSource: opencodeBaseUrl
|
|
? { kind: 'platform' }
|
|
: normalizeSource(settings.opencodeModelSource),
|
|
continueModelSource: normalizeSource(
|
|
settings.continueModelSource
|
|
),
|
|
deepseekHarnessModelSource: normalizeDeepSeekHarnessSource(
|
|
settings.deepseekHarnessModelSource
|
|
),
|
|
opencodeBaseUrl,
|
|
opencodeEmbedded: !opencodeBaseUrl
|
|
}
|
|
}
|
|
|
|
function migrateVersion4(
|
|
settings: z.infer<typeof version4StoredSettingsSchema>
|
|
): 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<typeof version5StoredSettingsSchema>
|
|
): 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<typeof version6StoredSettingsSchema>
|
|
): StoredSettings {
|
|
let knowledgeEmbeddingBaseUrl: string =
|
|
defaultRuntimeSettings.knowledgeEmbeddingBaseUrl
|
|
try {
|
|
const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl)
|
|
if (['http:', 'https:'].includes(endpoint.protocol)) {
|
|
const path = endpoint.pathname.replace(/\/+$/u, '')
|
|
if (!/\/v1\/embeddings$/iu.test(path)) {
|
|
endpoint.pathname = `${path}/v1/embeddings`
|
|
}
|
|
knowledgeEmbeddingBaseUrl = endpoint.toString()
|
|
}
|
|
} catch {
|
|
// Preserve the rest of the legacy settings and repair only this endpoint.
|
|
}
|
|
return migrateVersion10({
|
|
...settings,
|
|
version: 10,
|
|
subagentSmartRoutingEnabled:
|
|
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
|
intranetCompatibilityEnabled: true,
|
|
knowledgeEmbeddingBaseUrl,
|
|
modelProfiles: settings.modelProfiles.map((profile) => ({
|
|
...profile,
|
|
imageGenerationQuality:
|
|
defaultRuntimeSettings.imageGenerationQuality
|
|
}))
|
|
})
|
|
}
|
|
|
|
function migrateVersion7(
|
|
settings: z.infer<typeof version7StoredSettingsSchema>
|
|
): 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<typeof version8StoredSettingsSchema>
|
|
): StoredSettings {
|
|
return migrateVersion10({
|
|
...settings,
|
|
version: 10,
|
|
subagentSmartRoutingEnabled: false,
|
|
intranetCompatibilityEnabled: true
|
|
})
|
|
}
|
|
|
|
function migrateVersion9(
|
|
settings: z.infer<typeof version9StoredSettingsSchema>
|
|
): StoredSettings {
|
|
return migrateVersion10({
|
|
...settings,
|
|
version: 10,
|
|
intranetCompatibilityEnabled: true
|
|
})
|
|
}
|
|
|
|
export class RuntimeSettingsStore {
|
|
private settings?: StoredSettings
|
|
private settingsLoad?: Promise<StoredSettings>
|
|
private loadWarnings: SettingsWarning[] = []
|
|
private updateQueue: Promise<void> = Promise.resolve()
|
|
|
|
constructor(
|
|
private readonly filePath: string,
|
|
private readonly cipher: CredentialCipher,
|
|
private readonly environment: NodeJS.ProcessEnv = process.env
|
|
) {}
|
|
|
|
private load(): Promise<StoredSettings> {
|
|
if (this.settings) {
|
|
return Promise.resolve(this.settings)
|
|
}
|
|
if (!this.settingsLoad) {
|
|
this.settingsLoad = this.readSettings().finally(() => {
|
|
this.settingsLoad = undefined
|
|
})
|
|
}
|
|
return this.settingsLoad
|
|
}
|
|
|
|
private async readSettings(): Promise<StoredSettings> {
|
|
try {
|
|
const contents = await readFile(this.filePath, 'utf8')
|
|
const parsed: unknown = JSON.parse(contents)
|
|
assertSupportedSettingsVersion(
|
|
parsed,
|
|
16,
|
|
(version) =>
|
|
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
|
)
|
|
const current = storedSettingsSchema.safeParse(parsed)
|
|
if (current.success) {
|
|
this.settings = current.data
|
|
} else {
|
|
const version15 =
|
|
version15StoredSettingsSchema.safeParse(parsed)
|
|
if (version15.success) {
|
|
this.settings = migrateVersion15(version15.data)
|
|
} else {
|
|
const version14 =
|
|
version14StoredSettingsSchema.safeParse(parsed)
|
|
if (version14.success) {
|
|
this.settings = migrateVersion14(version14.data)
|
|
} else {
|
|
const version13 =
|
|
version13StoredSettingsSchema.safeParse(parsed)
|
|
if (version13.success) {
|
|
this.settings = migrateVersion13(version13.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 UnsupportedSettingsVersionError) {
|
|
throw error
|
|
}
|
|
if (!isMissingFileError(error)) {
|
|
await isolateCorruptSettingsFile(
|
|
this.filePath,
|
|
'Runtime 设置已损坏且无法隔离'
|
|
)
|
|
this.loadWarnings = [{ code: 'runtime-settings-recovered' }]
|
|
}
|
|
this.settings = { ...defaultSettings }
|
|
}
|
|
return this.settings
|
|
}
|
|
|
|
private getStoredApiKey(
|
|
profile: StoredSettings['modelProfiles'][number]
|
|
): string | undefined {
|
|
if (!profile.credential) {
|
|
return undefined
|
|
}
|
|
const warning = (code: SettingsWarning['code']): undefined => {
|
|
this.addWarning({ code, subject: profile.name })
|
|
return undefined
|
|
}
|
|
if (!this.cipher.isAvailable()) {
|
|
return warning('runtime-model-credential-unreadable')
|
|
}
|
|
try {
|
|
const payload = credentialPayloadSchema.parse(
|
|
decryptSettingsCredential(this.cipher, profile.credential)
|
|
)
|
|
if (payload.origin !== new URL(profile.baseUrl).origin) {
|
|
return warning('runtime-model-credential-binding-mismatch')
|
|
}
|
|
this.removeWarnings(
|
|
[
|
|
'runtime-model-credential-unreadable',
|
|
'runtime-model-credential-binding-mismatch'
|
|
],
|
|
profile.name
|
|
)
|
|
return payload.apiKey
|
|
} catch {
|
|
return warning('runtime-model-credential-unreadable')
|
|
}
|
|
}
|
|
|
|
private getStoredEmbeddingApiKey(
|
|
settings: StoredSettings
|
|
): string | undefined {
|
|
if (!settings.knowledgeEmbeddingCredential) {
|
|
return undefined
|
|
}
|
|
if (!this.cipher.isAvailable()) {
|
|
this.addWarning({
|
|
code: 'runtime-embedding-credential-unreadable'
|
|
})
|
|
return undefined
|
|
}
|
|
try {
|
|
const payload = embeddingCredentialPayloadSchema.parse(
|
|
decryptSettingsCredential(
|
|
this.cipher,
|
|
settings.knowledgeEmbeddingCredential
|
|
)
|
|
)
|
|
if (payload.endpoint !== settings.knowledgeEmbeddingBaseUrl) {
|
|
this.addWarning({
|
|
code: 'runtime-embedding-credential-binding-mismatch'
|
|
})
|
|
return undefined
|
|
}
|
|
this.removeWarnings([
|
|
'runtime-embedding-credential-unreadable',
|
|
'runtime-embedding-credential-binding-mismatch'
|
|
])
|
|
return payload.apiKey
|
|
} catch {
|
|
this.addWarning({
|
|
code: 'runtime-embedding-credential-unreadable'
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
private getStoredRerankApiKey(
|
|
settings: StoredSettings
|
|
): string | undefined {
|
|
if (!settings.knowledgeRerankCredential) {
|
|
return undefined
|
|
}
|
|
if (!this.cipher.isAvailable()) {
|
|
this.addWarning({
|
|
code: 'runtime-rerank-credential-unreadable'
|
|
})
|
|
return undefined
|
|
}
|
|
try {
|
|
const payload = rerankCredentialPayloadSchema.parse(
|
|
decryptSettingsCredential(
|
|
this.cipher,
|
|
settings.knowledgeRerankCredential
|
|
)
|
|
)
|
|
if (payload.endpoint !== settings.knowledgeRerankEndpoint) {
|
|
this.addWarning({
|
|
code: 'runtime-rerank-credential-binding-mismatch'
|
|
})
|
|
return undefined
|
|
}
|
|
this.removeWarnings([
|
|
'runtime-rerank-credential-unreadable',
|
|
'runtime-rerank-credential-binding-mismatch'
|
|
])
|
|
return payload.apiKey
|
|
} catch {
|
|
this.addWarning({
|
|
code: 'runtime-rerank-credential-unreadable'
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
private addWarning(warning: SettingsWarning): void {
|
|
if (
|
|
!this.loadWarnings.some(
|
|
(current) => settingsWarningsEqual(current, warning)
|
|
)
|
|
) {
|
|
this.loadWarnings.push(warning)
|
|
}
|
|
}
|
|
|
|
private removeWarnings(
|
|
codes: readonly SettingsWarning['code'][],
|
|
subject?: string
|
|
): void {
|
|
this.loadWarnings = this.loadWarnings.filter(
|
|
(warning) =>
|
|
!(
|
|
codes.includes(warning.code) &&
|
|
(subject === undefined || warning.subject === subject)
|
|
)
|
|
)
|
|
}
|
|
|
|
private getEnvironmentApiKey(): string | undefined {
|
|
return (
|
|
this.environment.GOODBUDDY_MODEL_API_KEY?.trim() ||
|
|
this.environment.GOODBUDDY_BIGTOKEN_API_KEY?.trim() ||
|
|
undefined
|
|
)
|
|
}
|
|
|
|
private resolvePlatformDeepSeekProfile(): ResolvedModelProfile | undefined {
|
|
const apiKey = this.environment.GOODBUDDY_MODEL_API_KEY?.trim()
|
|
const baseUrl = this.environment.GOODBUDDY_MODEL_BASE_URL?.trim()
|
|
const modelName = this.environment.GOODBUDDY_MODEL_NAME?.trim()
|
|
if (!apiKey || !baseUrl || !modelName) {
|
|
return undefined
|
|
}
|
|
try {
|
|
const endpoint = new URL(baseUrl)
|
|
if (
|
|
endpoint.protocol !== 'https:' ||
|
|
endpoint.hostname.toLowerCase() !== 'api.deepseek.com' ||
|
|
endpoint.port ||
|
|
endpoint.pathname !== '/' ||
|
|
endpoint.search ||
|
|
endpoint.hash ||
|
|
endpoint.username ||
|
|
endpoint.password
|
|
) {
|
|
return undefined
|
|
}
|
|
} catch {
|
|
return undefined
|
|
}
|
|
return {
|
|
id: platformDeepSeekProfileId,
|
|
name: '平台 DeepSeek',
|
|
baseUrl,
|
|
modelName,
|
|
protocol: 'openai-chat-completions',
|
|
authentication: 'api-key',
|
|
supportsImageInput: false,
|
|
imageGenerationQuality: 'auto',
|
|
apiKey
|
|
}
|
|
}
|
|
|
|
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' && !environmentApiKey
|
|
? 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
|
|
const credentialSource: RuntimeSettings['credentialSource'] =
|
|
environmentApiKey
|
|
? 'environment'
|
|
: storedApiKey
|
|
? 'encrypted'
|
|
: profile.credential
|
|
? 'unreadable'
|
|
: 'none'
|
|
return {
|
|
apiKey: environmentApiKey ?? storedApiKey,
|
|
baseUrl,
|
|
model,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
supportsImageInput: profile.supportsImageInput,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
credentialSource
|
|
}
|
|
}
|
|
|
|
private resolveModelProfiles(
|
|
settings: StoredSettings,
|
|
effective: ReturnType<
|
|
RuntimeSettingsStore['resolveEffectiveModelSettings']
|
|
>
|
|
): ResolvedModelProfile[] {
|
|
return settings.modelProfiles.map((profile) =>
|
|
profile.id === settings.defaultModelProfileId
|
|
? {
|
|
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
|
|
}
|
|
: {
|
|
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 environmentApiKeyConfigured = Boolean(
|
|
this.getEnvironmentApiKey()
|
|
)
|
|
const resolvedModelProfiles = this.resolveModelProfiles(
|
|
settings,
|
|
effective
|
|
)
|
|
const resolvedProfilesById = new Map(
|
|
resolvedModelProfiles.map((profile) => [profile.id, profile])
|
|
)
|
|
const modelProfiles = settings.modelProfiles.map((profile) => {
|
|
const isDefault = profile.id === settings.defaultModelProfileId
|
|
const resolved = resolvedProfilesById.get(profile.id)
|
|
if (!resolved) {
|
|
throw new Error(`模型连接不存在:${profile.id}`)
|
|
}
|
|
const apiKey = resolved.apiKey
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: resolved.baseUrl,
|
|
modelName: resolved.modelName,
|
|
protocol: resolved.protocol,
|
|
authentication: resolved.authentication,
|
|
supportsImageInput: resolved.supportsImageInput,
|
|
imageGenerationQuality:
|
|
resolved.imageGenerationQuality ??
|
|
defaultRuntimeSettings.imageGenerationQuality,
|
|
apiKeyConfigured: Boolean(apiKey),
|
|
credentialSource: isDefault
|
|
? effective.credentialSource
|
|
: apiKey
|
|
? ('encrypted' as const)
|
|
: profile.credential
|
|
? ('unreadable' as const)
|
|
: ('none' as const)
|
|
}
|
|
})
|
|
const configuredModelProfiles = settings.modelProfiles.map((profile) => {
|
|
const environmentManaged =
|
|
profile.id === settings.defaultModelProfileId &&
|
|
profile.authentication === 'api-key' &&
|
|
environmentApiKeyConfigured
|
|
const apiKey = resolvedProfilesById.get(profile.id)?.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 ??
|
|
defaultRuntimeSettings.imageGenerationQuality,
|
|
apiKeyConfigured: environmentManaged || Boolean(apiKey),
|
|
credentialSource: environmentManaged
|
|
? ('environment' as const)
|
|
: apiKey
|
|
? ('encrypted' as const)
|
|
: profile.credential
|
|
? ('unreadable' as const)
|
|
: ('none' as const)
|
|
}
|
|
})
|
|
const embeddingEnvironmentApiKey =
|
|
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
|
|
const embeddingStoredApiKey = embeddingEnvironmentApiKey
|
|
? undefined
|
|
: this.getStoredEmbeddingApiKey(settings)
|
|
const rerankEnvironmentApiKey =
|
|
this.environment.GOODBUDDY_RERANK_API_KEY?.trim()
|
|
const rerankStoredApiKey = rerankEnvironmentApiKey
|
|
? undefined
|
|
: this.getStoredRerankApiKey(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'
|
|
: settings.knowledgeEmbeddingCredential
|
|
? 'unreadable'
|
|
: 'none',
|
|
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: settings.knowledgeRerankModel,
|
|
knowledgeRerankApiKeyConfigured: Boolean(
|
|
rerankEnvironmentApiKey ?? rerankStoredApiKey
|
|
),
|
|
knowledgeRerankCredentialSource: rerankEnvironmentApiKey
|
|
? 'environment'
|
|
: rerankStoredApiKey
|
|
? 'encrypted'
|
|
: settings.knowledgeRerankCredential
|
|
? 'unreadable'
|
|
: '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,
|
|
deepseekHarnessModelSource:
|
|
settings.deepseekHarnessModelSource,
|
|
secureStorageAvailable: this.cipher.isAvailable(),
|
|
toolApproval: settings.toolApproval,
|
|
configured: {
|
|
modelProfiles: configuredModelProfiles,
|
|
opencodeBaseUrl: settings.opencodeBaseUrl,
|
|
opencodeBinaryPath: settings.opencodeBinaryPath,
|
|
opencodeConfigPath: settings.opencodeConfigPath,
|
|
continueBinaryPath: settings.continueBinaryPath,
|
|
continueConfigPath: settings.continueConfigPath,
|
|
workspacePath: settings.workspacePath || homedir(),
|
|
opencodeModelSource: settings.opencodeModelSource,
|
|
continueModelSource: settings.continueModelSource,
|
|
deepseekHarnessModelSource:
|
|
settings.deepseekHarnessModelSource
|
|
},
|
|
...(this.loadWarnings.length > 0
|
|
? { warnings: [...this.loadWarnings] }
|
|
: {})
|
|
}
|
|
}
|
|
|
|
async getPublicSettings(): Promise<RuntimeSettings> {
|
|
return this.toPublicSettings(await this.load())
|
|
}
|
|
|
|
async getPolicySettings(): Promise<RuntimePolicySettings> {
|
|
const settings = await this.load()
|
|
return {
|
|
subagentSmartRoutingEnabled:
|
|
settings.subagentSmartRoutingEnabled,
|
|
toolApproval: settings.toolApproval
|
|
}
|
|
}
|
|
|
|
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
|
|
const settings = await this.load()
|
|
const effective = this.resolveEffectiveModelSettings(settings)
|
|
const agent = this.resolveAgentSettings(settings)
|
|
const modelProfiles = this.resolveModelProfiles(settings, effective)
|
|
const profilesById = new Map(
|
|
modelProfiles.map((profile) => [profile.id, profile])
|
|
)
|
|
const opencodeModelProfile =
|
|
!agent.opencodeBaseUrl &&
|
|
settings.opencodeModelSource.kind === 'profile'
|
|
? profilesById.get(settings.opencodeModelSource.profileId)
|
|
: undefined
|
|
const continueModelProfile =
|
|
settings.continueModelSource.kind === 'profile'
|
|
? profilesById.get(settings.continueModelSource.profileId)
|
|
: undefined
|
|
const deepseekHarnessModelProfile =
|
|
settings.deepseekHarnessModelSource.kind === 'profile'
|
|
? profilesById.get(
|
|
settings.deepseekHarnessModelSource.profileId
|
|
)
|
|
: this.resolvePlatformDeepSeekProfile()
|
|
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,
|
|
defaultModelProfileId: settings.defaultModelProfileId,
|
|
opencodeModelProfile,
|
|
continueModelProfile,
|
|
deepseekHarnessModelProfile,
|
|
...agent,
|
|
subagentSmartRoutingEnabled:
|
|
settings.subagentSmartRoutingEnabled,
|
|
knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
|
|
knowledgeEmbeddingApiKey:
|
|
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() ||
|
|
this.getStoredEmbeddingApiKey(settings),
|
|
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: settings.knowledgeRerankModel,
|
|
knowledgeRerankApiKey:
|
|
this.environment.GOODBUDDY_RERANK_API_KEY?.trim() ||
|
|
this.getStoredRerankApiKey(settings),
|
|
toolApproval: settings.toolApproval
|
|
}
|
|
}
|
|
|
|
update(input: RuntimeSettingsInput): Promise<RuntimeSettings> {
|
|
const operation = this.updateQueue.then(() => this.performUpdate(input))
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation
|
|
}
|
|
|
|
private async performUpdate(
|
|
input: RuntimeSettingsInput
|
|
): Promise<RuntimeSettings> {
|
|
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' ||
|
|
input.knowledgeRerankApiKey?.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 environmentManaged =
|
|
profile.id === current.defaultModelProfileId &&
|
|
profile.authentication === 'api-key' &&
|
|
profile.apiKey.action === 'keep' &&
|
|
existing !== undefined &&
|
|
Boolean(this.getEnvironmentApiKey())
|
|
const normalizedBaseUrl = normalizeModelBaseUrl(
|
|
environmentManaged ? existing.baseUrl : 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: environmentManaged
|
|
? existing.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 = encryptSettingsCredential(
|
|
this.cipher,
|
|
{
|
|
version: 1,
|
|
apiKey: profile.apiKey.value,
|
|
origin: new URL(normalizedBaseUrl).origin
|
|
}
|
|
)
|
|
}
|
|
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 = encryptSettingsCredential(
|
|
this.cipher,
|
|
{
|
|
version: 1,
|
|
apiKey: embeddingApiKeyUpdate.value,
|
|
endpoint: embeddingEndpoint
|
|
}
|
|
)
|
|
}
|
|
|
|
const rerankEndpoint = new URL(
|
|
input.knowledgeRerankEndpoint
|
|
).toString()
|
|
const rerankApiKeyUpdate =
|
|
input.knowledgeRerankApiKey ?? { action: 'keep' as const }
|
|
if (
|
|
rerankApiKeyUpdate.action === 'keep' &&
|
|
current.knowledgeRerankCredential &&
|
|
current.knowledgeRerankEndpoint !== rerankEndpoint
|
|
) {
|
|
throw new Error(
|
|
'重排接口 URL 已更改,请重新输入或清除 API Key'
|
|
)
|
|
}
|
|
let knowledgeRerankCredential: StoredSettings['knowledgeRerankCredential']
|
|
if (
|
|
rerankApiKeyUpdate.action === 'keep' &&
|
|
current.knowledgeRerankCredential
|
|
) {
|
|
knowledgeRerankCredential = current.knowledgeRerankCredential
|
|
} else if (rerankApiKeyUpdate.action === 'replace') {
|
|
knowledgeRerankCredential = encryptSettingsCredential(
|
|
this.cipher,
|
|
{
|
|
version: 1,
|
|
apiKey: rerankApiKeyUpdate.value,
|
|
endpoint: rerankEndpoint
|
|
}
|
|
)
|
|
}
|
|
|
|
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 requestedDeepSeekHarnessSource =
|
|
input.deepseekHarnessModelSource ??
|
|
current.deepseekHarnessModelSource
|
|
if (requestedDeepSeekHarnessSource.kind === 'profile') {
|
|
const profile = modelProfiles.find(
|
|
(candidate) =>
|
|
candidate.id === requestedDeepSeekHarnessSource.profileId
|
|
)
|
|
if (!profile) {
|
|
throw new Error('DeepSeek Harness 引用的模型连接不存在')
|
|
}
|
|
if (!isDeepSeekHarnessModelProfile(profile)) {
|
|
throw new Error(
|
|
'DeepSeek Harness 模型连接仅支持 api.deepseek.com 的 OpenAI Chat Completions 协议'
|
|
)
|
|
}
|
|
}
|
|
|
|
const next: StoredSettings = {
|
|
...current,
|
|
version: 16,
|
|
provider: input.provider,
|
|
modelProfiles,
|
|
defaultModelProfileId,
|
|
opencodeModelSource,
|
|
continueModelSource,
|
|
deepseekHarnessModelSource:
|
|
requestedDeepSeekHarnessSource,
|
|
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,
|
|
knowledgeRerankEnabled: input.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: rerankEndpoint,
|
|
knowledgeRerankModel: input.knowledgeRerankModel,
|
|
knowledgeRerankCredential,
|
|
workspacePath: input.workspacePath,
|
|
toolApproval: input.toolApproval
|
|
}
|
|
|
|
await writeJsonFileAtomically(this.filePath, next)
|
|
this.settings = next
|
|
this.loadWarnings = []
|
|
return this.toPublicSettings(next)
|
|
}
|
|
|
|
private async canonicalizeRuntimeFile(
|
|
filePath: string,
|
|
label: string
|
|
): Promise<string> {
|
|
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}不存在、不可访问或不是普通文件`)
|
|
}
|
|
}
|
|
}
|