Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
2651 lines
85 KiB
TypeScript
2651 lines
85 KiB
TypeScript
import {
|
|
readFile,
|
|
realpath,
|
|
stat
|
|
} from 'node:fs/promises'
|
|
import { homedir } from 'node:os'
|
|
import { z } from 'zod'
|
|
import {
|
|
continueModeSchema,
|
|
builtinEmbeddingConnectionId,
|
|
contextCompressionSettingsSchema,
|
|
defaultContextCompressionSettings,
|
|
defaultModelProfileId,
|
|
defaultRuntimeCustomizationSettings,
|
|
defaultRuntimeSettings,
|
|
imageGenerationQualitySchema,
|
|
legacyEmbeddingConnectionId,
|
|
isAgentRuntimeModelProtocol,
|
|
isDeepSeekHarnessModelProfile,
|
|
minimumModelContextWindowTokens,
|
|
modelAuthenticationSchema,
|
|
modelProtocolSchema,
|
|
modelRequestBodySchema,
|
|
modelRequestHeadersSchema,
|
|
runtimeModelSourceSchema,
|
|
runtimeCustomizationSettingsSchema,
|
|
runtimePathSchema,
|
|
runtimeProviderSchema,
|
|
type RuntimeSettings,
|
|
type RuntimeCustomizationSettings,
|
|
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 CURRENT_SETTINGS_VERSION = 22
|
|
// Released settings versions only; not a current execution policy.
|
|
const legacyToolApprovalPolicySchema = z.enum(['always', 'session', 'workspace', 'policy'])
|
|
const legacyRuntimeSandboxModeSchema = z.enum([
|
|
'off',
|
|
'auto',
|
|
'strict'
|
|
])
|
|
|
|
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: legacyToolApprovalPolicySchema
|
|
})
|
|
|
|
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: legacyToolApprovalPolicySchema
|
|
})
|
|
|
|
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: legacyRuntimeSandboxModeSchema.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(),
|
|
contextWindowTokens: z
|
|
.number()
|
|
.int()
|
|
.min(8_000)
|
|
.max(10_000_000)
|
|
.optional(),
|
|
maximumOutputTokens: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(10_000_000)
|
|
.optional()
|
|
})
|
|
|
|
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 version16StoredSettingsSchema = version15StoredSettingsSchema
|
|
.omit({
|
|
version: true,
|
|
deepseekHarnessBinaryPath: true,
|
|
runtimeSandboxMode: true
|
|
})
|
|
.extend({
|
|
version: z.literal(16)
|
|
})
|
|
|
|
const version17StoredSettingsSchema = version16StoredSettingsSchema
|
|
.omit({ version: true })
|
|
.extend({
|
|
version: z.literal(17),
|
|
contextCompression: contextCompressionSettingsSchema
|
|
})
|
|
|
|
const version18StoredSettingsSchema = version17StoredSettingsSchema
|
|
.omit({ version: true })
|
|
.extend({
|
|
version: z.literal(18),
|
|
runtimeCustomization: runtimeCustomizationSettingsSchema
|
|
})
|
|
|
|
const storedEmbeddingConnectionSchema = z.discriminatedUnion('kind', [
|
|
z
|
|
.object({
|
|
id: z.literal(builtinEmbeddingConnectionId),
|
|
name: z.string().trim().min(1).max(64),
|
|
kind: z.literal('builtin')
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
id: z.string().uuid().refine(
|
|
(id) => id !== builtinEmbeddingConnectionId
|
|
),
|
|
name: z.string().trim().min(1).max(64),
|
|
kind: z.literal('openai-compatible'),
|
|
baseUrl: z.string().url().max(2_048),
|
|
modelName: z.string().trim().min(1).max(256),
|
|
authentication: modelAuthenticationSchema,
|
|
credential: credentialSchema
|
|
})
|
|
.strict()
|
|
])
|
|
|
|
const version19StoredSettingsSchema = version18StoredSettingsSchema
|
|
.omit({ version: true })
|
|
.extend({
|
|
version: z.literal(19),
|
|
embeddingConnections: z
|
|
.array(storedEmbeddingConnectionSchema)
|
|
.min(1)
|
|
.max(20),
|
|
activeEmbeddingConnectionId: z.string().uuid()
|
|
})
|
|
|
|
const version20StoredSettingsSchema = version19StoredSettingsSchema
|
|
.omit({ version: true })
|
|
.extend({
|
|
version: z.literal(20)
|
|
})
|
|
|
|
const version21StoredSettingsSchema = version20StoredSettingsSchema
|
|
.omit({ version: true, modelProfiles: true })
|
|
.extend({
|
|
version: z.literal(21),
|
|
modelProfiles: z
|
|
.array(
|
|
currentStoredModelProfileSchema.extend({
|
|
allowConversationInvocation: z.boolean().optional(),
|
|
requestHeaders: modelRequestHeadersSchema,
|
|
requestBody: modelRequestBodySchema
|
|
})
|
|
)
|
|
.min(1)
|
|
.max(20),
|
|
defaultImageModelProfileId: z.string().uuid().nullable().optional()
|
|
})
|
|
|
|
const storedSettingsSchema = version21StoredSettingsSchema
|
|
.omit({ version: true, toolApproval: true })
|
|
.extend({ version: z.literal(CURRENT_SETTINGS_VERSION) })
|
|
|
|
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
|
export type RuntimeSettingsRollback = {
|
|
publicSettings: RuntimeSettings
|
|
restore(): Promise<RuntimeSettings>
|
|
}
|
|
export type ActiveEmbeddingConnectionUpdate = {
|
|
changed: boolean
|
|
previousConnectionId: string
|
|
restore(): Promise<RuntimeSettings>
|
|
}
|
|
type Version17StoredSettings = z.infer<
|
|
typeof version17StoredSettingsSchema
|
|
>
|
|
type Version18StoredSettings = z.infer<
|
|
typeof version18StoredSettingsSchema
|
|
>
|
|
type Version19StoredSettings = z.infer<
|
|
typeof version19StoredSettingsSchema
|
|
>
|
|
type Version20StoredSettings = z.infer<
|
|
typeof version20StoredSettingsSchema
|
|
>
|
|
type Version16StoredSettings = z.infer<
|
|
typeof version16StoredSettingsSchema
|
|
>
|
|
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: legacyToolApprovalPolicySchema
|
|
})
|
|
|
|
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: legacyToolApprovalPolicySchema
|
|
})
|
|
|
|
const savedApiKeyPayloadSchema = z.object({
|
|
version: z.literal(2),
|
|
apiKey: z.string()
|
|
})
|
|
|
|
const credentialPayloadSchema = z.union([
|
|
savedApiKeyPayloadSchema,
|
|
z.object({
|
|
version: z.literal(1),
|
|
apiKey: z.string(),
|
|
origin: z.string()
|
|
})
|
|
])
|
|
|
|
const endpointCredentialPayloadSchema = z.union([
|
|
savedApiKeyPayloadSchema,
|
|
z.object({
|
|
version: z.literal(1),
|
|
apiKey: z.string(),
|
|
endpoint: z.string()
|
|
})
|
|
])
|
|
|
|
const encryptSavedApiKey = (
|
|
cipher: SettingsCredentialCipher,
|
|
apiKey: string
|
|
) =>
|
|
encryptSettingsCredential(cipher, {
|
|
version: 2,
|
|
apiKey
|
|
})
|
|
|
|
const platformHarnessProfileId = 'goodbuddy-platform-harness'
|
|
const historicalDefaultModelBaseUrl = 'https://bigtoken.ai'
|
|
const historicalDefaultModelName = 'sonnet-5'
|
|
const historicalDefaultModelProtocol = 'anthropic-messages'
|
|
|
|
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
|
|
defaultImageModelProfileId?: string | null
|
|
opencodeModelProfile?: ResolvedModelProfile
|
|
continueModelProfile?: ResolvedModelProfile
|
|
deepseekHarnessModelProfile?: ResolvedModelProfile
|
|
/** What DeepSeek Harness uses when its own configuration is selected. */
|
|
deepseekHarnessPlatformModelProfile?: ResolvedModelProfile
|
|
opencodeBaseUrl: string
|
|
opencodeEmbedded: boolean
|
|
opencodeBinaryPath: string
|
|
opencodeConfigPath: string
|
|
continueBinaryPath: string
|
|
continueConfigPath: string
|
|
continueMode: RuntimeSettings['continueMode']
|
|
subagentSmartRoutingEnabled: boolean
|
|
knowledgeEmbeddingEnabled: boolean
|
|
knowledgeEmbeddingBaseUrl: string
|
|
knowledgeEmbeddingModel: string
|
|
knowledgeEmbeddingApiKey?: string
|
|
embeddingConnections?: ResolvedEmbeddingConnection[]
|
|
activeEmbeddingConnectionId?: string
|
|
activeEmbeddingConnection?: ResolvedEmbeddingConnection
|
|
knowledgeRerankEnabled: boolean
|
|
knowledgeRerankEndpoint: string
|
|
knowledgeRerankModel: string
|
|
knowledgeRerankApiKey?: string
|
|
contextCompression?: RuntimeSettings['contextCompression']
|
|
runtimeCustomization: RuntimeCustomizationSettings
|
|
workspacePath: string
|
|
}
|
|
|
|
export type RuntimePolicySettings = Pick<
|
|
ResolvedRuntimeSettings,
|
|
'subagentSmartRoutingEnabled'
|
|
>
|
|
|
|
export type ResolvedModelProfile = {
|
|
allowConversationInvocation?: boolean
|
|
id: string
|
|
name: string
|
|
baseUrl: string
|
|
modelName: string
|
|
protocol: RuntimeSettings['modelProtocol']
|
|
authentication: RuntimeSettings['modelAuthentication']
|
|
supportsImageInput?: boolean
|
|
contextWindowTokens?: number
|
|
maximumOutputTokens?: number
|
|
imageGenerationQuality?: RuntimeSettings['imageGenerationQuality']
|
|
requestHeaders?: RuntimeSettings['modelProfiles'][number]['requestHeaders']
|
|
requestBody?: RuntimeSettings['modelProfiles'][number]['requestBody']
|
|
apiKey?: string
|
|
}
|
|
|
|
export type ResolvedEmbeddingConnection =
|
|
| {
|
|
id: typeof builtinEmbeddingConnectionId
|
|
name: string
|
|
kind: 'builtin'
|
|
}
|
|
| {
|
|
id: string
|
|
name: string
|
|
kind: 'openai-compatible'
|
|
baseUrl: string
|
|
modelName: string
|
|
authentication: RuntimeSettings['modelAuthentication']
|
|
apiKey?: string
|
|
}
|
|
|
|
type ResolvedModelCredential = {
|
|
activeApiKey?: string
|
|
configured: boolean
|
|
source: RuntimeSettings['credentialSource']
|
|
}
|
|
|
|
const defaultSettings: StoredSettings = {
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
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,
|
|
requestHeaders: {},
|
|
requestBody: {}
|
|
}
|
|
],
|
|
defaultModelProfileId,
|
|
opencodeModelSource: {
|
|
kind: 'default'
|
|
},
|
|
continueModelSource: {
|
|
kind: 'default'
|
|
},
|
|
opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl,
|
|
opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded,
|
|
opencodeBinaryPath: defaultRuntimeSettings.opencodeBinaryPath,
|
|
opencodeConfigPath: defaultRuntimeSettings.opencodeConfigPath,
|
|
continueBinaryPath: defaultRuntimeSettings.continueBinaryPath,
|
|
continueConfigPath: defaultRuntimeSettings.continueConfigPath,
|
|
continueMode: defaultRuntimeSettings.continueMode,
|
|
deepseekHarnessModelSource: { kind: 'platform' },
|
|
subagentSmartRoutingEnabled:
|
|
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
|
knowledgeEmbeddingEnabled:
|
|
defaultRuntimeSettings.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl:
|
|
defaultRuntimeSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel:
|
|
defaultRuntimeSettings.knowledgeEmbeddingModel,
|
|
embeddingConnections: [
|
|
{
|
|
id: builtinEmbeddingConnectionId,
|
|
name: 'GoodBuddy 内置向量模型',
|
|
kind: 'builtin'
|
|
},
|
|
{
|
|
id: legacyEmbeddingConnectionId,
|
|
name: '自定义向量模型',
|
|
kind: 'openai-compatible',
|
|
baseUrl: defaultRuntimeSettings.knowledgeEmbeddingBaseUrl,
|
|
modelName: defaultRuntimeSettings.knowledgeEmbeddingModel,
|
|
authentication: 'api-key'
|
|
}
|
|
],
|
|
activeEmbeddingConnectionId: builtinEmbeddingConnectionId,
|
|
knowledgeRerankEnabled:
|
|
defaultRuntimeSettings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint:
|
|
defaultRuntimeSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel:
|
|
defaultRuntimeSettings.knowledgeRerankModel,
|
|
contextCompression: defaultContextCompressionSettings,
|
|
runtimeCustomization: defaultRuntimeCustomizationSettings,
|
|
workspacePath: defaultRuntimeSettings.workspacePath
|
|
}
|
|
|
|
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 {
|
|
const {
|
|
runtimeSandboxMode: _obsolete,
|
|
...current
|
|
} = settings
|
|
void _obsolete
|
|
return migrateVersion18({
|
|
...current,
|
|
version: 18,
|
|
deepseekHarnessModelSource: { kind: 'platform' },
|
|
contextCompression: defaultContextCompressionSettings,
|
|
runtimeCustomization: defaultRuntimeCustomizationSettings
|
|
})
|
|
}
|
|
|
|
function migrateVersion15(
|
|
settings: Version15StoredSettings
|
|
): StoredSettings {
|
|
const {
|
|
deepseekHarnessBinaryPath: _obsolete,
|
|
runtimeSandboxMode: _obsoleteSandbox,
|
|
...current
|
|
} = settings
|
|
void _obsolete
|
|
void _obsoleteSandbox
|
|
return migrateVersion18({
|
|
...current,
|
|
version: 18,
|
|
contextCompression: defaultContextCompressionSettings,
|
|
runtimeCustomization: defaultRuntimeCustomizationSettings
|
|
})
|
|
}
|
|
|
|
function migrateVersion16(
|
|
settings: Version16StoredSettings
|
|
): StoredSettings {
|
|
return migrateVersion18({
|
|
...settings,
|
|
version: 18,
|
|
contextCompression: defaultContextCompressionSettings,
|
|
runtimeCustomization: defaultRuntimeCustomizationSettings
|
|
})
|
|
}
|
|
|
|
function migrateVersion17(
|
|
settings: Version17StoredSettings
|
|
): StoredSettings {
|
|
return migrateVersion18({
|
|
...settings,
|
|
version: 18,
|
|
runtimeCustomization: defaultRuntimeCustomizationSettings
|
|
})
|
|
}
|
|
|
|
function migrateVersion18(
|
|
settings: Version18StoredSettings
|
|
): StoredSettings {
|
|
return migrateVersion19({
|
|
...settings,
|
|
version: 19,
|
|
embeddingConnections: [
|
|
{
|
|
id: builtinEmbeddingConnectionId,
|
|
name: 'GoodBuddy 内置向量模型',
|
|
kind: 'builtin'
|
|
},
|
|
{
|
|
id: legacyEmbeddingConnectionId,
|
|
name: '自定义向量模型',
|
|
kind: 'openai-compatible',
|
|
baseUrl: settings.knowledgeEmbeddingBaseUrl,
|
|
modelName: settings.knowledgeEmbeddingModel,
|
|
authentication: 'api-key',
|
|
credential: settings.knowledgeEmbeddingCredential
|
|
}
|
|
],
|
|
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
|
|
})
|
|
}
|
|
|
|
function migrateVersion19(
|
|
settings: Version19StoredSettings
|
|
): StoredSettings {
|
|
return migrateVersion20({
|
|
...settings,
|
|
version: 20,
|
|
modelProfiles: settings.modelProfiles.map((profile) =>
|
|
profile.id === defaultModelProfileId &&
|
|
profile.name === '默认模型' &&
|
|
profile.baseUrl === historicalDefaultModelBaseUrl &&
|
|
profile.modelName === historicalDefaultModelName &&
|
|
profile.protocol === historicalDefaultModelProtocol &&
|
|
profile.authentication === 'api-key' &&
|
|
profile.supportsImageInput === false &&
|
|
profile.imageGenerationQuality === 'auto' &&
|
|
profile.contextWindowTokens === undefined &&
|
|
profile.credential === undefined
|
|
? {
|
|
...profile,
|
|
baseUrl: defaultRuntimeSettings.modelBaseUrl,
|
|
modelName: defaultRuntimeSettings.modelName,
|
|
protocol: defaultRuntimeSettings.modelProtocol,
|
|
authentication:
|
|
defaultRuntimeSettings.modelAuthentication
|
|
}
|
|
: profile
|
|
)
|
|
})
|
|
}
|
|
|
|
function migrateVersion20(
|
|
settings: Version20StoredSettings
|
|
): StoredSettings {
|
|
return migrateVersion21({
|
|
...settings,
|
|
version: 21,
|
|
modelProfiles: settings.modelProfiles.map((profile) => ({
|
|
...profile,
|
|
requestHeaders: {},
|
|
requestBody: {}
|
|
}))
|
|
})
|
|
}
|
|
|
|
function migrateVersion21(
|
|
settings: z.infer<typeof version21StoredSettingsSchema>
|
|
): StoredSettings {
|
|
const { toolApproval: obsolete, ...current } = settings
|
|
void obsolete
|
|
return { ...current, version: CURRENT_SETTINGS_VERSION }
|
|
}
|
|
|
|
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),
|
|
contextWindowTokens:
|
|
profile.contextWindowTokens === undefined ||
|
|
profile.contextWindowTokens >= minimumModelContextWindowTokens
|
|
? profile.contextWindowTokens
|
|
: undefined
|
|
}))
|
|
const fallbackProfileId = compatibleTextProfileId({
|
|
modelProfiles,
|
|
defaultModelProfileId: settings.defaultModelProfileId
|
|
})
|
|
const normalizeSource = (
|
|
source: RuntimeSettings['opencodeModelSource']
|
|
): RuntimeSettings['opencodeModelSource'] => {
|
|
if (source.kind !== 'profile') {
|
|
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' }
|
|
}
|
|
if (source.kind === 'default') return source
|
|
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
|
|
const compressionSource = settings.contextCompression.modelSource
|
|
const compressionProfile =
|
|
compressionSource.kind === 'profile'
|
|
? modelProfiles.find(
|
|
(profile) => profile.id === compressionSource.profileId
|
|
)
|
|
: undefined
|
|
const contextCompression = {
|
|
...settings.contextCompression,
|
|
modelSource:
|
|
compressionSource.kind === 'profile' &&
|
|
compressionProfile &&
|
|
isAgentRuntimeModelProtocol(compressionProfile.protocol)
|
|
? compressionSource
|
|
: ({ kind: 'current' } as const)
|
|
}
|
|
const userEmbeddingConnections = settings.embeddingConnections
|
|
.filter((connection) => connection.kind === 'openai-compatible')
|
|
.map((connection) => ({
|
|
...connection,
|
|
baseUrl: normalizeModelBaseUrl(connection.baseUrl)
|
|
}))
|
|
const embeddingConnections: StoredSettings['embeddingConnections'] = [
|
|
{
|
|
id: builtinEmbeddingConnectionId,
|
|
name: 'GoodBuddy 内置向量模型',
|
|
kind: 'builtin'
|
|
},
|
|
...userEmbeddingConnections
|
|
]
|
|
const activeEmbeddingConnectionId = embeddingConnections.some(
|
|
(connection) =>
|
|
connection.id === settings.activeEmbeddingConnectionId
|
|
)
|
|
? settings.activeEmbeddingConnectionId
|
|
: builtinEmbeddingConnectionId
|
|
|
|
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
|
|
),
|
|
contextCompression,
|
|
embeddingConnections,
|
|
activeEmbeddingConnectionId,
|
|
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: 'auto',
|
|
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: 'auto',
|
|
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
|
|
})
|
|
}
|
|
|
|
function migrateVersion3(
|
|
settings: z.infer<typeof version3StoredSettingsSchema>
|
|
): StoredSettings {
|
|
return migrateVersion4({
|
|
...settings,
|
|
version: 4,
|
|
continueMode: 'chat'
|
|
})
|
|
}
|
|
|
|
function migrateVersion2(
|
|
settings: z.infer<typeof version2StoredSettingsSchema>
|
|
): StoredSettings {
|
|
return migrateVersion4({
|
|
version: 4,
|
|
provider: settings.provider,
|
|
modelBaseUrl: settings.modelBaseUrl,
|
|
modelName: settings.modelName,
|
|
opencodeBaseUrl: settings.opencodeBaseUrl,
|
|
opencodeEmbedded: settings.opencodeEmbedded,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: migrateContinueCommand(
|
|
settings.continueCommand
|
|
),
|
|
continueConfigPath: '',
|
|
continueMode: 'chat',
|
|
workspacePath: settings.workspacePath,
|
|
credential: settings.credential,
|
|
toolApproval: settings.toolApproval
|
|
})
|
|
}
|
|
|
|
function migrateLegacySettings(
|
|
settings: z.infer<typeof legacyStoredSettingsSchema>
|
|
): StoredSettings {
|
|
return migrateVersion4({
|
|
version: 4,
|
|
provider:
|
|
settings.provider === 'bigtoken' ? 'model' : settings.provider,
|
|
modelBaseUrl: settings.bigtokenBaseUrl,
|
|
modelName: settings.bigtokenModel,
|
|
opencodeBaseUrl: settings.opencodeBaseUrl,
|
|
opencodeEmbedded: settings.opencodeEmbedded,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: migrateContinueCommand(
|
|
settings.continueCommand
|
|
),
|
|
continueConfigPath: '',
|
|
continueMode: 'chat',
|
|
workspacePath: settings.workspacePath,
|
|
credential: settings.credential,
|
|
toolApproval: settings.toolApproval
|
|
})
|
|
}
|
|
|
|
function parseStoredSettings(value: unknown): StoredSettings {
|
|
const version =
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
'version' in value &&
|
|
typeof value.version === 'number'
|
|
? value.version
|
|
: undefined
|
|
switch (version) {
|
|
case CURRENT_SETTINGS_VERSION:
|
|
return storedSettingsSchema.parse(value)
|
|
case 21:
|
|
return migrateVersion21(version21StoredSettingsSchema.parse(value))
|
|
case 20:
|
|
return migrateVersion20(version20StoredSettingsSchema.parse(value))
|
|
case 19:
|
|
return migrateVersion19(version19StoredSettingsSchema.parse(value))
|
|
case 18:
|
|
return migrateVersion18(version18StoredSettingsSchema.parse(value))
|
|
case 17:
|
|
return migrateVersion17(version17StoredSettingsSchema.parse(value))
|
|
case 16:
|
|
return migrateVersion16(version16StoredSettingsSchema.parse(value))
|
|
case 15:
|
|
return migrateVersion15(version15StoredSettingsSchema.parse(value))
|
|
case 14:
|
|
return migrateVersion14(version14StoredSettingsSchema.parse(value))
|
|
case 13:
|
|
return migrateVersion13(version13StoredSettingsSchema.parse(value))
|
|
case 12:
|
|
return migrateVersion12(version12StoredSettingsSchema.parse(value))
|
|
case 11:
|
|
return migrateVersion11(version11StoredSettingsSchema.parse(value))
|
|
case 10:
|
|
return migrateVersion10(version10StoredSettingsSchema.parse(value))
|
|
case 9:
|
|
return migrateVersion9(version9StoredSettingsSchema.parse(value))
|
|
case 8:
|
|
return migrateVersion8(version8StoredSettingsSchema.parse(value))
|
|
case 7:
|
|
return migrateVersion7(version7StoredSettingsSchema.parse(value))
|
|
case 6:
|
|
return migrateVersion6(version6StoredSettingsSchema.parse(value))
|
|
case 5:
|
|
return migrateVersion5(version5StoredSettingsSchema.parse(value))
|
|
case 4:
|
|
return migrateVersion4(version4StoredSettingsSchema.parse(value))
|
|
case 3:
|
|
return migrateVersion3(version3StoredSettingsSchema.parse(value))
|
|
case 2:
|
|
return migrateVersion2(version2StoredSettingsSchema.parse(value))
|
|
default:
|
|
return migrateLegacySettings(
|
|
legacyStoredSettingsSchema.parse(value)
|
|
)
|
|
}
|
|
}
|
|
|
|
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> {
|
|
let needsUpgrade = false
|
|
let settings: StoredSettings
|
|
try {
|
|
const contents = await readFile(this.filePath, 'utf8')
|
|
const parsed: unknown = JSON.parse(contents)
|
|
assertSupportedSettingsVersion(
|
|
parsed,
|
|
CURRENT_SETTINGS_VERSION,
|
|
(version) =>
|
|
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
|
)
|
|
settings = normalizeStoredSettings(parseStoredSettings(parsed))
|
|
needsUpgrade = (parsed as { version: number }).version < CURRENT_SETTINGS_VERSION
|
|
} catch (error) {
|
|
if (error instanceof UnsupportedSettingsVersionError) {
|
|
throw error
|
|
}
|
|
if (!isMissingFileError(error)) {
|
|
await isolateCorruptSettingsFile(
|
|
this.filePath,
|
|
'Runtime 设置已损坏且无法隔离'
|
|
)
|
|
this.loadWarnings = [{ code: 'runtime-settings-recovered' }]
|
|
}
|
|
settings = { ...defaultSettings }
|
|
}
|
|
// Persist the upgrade without decrypting/re-encrypting any credential. A write
|
|
// failure is not evidence of corrupt input and must not isolate the source.
|
|
if (needsUpgrade) await writeJsonFileAtomically(this.filePath, settings)
|
|
this.settings = settings
|
|
return 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)
|
|
)
|
|
this.removeWarnings(
|
|
['runtime-model-credential-unreadable'],
|
|
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 = endpointCredentialPayloadSchema.parse(
|
|
decryptSettingsCredential(
|
|
this.cipher,
|
|
settings.knowledgeEmbeddingCredential
|
|
)
|
|
)
|
|
this.removeWarnings(['runtime-embedding-credential-unreadable'])
|
|
return payload.apiKey
|
|
} catch {
|
|
this.addWarning({
|
|
code: 'runtime-embedding-credential-unreadable'
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
private getEmbeddingConnectionApiKey(
|
|
connection: Extract<
|
|
StoredSettings['embeddingConnections'][number],
|
|
{ kind: 'openai-compatible' }
|
|
>
|
|
): string | undefined {
|
|
if (!connection.credential) {
|
|
return undefined
|
|
}
|
|
if (!this.cipher.isAvailable()) {
|
|
this.addWarning({
|
|
code: 'runtime-embedding-credential-unreadable'
|
|
})
|
|
return undefined
|
|
}
|
|
try {
|
|
return endpointCredentialPayloadSchema.parse(
|
|
decryptSettingsCredential(this.cipher, connection.credential)
|
|
).apiKey
|
|
} catch {
|
|
this.addWarning({
|
|
code: 'runtime-embedding-credential-unreadable'
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
private resolveEmbeddingConnections(
|
|
settings: StoredSettings
|
|
): ResolvedEmbeddingConnection[] {
|
|
const environmentApiKey =
|
|
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
|
|
return settings.embeddingConnections.map((connection) => {
|
|
if (connection.kind === 'builtin') {
|
|
return connection
|
|
}
|
|
const storedApiKey =
|
|
connection.id === legacyEmbeddingConnectionId &&
|
|
environmentApiKey
|
|
? undefined
|
|
: this.getEmbeddingConnectionApiKey(connection)
|
|
return {
|
|
id: connection.id,
|
|
name: connection.name,
|
|
kind: connection.kind,
|
|
baseUrl: connection.baseUrl,
|
|
modelName: connection.modelName,
|
|
authentication: connection.authentication,
|
|
apiKey:
|
|
connection.authentication === 'api-key'
|
|
? connection.id === legacyEmbeddingConnectionId
|
|
? environmentApiKey || storedApiKey
|
|
: storedApiKey
|
|
: 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 = endpointCredentialPayloadSchema.parse(
|
|
decryptSettingsCredential(
|
|
this.cipher,
|
|
settings.knowledgeRerankCredential
|
|
)
|
|
)
|
|
this.removeWarnings(['runtime-rerank-credential-unreadable'])
|
|
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 resolvePlatformHarnessProfile(): 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
|
|
}
|
|
if (
|
|
!isDeepSeekHarnessModelProfile({
|
|
baseUrl,
|
|
protocol: 'openai-chat-completions',
|
|
authentication: 'api-key'
|
|
})
|
|
) {
|
|
return undefined
|
|
}
|
|
return {
|
|
id: platformHarnessProfileId,
|
|
name: '管理员预置模型',
|
|
baseUrl,
|
|
modelName,
|
|
protocol: 'openai-chat-completions',
|
|
authentication: 'api-key',
|
|
supportsImageInput: false,
|
|
imageGenerationQuality: 'auto',
|
|
apiKey
|
|
}
|
|
}
|
|
|
|
private resolveDeepSeekHarnessModelProfile(
|
|
settings: StoredSettings,
|
|
modelProfiles: readonly ResolvedModelProfile[]
|
|
): ResolvedModelProfile | undefined {
|
|
const source = settings.deepseekHarnessModelSource
|
|
if (source.kind === 'default') {
|
|
return (
|
|
modelProfiles.find(
|
|
(profile) =>
|
|
profile.id === settings.defaultModelProfileId &&
|
|
isDeepSeekHarnessModelProfile(profile)
|
|
) ?? modelProfiles.find(isDeepSeekHarnessModelProfile)
|
|
)
|
|
}
|
|
if (source.kind === 'profile') {
|
|
return modelProfiles.find(
|
|
(profile) => profile.id === source.profileId
|
|
)
|
|
}
|
|
const platformProfile = this.resolvePlatformHarnessProfile()
|
|
if (platformProfile) {
|
|
return platformProfile
|
|
}
|
|
return (
|
|
modelProfiles.find(
|
|
(profile) =>
|
|
profile.id === settings.defaultModelProfileId &&
|
|
isDeepSeekHarnessModelProfile(profile)
|
|
) ??
|
|
modelProfiles.find(isDeepSeekHarnessModelProfile)
|
|
)
|
|
}
|
|
|
|
private resolveModelCredentials(
|
|
settings: StoredSettings
|
|
): Map<string, ResolvedModelCredential> {
|
|
const defaultProfile =
|
|
settings.modelProfiles.find(
|
|
(profile) => profile.id === settings.defaultModelProfileId
|
|
) ?? settings.modelProfiles[0]
|
|
const environmentApiKey = this.getEnvironmentApiKey()
|
|
return new Map(
|
|
settings.modelProfiles.map((profile) => {
|
|
const environmentManaged =
|
|
profile.id === defaultProfile?.id &&
|
|
Boolean(environmentApiKey)
|
|
const storedApiKey = environmentManaged
|
|
? undefined
|
|
: this.getStoredApiKey(profile)
|
|
const source: RuntimeSettings['credentialSource'] =
|
|
environmentManaged
|
|
? 'environment'
|
|
: storedApiKey
|
|
? 'encrypted'
|
|
: profile.credential
|
|
? 'unreadable'
|
|
: 'none'
|
|
return [
|
|
profile.id,
|
|
{
|
|
activeApiKey:
|
|
environmentManaged
|
|
? environmentApiKey
|
|
: profile.authentication === 'api-key'
|
|
? storedApiKey
|
|
: undefined,
|
|
configured: environmentManaged || Boolean(storedApiKey),
|
|
source
|
|
}
|
|
]
|
|
})
|
|
)
|
|
}
|
|
|
|
private resolveEffectiveModelSettings(
|
|
settings: StoredSettings,
|
|
credentials: ReadonlyMap<string, ResolvedModelCredential>
|
|
): {
|
|
apiKey?: string
|
|
baseUrl: string
|
|
model: string
|
|
protocol: RuntimeSettings['modelProtocol']
|
|
authentication: RuntimeSettings['modelAuthentication']
|
|
supportsImageInput: boolean
|
|
contextWindowTokens?: number
|
|
maximumOutputTokens?: number
|
|
imageGenerationQuality: RuntimeSettings['imageGenerationQuality']
|
|
requestHeaders: RuntimeSettings['modelProfiles'][number]['requestHeaders']
|
|
requestBody: RuntimeSettings['modelProfiles'][number]['requestBody']
|
|
credentialSource: RuntimeSettings['credentialSource']
|
|
} {
|
|
const profile =
|
|
settings.modelProfiles.find(
|
|
(candidate) => candidate.id === settings.defaultModelProfileId
|
|
) ?? settings.modelProfiles[0]
|
|
if (!profile) {
|
|
throw new Error('默认模型连接不存在')
|
|
}
|
|
const credential = credentials.get(profile.id)
|
|
if (!credential) {
|
|
throw new Error(`模型连接不存在:${profile.id}`)
|
|
}
|
|
const environmentManaged = credential.source === 'environment'
|
|
const genericEnvironmentApiKey =
|
|
this.environment.GOODBUDDY_MODEL_API_KEY?.trim()
|
|
const legacyEnvironmentApiKey =
|
|
this.environment.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
|
|
const genericEnvironmentBaseUrl =
|
|
this.environment.GOODBUDDY_MODEL_BASE_URL?.trim()
|
|
const legacyEnvironmentBaseUrl =
|
|
this.environment.GOODBUDDY_BIGTOKEN_BASE_URL?.trim()
|
|
const genericEnvironmentModel =
|
|
this.environment.GOODBUDDY_MODEL_NAME?.trim()
|
|
const legacyEnvironmentModel =
|
|
this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim()
|
|
const legacyEnvironmentConnection =
|
|
environmentManaged &&
|
|
!genericEnvironmentApiKey &&
|
|
Boolean(legacyEnvironmentApiKey) &&
|
|
!genericEnvironmentBaseUrl &&
|
|
!genericEnvironmentModel
|
|
const environmentBaseUrl =
|
|
genericEnvironmentBaseUrl || legacyEnvironmentBaseUrl
|
|
const environmentModel =
|
|
genericEnvironmentModel || legacyEnvironmentModel
|
|
const baseUrl = environmentManaged
|
|
? environmentBaseUrl ||
|
|
(legacyEnvironmentConnection
|
|
? historicalDefaultModelBaseUrl
|
|
: defaultRuntimeSettings.modelBaseUrl)
|
|
: profile.baseUrl
|
|
const model = environmentManaged
|
|
? environmentModel ||
|
|
(legacyEnvironmentConnection
|
|
? historicalDefaultModelName
|
|
: defaultRuntimeSettings.modelName)
|
|
: profile.modelName
|
|
return {
|
|
apiKey: credential.activeApiKey,
|
|
baseUrl,
|
|
model,
|
|
protocol: legacyEnvironmentConnection
|
|
? historicalDefaultModelProtocol
|
|
: profile.protocol,
|
|
authentication: environmentManaged
|
|
? 'api-key'
|
|
: profile.authentication,
|
|
supportsImageInput: profile.supportsImageInput,
|
|
contextWindowTokens: profile.contextWindowTokens,
|
|
maximumOutputTokens: profile.maximumOutputTokens,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
requestHeaders: profile.requestHeaders,
|
|
requestBody: profile.requestBody,
|
|
credentialSource: credential.source
|
|
}
|
|
}
|
|
|
|
private resolveModelProfiles(
|
|
settings: StoredSettings,
|
|
effective: ReturnType<
|
|
RuntimeSettingsStore['resolveEffectiveModelSettings']
|
|
>,
|
|
credentials: ReadonlyMap<string, ResolvedModelCredential>
|
|
): ResolvedModelProfile[] {
|
|
return settings.modelProfiles.map((profile) => {
|
|
if (profile.id === settings.defaultModelProfileId) {
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: effective.baseUrl,
|
|
modelName: effective.model,
|
|
protocol: effective.protocol,
|
|
authentication: effective.authentication,
|
|
supportsImageInput: effective.supportsImageInput,
|
|
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
|
contextWindowTokens: effective.contextWindowTokens,
|
|
maximumOutputTokens: effective.maximumOutputTokens,
|
|
imageGenerationQuality: effective.imageGenerationQuality,
|
|
requestHeaders: effective.requestHeaders,
|
|
requestBody: effective.requestBody,
|
|
apiKey: effective.apiKey
|
|
}
|
|
}
|
|
const credential = credentials.get(profile.id)
|
|
if (!credential) {
|
|
throw new Error(`模型连接不存在:${profile.id}`)
|
|
}
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
supportsImageInput: profile.supportsImageInput,
|
|
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
|
contextWindowTokens: profile.contextWindowTokens,
|
|
maximumOutputTokens: profile.maximumOutputTokens,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
requestHeaders: profile.requestHeaders,
|
|
requestBody: profile.requestBody,
|
|
apiKey: credential.activeApiKey
|
|
}
|
|
})
|
|
}
|
|
|
|
private resolveAgentSettings(settings: StoredSettings): {
|
|
opencodeBaseUrl: string
|
|
opencodeEmbedded: boolean
|
|
opencodeBinaryPath: string
|
|
opencodeConfigPath: string
|
|
continueBinaryPath: string
|
|
continueConfigPath: string
|
|
continueMode: RuntimeSettings['continueMode']
|
|
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,
|
|
workspacePath:
|
|
this.environment.GOODBUDDY_WORKSPACE?.trim() ||
|
|
settings.workspacePath ||
|
|
homedir()
|
|
}
|
|
}
|
|
|
|
private toPublicSettings(settings: StoredSettings): RuntimeSettings {
|
|
const credentials = this.resolveModelCredentials(settings)
|
|
const effective = this.resolveEffectiveModelSettings(
|
|
settings,
|
|
credentials
|
|
)
|
|
const agent = this.resolveAgentSettings(settings)
|
|
const resolvedModelProfiles = this.resolveModelProfiles(
|
|
settings,
|
|
effective,
|
|
credentials
|
|
)
|
|
const resolvedProfilesById = new Map(
|
|
resolvedModelProfiles.map((profile) => [profile.id, profile])
|
|
)
|
|
const modelProfiles = settings.modelProfiles.map((profile) => {
|
|
const resolved = resolvedProfilesById.get(profile.id)
|
|
if (!resolved) {
|
|
throw new Error(`模型连接不存在:${profile.id}`)
|
|
}
|
|
const credential = credentials.get(profile.id)
|
|
if (!credential) {
|
|
throw new Error(`模型连接不存在:${profile.id}`)
|
|
}
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: resolved.baseUrl,
|
|
modelName: resolved.modelName,
|
|
protocol: resolved.protocol,
|
|
authentication: resolved.authentication,
|
|
supportsImageInput: resolved.supportsImageInput,
|
|
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
|
contextWindowTokens: resolved.contextWindowTokens,
|
|
maximumOutputTokens: resolved.maximumOutputTokens,
|
|
imageGenerationQuality:
|
|
resolved.imageGenerationQuality ??
|
|
defaultRuntimeSettings.imageGenerationQuality,
|
|
requestHeaders: resolved.requestHeaders ?? {},
|
|
requestBody: resolved.requestBody ?? {},
|
|
apiKeyConfigured: credential.configured,
|
|
credentialSource: credential.source
|
|
}
|
|
})
|
|
const configuredModelProfiles = settings.modelProfiles.map((profile) => {
|
|
const credential = credentials.get(profile.id)
|
|
if (!credential) {
|
|
throw new Error(`模型连接不存在:${profile.id}`)
|
|
}
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
supportsImageInput: profile.supportsImageInput,
|
|
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
|
contextWindowTokens: profile.contextWindowTokens,
|
|
maximumOutputTokens: profile.maximumOutputTokens,
|
|
imageGenerationQuality:
|
|
profile.imageGenerationQuality ??
|
|
defaultRuntimeSettings.imageGenerationQuality,
|
|
requestHeaders: profile.requestHeaders,
|
|
requestBody: profile.requestBody,
|
|
apiKeyConfigured: credential.configured,
|
|
credentialSource: credential.source
|
|
}
|
|
})
|
|
const deepseekHarnessModelSource = settings.deepseekHarnessModelSource
|
|
const harnessPlatformProfile = this.resolveDeepSeekHarnessModelProfile(
|
|
{ ...settings, deepseekHarnessModelSource: { kind: 'platform' } },
|
|
resolvedModelProfiles
|
|
)
|
|
const deepseekHarnessPlatformModel: RuntimeSettings['deepseekHarnessPlatformModel'] =
|
|
!harnessPlatformProfile
|
|
? { source: 'unavailable' }
|
|
: harnessPlatformProfile.id === platformHarnessProfileId
|
|
? {
|
|
source: 'environment',
|
|
name: harnessPlatformProfile.name,
|
|
modelName: harnessPlatformProfile.modelName
|
|
}
|
|
: {
|
|
source: 'profile',
|
|
profileId: harnessPlatformProfile.id,
|
|
name: harnessPlatformProfile.name,
|
|
modelName: harnessPlatformProfile.modelName
|
|
}
|
|
const embeddingEnvironmentApiKey =
|
|
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
|
|
const embeddingStoredApiKey = embeddingEnvironmentApiKey
|
|
? undefined
|
|
: this.getStoredEmbeddingApiKey(settings)
|
|
const resolvedEmbeddingConnections =
|
|
this.resolveEmbeddingConnections(settings)
|
|
const embeddingConnections = settings.embeddingConnections.map(
|
|
(connection) => {
|
|
if (connection.kind === 'builtin') {
|
|
return {
|
|
...connection,
|
|
apiKeyConfigured: false as const,
|
|
credentialSource: 'none' as const
|
|
}
|
|
}
|
|
const resolved = resolvedEmbeddingConnections.find(
|
|
(candidate) => candidate.id === connection.id
|
|
)
|
|
const environmentManaged =
|
|
connection.id === legacyEmbeddingConnectionId &&
|
|
Boolean(embeddingEnvironmentApiKey)
|
|
return {
|
|
id: connection.id,
|
|
name: connection.name,
|
|
kind: connection.kind,
|
|
baseUrl: connection.baseUrl,
|
|
modelName: connection.modelName,
|
|
authentication: connection.authentication,
|
|
apiKeyConfigured:
|
|
resolved?.kind === 'openai-compatible' &&
|
|
Boolean(resolved.apiKey),
|
|
credentialSource: environmentManaged
|
|
? ('environment' as const)
|
|
: resolved?.kind === 'openai-compatible' &&
|
|
resolved.apiKey
|
|
? ('encrypted' as const)
|
|
: connection.credential
|
|
? ('unreadable' as const)
|
|
: ('none' as const)
|
|
}
|
|
}
|
|
)
|
|
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,
|
|
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',
|
|
embeddingConnections,
|
|
activeEmbeddingConnectionId:
|
|
settings.activeEmbeddingConnectionId,
|
|
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: settings.knowledgeRerankModel,
|
|
knowledgeRerankApiKeyConfigured: Boolean(
|
|
rerankEnvironmentApiKey ?? rerankStoredApiKey
|
|
),
|
|
knowledgeRerankCredentialSource: rerankEnvironmentApiKey
|
|
? 'environment'
|
|
: rerankStoredApiKey
|
|
? 'encrypted'
|
|
: settings.knowledgeRerankCredential
|
|
? 'unreadable'
|
|
: 'none',
|
|
contextCompression: settings.contextCompression,
|
|
runtimeCustomization: settings.runtimeCustomization,
|
|
workspacePath: agent.workspacePath,
|
|
apiKeyConfigured: Boolean(effective.apiKey),
|
|
credentialSource: effective.credentialSource,
|
|
modelProfiles,
|
|
defaultModelProfileId: settings.defaultModelProfileId,
|
|
defaultImageModelProfileId: settings.defaultImageModelProfileId,
|
|
opencodeModelSource: agent.opencodeBaseUrl
|
|
? { kind: 'platform' }
|
|
: settings.opencodeModelSource,
|
|
continueModelSource: settings.continueModelSource,
|
|
deepseekHarnessModelSource,
|
|
deepseekHarnessPlatformModel,
|
|
secureStorageAvailable: this.cipher.isAvailable(),
|
|
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 getSavedModelProfiles(): Promise<Array<Pick<
|
|
RuntimeSettings['modelProfiles'][number],
|
|
'id' | 'name' | 'protocol' | 'allowConversationInvocation'
|
|
>>> {
|
|
return (await this.load()).modelProfiles.map((profile) => ({
|
|
id: profile.id,
|
|
name: profile.name,
|
|
protocol: profile.protocol,
|
|
allowConversationInvocation: profile.allowConversationInvocation ?? false
|
|
}))
|
|
}
|
|
|
|
captureRollback(): Promise<RuntimeSettingsRollback> {
|
|
let result: RuntimeSettingsRollback | undefined
|
|
const operation = this.updateQueue.then(async () => {
|
|
const snapshot = structuredClone(await this.load())
|
|
result = {
|
|
publicSettings: this.toPublicSettings(snapshot),
|
|
restore: () => this.restoreSnapshot(snapshot)
|
|
}
|
|
})
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation.then(() => result!)
|
|
}
|
|
|
|
private restoreSnapshot(
|
|
snapshot: StoredSettings
|
|
): Promise<RuntimeSettings> {
|
|
const operation = this.updateQueue.then(async () => {
|
|
const restored = structuredClone(snapshot)
|
|
await writeJsonFileAtomically(this.filePath, restored)
|
|
this.settings = restored
|
|
this.loadWarnings = []
|
|
return this.toPublicSettings(restored)
|
|
})
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation
|
|
}
|
|
|
|
async getPolicySettings(): Promise<RuntimePolicySettings> {
|
|
const settings = await this.load()
|
|
return {
|
|
subagentSmartRoutingEnabled:
|
|
settings.subagentSmartRoutingEnabled,
|
|
}
|
|
}
|
|
|
|
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
|
|
const settings = await this.load()
|
|
const credentials = this.resolveModelCredentials(settings)
|
|
const effective = this.resolveEffectiveModelSettings(
|
|
settings,
|
|
credentials
|
|
)
|
|
const agent = this.resolveAgentSettings(settings)
|
|
const modelProfiles = this.resolveModelProfiles(
|
|
settings,
|
|
effective,
|
|
credentials
|
|
)
|
|
const profilesById = new Map(
|
|
modelProfiles.map((profile) => [profile.id, profile])
|
|
)
|
|
const opencodeModelProfile =
|
|
!agent.opencodeBaseUrl &&
|
|
settings.opencodeModelSource.kind !== 'platform'
|
|
? profilesById.get(
|
|
settings.opencodeModelSource.kind === 'default'
|
|
? compatibleTextProfileId(settings) ?? ''
|
|
: settings.opencodeModelSource.profileId
|
|
)
|
|
: undefined
|
|
const continueModelProfile =
|
|
settings.continueModelSource.kind !== 'platform'
|
|
? profilesById.get(
|
|
settings.continueModelSource.kind === 'default'
|
|
? compatibleTextProfileId(settings) ?? ''
|
|
: settings.continueModelSource.profileId
|
|
)
|
|
: undefined
|
|
const deepseekHarnessModelProfile =
|
|
this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles)
|
|
const deepseekHarnessPlatformModelProfile =
|
|
this.resolveDeepSeekHarnessModelProfile(
|
|
{ ...settings, deepseekHarnessModelSource: { kind: 'platform' } },
|
|
modelProfiles
|
|
)
|
|
const embeddingConnections =
|
|
this.resolveEmbeddingConnections(settings)
|
|
const activeEmbeddingConnection =
|
|
embeddingConnections.find(
|
|
(connection) =>
|
|
connection.id === settings.activeEmbeddingConnectionId
|
|
)
|
|
if (!activeEmbeddingConnection) {
|
|
throw new Error('当前向量连接不存在')
|
|
}
|
|
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,
|
|
defaultImageModelProfileId: settings.defaultImageModelProfileId,
|
|
continueModelProfile,
|
|
deepseekHarnessModelProfile,
|
|
deepseekHarnessPlatformModelProfile,
|
|
...agent,
|
|
subagentSmartRoutingEnabled:
|
|
settings.subagentSmartRoutingEnabled,
|
|
knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
|
|
knowledgeEmbeddingApiKey:
|
|
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() ||
|
|
this.getStoredEmbeddingApiKey(settings),
|
|
embeddingConnections,
|
|
activeEmbeddingConnectionId:
|
|
settings.activeEmbeddingConnectionId,
|
|
activeEmbeddingConnection,
|
|
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: settings.knowledgeRerankModel,
|
|
knowledgeRerankApiKey:
|
|
this.environment.GOODBUDDY_RERANK_API_KEY?.trim() ||
|
|
this.getStoredRerankApiKey(settings),
|
|
contextCompression: settings.contextCompression,
|
|
runtimeCustomization: settings.runtimeCustomization,
|
|
}
|
|
}
|
|
|
|
update(input: RuntimeSettingsInput): Promise<RuntimeSettings> {
|
|
const operation = this.updateQueue.then(() => this.performUpdate(input))
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation
|
|
}
|
|
|
|
setActiveEmbeddingConnection(
|
|
connectionId: string
|
|
): Promise<ActiveEmbeddingConnectionUpdate> {
|
|
let result: ActiveEmbeddingConnectionUpdate | undefined
|
|
const operation = this.updateQueue.then(async () => {
|
|
const current = await this.load()
|
|
const previousConnectionId =
|
|
current.activeEmbeddingConnectionId
|
|
const changed = previousConnectionId !== connectionId
|
|
if (changed) {
|
|
const next = this.withActiveEmbeddingConnection(
|
|
current,
|
|
connectionId
|
|
)
|
|
await writeJsonFileAtomically(this.filePath, next)
|
|
this.settings = next
|
|
this.loadWarnings = []
|
|
}
|
|
result = {
|
|
changed,
|
|
previousConnectionId,
|
|
restore: () =>
|
|
this.restoreActiveEmbeddingConnection(
|
|
connectionId,
|
|
previousConnectionId
|
|
)
|
|
}
|
|
})
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation.then(() => result!)
|
|
}
|
|
|
|
private restoreActiveEmbeddingConnection(
|
|
expectedConnectionId: string,
|
|
previousConnectionId: string
|
|
): Promise<RuntimeSettings> {
|
|
const operation = this.updateQueue.then(async () => {
|
|
const current = await this.load()
|
|
if (
|
|
current.activeEmbeddingConnectionId !== expectedConnectionId
|
|
) {
|
|
return this.toPublicSettings(current)
|
|
}
|
|
const restored = this.withActiveEmbeddingConnection(
|
|
current,
|
|
previousConnectionId
|
|
)
|
|
await writeJsonFileAtomically(this.filePath, restored)
|
|
this.settings = restored
|
|
this.loadWarnings = []
|
|
return this.toPublicSettings(restored)
|
|
})
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation
|
|
}
|
|
|
|
private withActiveEmbeddingConnection(
|
|
current: StoredSettings,
|
|
connectionId: string
|
|
): StoredSettings {
|
|
const connectionExists = current.embeddingConnections.some(
|
|
(candidate) => candidate.id === connectionId
|
|
)
|
|
if (!connectionExists) {
|
|
throw new Error('当前向量连接不存在')
|
|
}
|
|
return {
|
|
...current,
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
activeEmbeddingConnectionId: connectionId
|
|
}
|
|
}
|
|
|
|
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,
|
|
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
|
contextWindowTokens: profile.contextWindowTokens,
|
|
maximumOutputTokens: profile.maximumOutputTokens,
|
|
imageGenerationQuality: input.imageGenerationQuality,
|
|
requestHeaders: profile.requestHeaders,
|
|
requestBody: profile.requestBody,
|
|
apiKey: input.apiKey
|
|
}
|
|
: {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
supportsImageInput: profile.supportsImageInput,
|
|
allowConversationInvocation: profile.allowConversationInvocation ?? false,
|
|
contextWindowTokens: profile.contextWindowTokens,
|
|
maximumOutputTokens: profile.maximumOutputTokens,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
requestHeaders: profile.requestHeaders,
|
|
requestBody: profile.requestBody,
|
|
apiKey: { action: 'keep' as const }
|
|
}
|
|
)
|
|
if (
|
|
(
|
|
profileInputs.some(
|
|
(profile) =>
|
|
profile.authentication === 'api-key' &&
|
|
profile.apiKey.action === 'replace'
|
|
) ||
|
|
input.knowledgeEmbeddingApiKey?.action === 'replace' ||
|
|
input.embeddingConnections?.some(
|
|
(connection) =>
|
|
connection.kind === 'openai-compatible' &&
|
|
connection.authentication === 'api-key' &&
|
|
connection.apiKey.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
|
|
)
|
|
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,
|
|
allowConversationInvocation:
|
|
profile.allowConversationInvocation ??
|
|
existing?.allowConversationInvocation ?? false,
|
|
contextWindowTokens: profile.contextWindowTokens,
|
|
maximumOutputTokens: profile.maximumOutputTokens,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
requestHeaders: profile.requestHeaders ?? {},
|
|
requestBody: profile.requestBody ?? {}
|
|
}
|
|
if (profile.apiKey.action === 'copy') {
|
|
const sourceProfileId = profile.apiKey.sourceProfileId
|
|
const source = current.modelProfiles.find(
|
|
(candidate) => candidate.id === sourceProfileId
|
|
)
|
|
if (!source) {
|
|
throw new Error('复制凭据的源模型连接不存在,请重新选择连接')
|
|
}
|
|
const environmentKey = source.id === current.defaultModelProfileId
|
|
? this.getEnvironmentApiKey()
|
|
: undefined
|
|
if (environmentKey) {
|
|
nextProfile.credential = encryptSavedApiKey(this.cipher, environmentKey)
|
|
} else if (source.credential) {
|
|
nextProfile.credential = source.credential
|
|
}
|
|
} else if (
|
|
profile.apiKey.action === 'keep' &&
|
|
existing?.credential
|
|
) {
|
|
nextProfile.credential = existing.credential
|
|
} else if (
|
|
profile.authentication === 'api-key' &&
|
|
profile.apiKey.action === 'replace'
|
|
) {
|
|
nextProfile.credential = encryptSavedApiKey(
|
|
this.cipher,
|
|
profile.apiKey.value
|
|
)
|
|
}
|
|
return nextProfile
|
|
})
|
|
|
|
const embeddingConnections: StoredSettings['embeddingConnections'] =
|
|
input.embeddingConnections
|
|
? input.embeddingConnections.map((connection) => {
|
|
if (connection.kind === 'builtin') {
|
|
return {
|
|
id: builtinEmbeddingConnectionId,
|
|
name: 'GoodBuddy 内置向量模型',
|
|
kind: 'builtin'
|
|
}
|
|
}
|
|
const existing = current.embeddingConnections.find(
|
|
(candidate) =>
|
|
candidate.kind === 'openai-compatible' &&
|
|
candidate.id === connection.id
|
|
)
|
|
const nextConnection: Extract<
|
|
StoredSettings['embeddingConnections'][number],
|
|
{ kind: 'openai-compatible' }
|
|
> = {
|
|
id: connection.id,
|
|
name: connection.name,
|
|
kind: connection.kind,
|
|
baseUrl: normalizeModelBaseUrl(connection.baseUrl),
|
|
modelName: connection.modelName,
|
|
authentication: connection.authentication
|
|
}
|
|
if (
|
|
connection.apiKey.action === 'keep' &&
|
|
existing?.kind === 'openai-compatible' &&
|
|
existing.credential
|
|
) {
|
|
nextConnection.credential = existing.credential
|
|
} else if (
|
|
connection.authentication === 'api-key' &&
|
|
connection.apiKey.action === 'replace'
|
|
) {
|
|
nextConnection.credential = encryptSavedApiKey(
|
|
this.cipher,
|
|
connection.apiKey.value
|
|
)
|
|
}
|
|
return nextConnection
|
|
})
|
|
: current.embeddingConnections
|
|
if (
|
|
!embeddingConnections.some(
|
|
(connection) =>
|
|
connection.id === builtinEmbeddingConnectionId &&
|
|
connection.kind === 'builtin'
|
|
)
|
|
) {
|
|
throw new Error('系统向量连接不能删除')
|
|
}
|
|
const activeEmbeddingConnectionId =
|
|
input.activeEmbeddingConnectionId ??
|
|
current.activeEmbeddingConnectionId
|
|
if (
|
|
!embeddingConnections.some(
|
|
(connection) =>
|
|
connection.id === activeEmbeddingConnectionId
|
|
)
|
|
) {
|
|
throw new Error('当前向量连接不存在')
|
|
}
|
|
|
|
const embeddingEndpoint = new URL(
|
|
input.knowledgeEmbeddingBaseUrl
|
|
).toString()
|
|
const embeddingApiKeyUpdate =
|
|
input.knowledgeEmbeddingApiKey ?? { action: 'keep' as const }
|
|
let knowledgeEmbeddingCredential: StoredSettings['knowledgeEmbeddingCredential']
|
|
if (
|
|
embeddingApiKeyUpdate.action === 'keep' &&
|
|
current.knowledgeEmbeddingCredential
|
|
) {
|
|
knowledgeEmbeddingCredential =
|
|
current.knowledgeEmbeddingCredential
|
|
} else if (embeddingApiKeyUpdate.action === 'replace') {
|
|
knowledgeEmbeddingCredential = encryptSavedApiKey(
|
|
this.cipher,
|
|
embeddingApiKeyUpdate.value
|
|
)
|
|
}
|
|
const synchronizedEmbeddingConnections =
|
|
input.embeddingConnections
|
|
? embeddingConnections
|
|
: embeddingConnections.map((connection) =>
|
|
connection.id === legacyEmbeddingConnectionId &&
|
|
connection.kind === 'openai-compatible'
|
|
? {
|
|
...connection,
|
|
baseUrl: embeddingEndpoint,
|
|
modelName: input.knowledgeEmbeddingModel,
|
|
credential: knowledgeEmbeddingCredential
|
|
}
|
|
: connection
|
|
)
|
|
const activeEmbeddingConnection =
|
|
synchronizedEmbeddingConnections.find(
|
|
(connection) =>
|
|
connection.id === activeEmbeddingConnectionId
|
|
)
|
|
const compatibleEmbeddingConnection =
|
|
activeEmbeddingConnection?.kind === 'openai-compatible'
|
|
? activeEmbeddingConnection
|
|
: undefined
|
|
|
|
const rerankEndpoint = new URL(
|
|
input.knowledgeRerankEndpoint
|
|
).toString()
|
|
const rerankApiKeyUpdate =
|
|
input.knowledgeRerankApiKey ?? { action: 'keep' as const }
|
|
let knowledgeRerankCredential: StoredSettings['knowledgeRerankCredential']
|
|
if (
|
|
rerankApiKeyUpdate.action === 'keep' &&
|
|
current.knowledgeRerankCredential
|
|
) {
|
|
knowledgeRerankCredential = current.knowledgeRerankCredential
|
|
} else if (rerankApiKeyUpdate.action === 'replace') {
|
|
knowledgeRerankCredential = encryptSavedApiKey(
|
|
this.cipher,
|
|
rerankApiKeyUpdate.value
|
|
)
|
|
}
|
|
|
|
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 =
|
|
source.kind === 'default'
|
|
? (modelProfiles.find(
|
|
(candidate) =>
|
|
candidate.id === defaultModelProfileId &&
|
|
isAgentRuntimeModelProtocol(candidate.protocol)
|
|
) ??
|
|
modelProfiles.find((candidate) =>
|
|
isAgentRuntimeModelProtocol(candidate.protocol)
|
|
))
|
|
: 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 !== 'profile') {
|
|
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 !== 'platform') {
|
|
const profile =
|
|
requestedDeepSeekHarnessSource.kind === 'default'
|
|
? (modelProfiles.find(
|
|
(candidate) =>
|
|
candidate.id === defaultModelProfileId &&
|
|
isDeepSeekHarnessModelProfile(candidate)
|
|
) ?? modelProfiles.find(isDeepSeekHarnessModelProfile))
|
|
: modelProfiles.find(
|
|
(candidate) =>
|
|
candidate.id === requestedDeepSeekHarnessSource.profileId
|
|
)
|
|
if (!profile) {
|
|
throw new Error('DeepSeek Harness 引用的模型连接不存在')
|
|
}
|
|
if (!isDeepSeekHarnessModelProfile(profile)) {
|
|
throw new Error(
|
|
'DeepSeek Harness 仅支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接'
|
|
)
|
|
}
|
|
}
|
|
const requestedContextCompression =
|
|
input.contextCompression ?? current.contextCompression
|
|
const requestedContextModelSource =
|
|
requestedContextCompression.modelSource
|
|
const contextCompression =
|
|
requestedContextModelSource.kind === 'profile' &&
|
|
!modelProfiles.some(
|
|
(profile) =>
|
|
profile.id === requestedContextModelSource.profileId &&
|
|
isAgentRuntimeModelProtocol(profile.protocol)
|
|
)
|
|
? {
|
|
...requestedContextCompression,
|
|
modelSource: { kind: 'current' as const }
|
|
}
|
|
: requestedContextCompression
|
|
|
|
const next: StoredSettings = {
|
|
...current,
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
provider: input.provider === 'auto' ? 'model' : input.provider,
|
|
modelProfiles,
|
|
defaultModelProfileId,
|
|
opencodeModelSource,
|
|
defaultImageModelProfileId: input.defaultImageModelProfileId === undefined
|
|
? current.defaultImageModelProfileId
|
|
: input.defaultImageModelProfileId,
|
|
continueModelSource,
|
|
deepseekHarnessModelSource:
|
|
requestedDeepSeekHarnessSource,
|
|
opencodeBaseUrl,
|
|
opencodeEmbedded: !opencodeBaseUrl,
|
|
opencodeBinaryPath,
|
|
opencodeConfigPath,
|
|
continueBinaryPath,
|
|
continueConfigPath,
|
|
continueMode: input.continueMode,
|
|
subagentSmartRoutingEnabled:
|
|
input.subagentSmartRoutingEnabled ??
|
|
current.subagentSmartRoutingEnabled,
|
|
knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl:
|
|
compatibleEmbeddingConnection?.baseUrl ?? embeddingEndpoint,
|
|
knowledgeEmbeddingModel:
|
|
compatibleEmbeddingConnection?.modelName ??
|
|
input.knowledgeEmbeddingModel,
|
|
knowledgeEmbeddingCredential:
|
|
compatibleEmbeddingConnection
|
|
? compatibleEmbeddingConnection.credential
|
|
: knowledgeEmbeddingCredential,
|
|
embeddingConnections: synchronizedEmbeddingConnections,
|
|
activeEmbeddingConnectionId,
|
|
knowledgeRerankEnabled: input.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: rerankEndpoint,
|
|
knowledgeRerankModel: input.knowledgeRerankModel,
|
|
knowledgeRerankCredential,
|
|
contextCompression,
|
|
runtimeCustomization:
|
|
input.runtimeCustomization ??
|
|
current.runtimeCustomization,
|
|
workspacePath: input.workspacePath,
|
|
}
|
|
|
|
await writeJsonFileAtomically(this.filePath, next)
|
|
this.settings = next
|
|
this.loadWarnings = []
|
|
return this.toPublicSettings(next)
|
|
}
|
|
|
|
async getRuntimeCustomization(): Promise<RuntimeCustomizationSettings> {
|
|
return structuredClone((await this.load()).runtimeCustomization)
|
|
}
|
|
|
|
updateRuntimeCustomization(
|
|
input: RuntimeCustomizationSettings
|
|
): Promise<RuntimeCustomizationSettings> {
|
|
const parsed = runtimeCustomizationSettingsSchema.parse(input)
|
|
let result: RuntimeCustomizationSettings | undefined
|
|
const operation = this.updateQueue.then(async () => {
|
|
const current = await this.load()
|
|
const next: StoredSettings = {
|
|
...current,
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
runtimeCustomization: parsed
|
|
}
|
|
await writeJsonFileAtomically(this.filePath, next)
|
|
this.settings = next
|
|
result = structuredClone(next.runtimeCustomization)
|
|
})
|
|
this.updateQueue = operation.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return operation.then(() => result!)
|
|
}
|
|
|
|
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}不存在、不可访问或不是普通文件`)
|
|
}
|
|
}
|
|
}
|