feat: add persistent desktop assistant workspace

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-31 22:33:03 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 698a15ad14
commit 6ef1795b81
101 changed files with 31866 additions and 1176 deletions
+556 -75
View File
@@ -1,31 +1,110 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import {
mkdir,
readFile,
realpath,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname } from 'node:path'
import { z } from 'zod'
import {
continueModeSchema,
defaultModelProfileId,
defaultRuntimeSettings,
runtimeModelSourceSchema,
runtimePathSchema,
runtimeProviderSchema,
toolApprovalPolicySchema,
RuntimeSettings,
type RuntimeSettingsInput
} from '../shared/contracts'
const storedSettingsSchema = z.object({
version: z.literal(1),
const credentialSchema = z
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional()
const version4StoredSettingsSchema = z.object({
version: z.literal(4),
provider: runtimeProviderSchema,
bigtokenBaseUrl: z.string(),
bigtokenModel: z.string(),
credential: z
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional(),
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 storedModelProfileSchema = z.object({
id: z.string().uuid(),
name: z.string(),
baseUrl: z.string(),
modelName: z.string(),
credential: credentialSchema
})
const storedSettingsSchema = z.object({
version: z.literal(5),
provider: runtimeProviderSchema,
modelProfiles: z.array(storedModelProfileSchema).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
})
type StoredSettings = z.infer<typeof storedSettingsSchema>
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(),
@@ -40,19 +119,93 @@ export type CredentialCipher = {
export type ResolvedRuntimeSettings = {
provider: RuntimeSettings['provider']
bigtokenBaseUrl: string
bigtokenModel: string
modelBaseUrl: string
modelName: string
apiKey?: string
opencodeModelProfile?: ResolvedModelProfile
continueModelProfile?: ResolvedModelProfile
opencodeBaseUrl: string
opencodeEmbedded: boolean
opencodeBinaryPath: string
opencodeConfigPath: string
continueBinaryPath: string
continueConfigPath: string
continueMode: RuntimeSettings['continueMode']
workspacePath: string
toolApproval: RuntimeSettings['toolApproval']
}
export type ResolvedModelProfile = {
id: string
name: string
baseUrl: string
modelName: string
apiKey?: string
}
const defaultSettings: StoredSettings = {
version: 1,
...defaultRuntimeSettings
version: 5,
provider: defaultRuntimeSettings.provider,
modelProfiles: [
{
id: defaultModelProfileId,
name: '默认模型',
baseUrl: defaultRuntimeSettings.modelBaseUrl,
modelName: defaultRuntimeSettings.modelName
}
],
defaultModelProfileId,
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' },
opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl,
opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded,
opencodeBinaryPath: defaultRuntimeSettings.opencodeBinaryPath,
opencodeConfigPath: defaultRuntimeSettings.opencodeConfigPath,
continueBinaryPath: defaultRuntimeSettings.continueBinaryPath,
continueConfigPath: defaultRuntimeSettings.continueConfigPath,
continueMode: defaultRuntimeSettings.continueMode,
workspacePath: defaultRuntimeSettings.workspacePath,
toolApproval: defaultRuntimeSettings.toolApproval
}
function migrateContinueCommand(command: string): string {
const value = command.trim()
return value === 'cn' ? '' : value
}
function migrateVersion4(
settings: z.infer<typeof version4StoredSettingsSchema>
): StoredSettings {
return {
version: 5,
provider: settings.provider,
modelProfiles: [
{
id: defaultModelProfileId,
name: '默认模型',
baseUrl: settings.modelBaseUrl,
modelName: settings.modelName,
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,
workspacePath: settings.workspacePath,
toolApproval: settings.toolApproval
}
}
export class RuntimeSettingsStore {
private settings?: StoredSettings
private loadWarning?: string
private updateQueue: Promise<void> = Promise.resolve()
constructor(
@@ -68,26 +221,104 @@ export class RuntimeSettingsStore {
try {
const contents = await readFile(this.filePath, 'utf8')
this.settings = storedSettingsSchema.parse(JSON.parse(contents))
} catch {
const parsed: unknown = JSON.parse(contents)
const current = storedSettingsSchema.safeParse(parsed)
if (current.success) {
this.settings = current.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
})
}
}
}
}
} catch (error) {
if (
!(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
) {
this.loadWarning =
'Runtime 设置文件已损坏,已隔离原文件并恢复默认设置'
await rename(
this.filePath,
`${this.filePath}.corrupt-${Date.now()}`
).catch(() => undefined)
}
this.settings = { ...defaultSettings }
}
return this.settings
}
private getStoredApiKey(settings: StoredSettings): string | undefined {
if (!settings.credential || !this.cipher.isAvailable()) {
private getStoredApiKey(
profile: StoredSettings['modelProfiles'][number]
): string | undefined {
if (!profile.credential || !this.cipher.isAvailable()) {
return undefined
}
try {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(settings.credential.ciphertextBase64, 'base64')
Buffer.from(profile.credential.ciphertextBase64, 'base64')
)
)
)
return payload.origin === new URL(settings.bigtokenBaseUrl).origin
return payload.origin === new URL(profile.baseUrl).origin
? payload.apiKey
: undefined
} catch {
@@ -96,28 +327,42 @@ export class RuntimeSettingsStore {
}
private getEnvironmentApiKey(): string | undefined {
return this.environment.GOODBUDDY_BIGTOKEN_API_KEY?.trim() || undefined
return (
this.environment.GOODBUDDY_MODEL_API_KEY?.trim() ||
this.environment.GOODBUDDY_BIGTOKEN_API_KEY?.trim() ||
undefined
)
}
private resolveEffectiveBigtokenSettings(settings: StoredSettings): {
private resolveEffectiveModelSettings(settings: StoredSettings): {
apiKey?: string
baseUrl: string
model: string
credentialSource: RuntimeSettings['credentialSource']
} {
const profile =
settings.modelProfiles.find(
(candidate) => candidate.id === settings.defaultModelProfileId
) ?? settings.modelProfiles[0]
if (!profile) {
throw new Error('默认模型连接不存在')
}
const environmentApiKey = this.getEnvironmentApiKey()
const storedApiKey = this.getStoredApiKey(settings)
const storedApiKey = this.getStoredApiKey(profile)
const environmentBaseUrl =
this.environment.GOODBUDDY_MODEL_BASE_URL?.trim() ||
this.environment.GOODBUDDY_BIGTOKEN_BASE_URL?.trim()
const environmentModel = this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim()
const environmentModel =
this.environment.GOODBUDDY_MODEL_NAME?.trim() ||
this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim()
return {
apiKey: environmentApiKey ?? storedApiKey,
baseUrl: environmentApiKey
? environmentBaseUrl || defaultSettings.bigtokenBaseUrl
: settings.bigtokenBaseUrl,
? environmentBaseUrl || defaultRuntimeSettings.modelBaseUrl
: profile.baseUrl,
model: environmentApiKey
? environmentModel || defaultSettings.bigtokenModel
: settings.bigtokenModel,
? environmentModel || defaultRuntimeSettings.modelName
: profile.modelName,
credentialSource: environmentApiKey
? 'environment'
: storedApiKey
@@ -126,16 +371,124 @@ export class RuntimeSettingsStore {
}
}
private resolveProfile(
settings: StoredSettings,
profileId: string
): ResolvedModelProfile | undefined {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === profileId
)
if (!profile) {
return undefined
}
if (profile.id === settings.defaultModelProfileId) {
const effective = this.resolveEffectiveModelSettings(settings)
return {
id: profile.id,
name: profile.name,
baseUrl: effective.baseUrl,
modelName: effective.model,
apiKey: effective.apiKey
}
}
return {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
apiKey: this.getStoredApiKey(profile)
}
}
private resolveAgentSettings(settings: StoredSettings): {
opencodeBaseUrl: string
opencodeEmbedded: boolean
opencodeBinaryPath: string
opencodeConfigPath: string
continueBinaryPath: string
continueConfigPath: string
continueMode: RuntimeSettings['continueMode']
workspacePath: string
} {
const embeddedEnvironment =
this.environment.GOODBUDDY_OPENCODE_EMBEDDED?.trim()
const continueBinaryEnvironment =
this.environment.GOODBUDDY_CONTINUE_BINARY?.trim()
const legacyContinueCommand =
this.environment.GOODBUDDY_CONTINUE_COMMAND?.trim()
return {
opencodeBaseUrl:
this.environment.GOODBUDDY_OPENCODE_URL?.trim() ??
settings.opencodeBaseUrl,
opencodeEmbedded:
embeddedEnvironment === undefined
? settings.opencodeEmbedded
: embeddedEnvironment === 'true',
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 effective = this.resolveEffectiveBigtokenSettings(settings)
const effective = this.resolveEffectiveModelSettings(settings)
const agent = this.resolveAgentSettings(settings)
const modelProfiles = settings.modelProfiles.map((profile) => {
const isDefault = profile.id === settings.defaultModelProfileId
const apiKey = this.getStoredApiKey(profile)
return {
id: profile.id,
name: profile.name,
baseUrl: isDefault ? effective.baseUrl : profile.baseUrl,
modelName: isDefault ? effective.model : profile.modelName,
apiKeyConfigured: isDefault
? Boolean(effective.apiKey)
: Boolean(apiKey),
credentialSource: isDefault
? effective.credentialSource
: apiKey
? ('encrypted' as const)
: ('none' as const)
}
})
return {
provider: settings.provider,
bigtokenBaseUrl: effective.baseUrl,
bigtokenModel: effective.model,
modelBaseUrl: effective.baseUrl,
modelName: effective.model,
opencodeBaseUrl: agent.opencodeBaseUrl,
opencodeEmbedded: agent.opencodeEmbedded,
opencodeBinaryPath: agent.opencodeBinaryPath,
opencodeConfigPath: agent.opencodeConfigPath,
continueBinaryPath: agent.continueBinaryPath,
continueConfigPath: agent.continueConfigPath,
continueMode: agent.continueMode,
workspacePath: agent.workspacePath,
apiKeyConfigured: Boolean(effective.apiKey),
credentialSource: effective.credentialSource,
modelProfiles,
defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelSource: settings.opencodeModelSource,
continueModelSource: settings.continueModelSource,
secureStorageAvailable: this.cipher.isAvailable(),
toolApproval: settings.toolApproval
toolApproval: settings.toolApproval,
warning: this.loadWarning
}
}
@@ -145,12 +498,30 @@ export class RuntimeSettingsStore {
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
const settings = await this.load()
const effective = this.resolveEffectiveBigtokenSettings(settings)
const effective = this.resolveEffectiveModelSettings(settings)
const agent = this.resolveAgentSettings(settings)
const opencodeModelProfile =
settings.opencodeModelSource.kind === 'profile'
? this.resolveProfile(
settings,
settings.opencodeModelSource.profileId
)
: undefined
const continueModelProfile =
settings.continueModelSource.kind === 'profile'
? this.resolveProfile(
settings,
settings.continueModelSource.profileId
)
: undefined
return {
provider: settings.provider,
bigtokenBaseUrl: effective.baseUrl,
bigtokenModel: effective.model,
modelBaseUrl: effective.baseUrl,
modelName: effective.model,
apiKey: effective.apiKey,
opencodeModelProfile,
continueModelProfile,
...agent,
toolApproval: settings.toolApproval
}
}
@@ -168,55 +539,165 @@ export class RuntimeSettingsStore {
input: RuntimeSettingsInput
): Promise<RuntimeSettings> {
const current = await this.load()
const normalizedOrigin = new URL(input.bigtokenBaseUrl).origin
const previousOrigin = new URL(current.bigtokenBaseUrl).origin
if (
input.apiKey.action === 'keep' &&
current.credential &&
previousOrigin !== normalizedOrigin
) {
throw new Error('服务地址已更改,请重新输入或清除已保存的 API Key')
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,
apiKey: input.apiKey
}
: {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
apiKey: { action: 'keep' as const }
}
)
if (
profileInputs.some(
(profile) => profile.apiKey.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 normalizedOrigin = new URL(profile.baseUrl).origin
if (
profile.apiKey.action === 'keep' &&
existing?.credential &&
new URL(existing.baseUrl).origin !== normalizedOrigin
) {
throw new Error(
`模型连接“${profile.name}”的服务地址已更改,请重新输入或清除 API Key`
)
}
const nextProfile: StoredSettings['modelProfiles'][number] = {
id: profile.id,
name: profile.name,
baseUrl: normalizedOrigin,
modelName: profile.modelName
}
if (profile.apiKey.action === 'keep' && existing?.credential) {
nextProfile.credential = existing.credential
} else if (profile.apiKey.action === 'replace') {
nextProfile.credential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
apiKey: profile.apiKey.value,
origin: normalizedOrigin
})
)
.toString('base64')
}
}
return nextProfile
})
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 next: StoredSettings = {
...current,
version: 5,
provider: input.provider,
bigtokenBaseUrl: normalizedOrigin,
bigtokenModel: input.bigtokenModel,
modelProfiles,
defaultModelProfileId:
input.defaultModelProfileId ??
(input.modelProfiles
? modelProfiles[0]!.id
: current.defaultModelProfileId),
opencodeModelSource:
input.opencodeModelSource ?? current.opencodeModelSource,
continueModelSource:
input.continueModelSource ?? current.continueModelSource,
opencodeBaseUrl: input.opencodeBaseUrl
? new URL(input.opencodeBaseUrl).origin
: '',
opencodeEmbedded: input.opencodeEmbedded,
opencodeBinaryPath,
opencodeConfigPath,
continueBinaryPath,
continueConfigPath,
continueMode: input.continueMode,
workspacePath: input.workspacePath,
toolApproval: input.toolApproval
}
if (input.apiKey.action === 'clear') {
delete next.credential
} else if (input.apiKey.action === 'replace') {
if (!this.cipher.isAvailable()) {
throw new Error(
'当前系统安全存储不可用,API Key 未保存。请启用系统密钥服务或使用环境变量。'
)
}
next.credential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
apiKey: input.apiKey.value,
origin: normalizedOrigin
})
)
.toString('base64')
}
}
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
await rename(temporaryPath, this.filePath)
try {
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
await rename(temporaryPath, this.filePath)
} finally {
await rm(temporaryPath, { force: true })
}
this.settings = next
this.loadWarning = undefined
return this.toPublicSettings(next)
}
private async canonicalizeRuntimeFile(
filePath: string,
label: string
): Promise<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}不存在、不可访问或不是普通文件`)
}
}
}