Files
goodbuddy/src/main/application-settings-store.ts
T
mesalogo 48d7526b7e feat: configure supervisor model and story graph use per conversation
Add a supervisor text-model selector using existing connections and a per-conversation story graph switch above knowledge settings. Hide the switch when Supervisor is disabled and enforce conversation preferences at local and remote tool execution boundaries.

Release note: Choose a dedicated supervisor model and control whether each conversation can query existing stories and experiences. Enabling graph access does not start a review or automatically insert context.
2026-10-03 19:16:35 +08:00

636 lines
22 KiB
TypeScript

import { readFile, realpath, stat } from 'node:fs/promises'
import { delimiter, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
import {
applicationSettingsSchema,
applicationSettingsUpdateSchema,
applicationNavigationSchema,
builtInApplicationIds,
defaultApplicationNavigation,
defaultSupervisionTimeoutSeconds,
defaultSupervisorModelConcurrency,
type ApplicationSettings
} from '../shared/application-settings-contracts'
import {
defaultLocalToolEnvironmentSettings,
localToolExecutablePathSchema,
type LocalToolEnvironmentSettings
} from '../shared/local-tool-environment-contracts'
import { inspectLocalToolExecutable } from './local-tool-environment/local-tool-environment'
import { releaseVersionSchema } from '../shared/release-notes-contracts'
import type { SettingsWarning } from '../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from './settings-file-utils'
export {
applicationSettingsSchema,
applicationSettingsUpdateSchema
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 12
const legacyStoredApplicationSettingsSchema = z
.object({
version: z.union([z.literal(1), z.literal(2)]),
checkUpdatesOnStartup: z.boolean()
})
.strict()
const versionTwoStoredApplicationSettingsSchema = z
.object({
version: z.literal(2),
checkUpdatesOnStartup: z.boolean(),
magicNotesEnabled: z.boolean()
})
.strict()
const versionThreeStoredApplicationSettingsSchema = z
.object({
version: z.literal(3),
checkUpdatesOnStartup: z.boolean(),
magicNotesEnabled: z.boolean(),
magicNoteCommentMode: applicationSettingsSchema.shape.magicNoteCommentMode
})
.strict()
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(4)
})
.strict()
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(5),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(6),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(7),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true
})
.extend({
version: z.literal(8),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true
})
.extend({
version: z.literal(9),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionTenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ conversationHtmlRenderingEnabled: true })
.extend({
version: z.literal(10),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
// Stored partial orders are upgraded here; public settings writes remain strict.
applicationNavigation: applicationNavigationSchema.extend({
order: z.array(z.enum(builtInApplicationIds))
.refine(ids => new Set(ids).size === ids.length, 'Duplicate application')
.default([])
.transform(ids => [
...(['knowledge', 'heartbeat'] as const).filter(id => !ids.includes(id)),
...ids,
...defaultApplicationNavigation.order.filter(id => id !== 'knowledge' && id !== 'heartbeat' && !ids.includes(id))
]),
pinned: applicationNavigationSchema.shape.pinned.partial()
.default({})
.transform(pinned => ({ ...defaultApplicationNavigation.pinned, ...pinned }))
}).default(defaultApplicationNavigation),
version: z.literal(CURRENT_SETTINGS_VERSION),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionElevenStoredApplicationSettingsSchema = storedApplicationSettingsSchema.extend({ version: z.literal(11) })
type StoredApplicationSettings = z.infer<typeof storedApplicationSettingsSchema>
export type LegacyLocalToolEnvironmentPaths = {
nodeExecutablePath?: string
pythonExecutablePath?: string
}
export type LegacyLocalToolEnvironmentResolver =
() => Promise<LegacyLocalToolEnvironmentPaths>
async function resolveExecutable(
kind: 'node' | 'python',
binaryNames: readonly string[]
): Promise<string | undefined> {
const extensions =
process.platform === 'win32'
? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
.split(';')
.filter((extension) => /^\.[A-Za-z0-9]+$/u.test(extension))
: ['']
const directories = (
process.env.PATH ??
process.env.Path ??
process.env.path ??
''
)
.split(delimiter)
.map((directory) => directory.trim())
.filter(isAbsolute)
for (const directory of directories) {
for (const binaryName of binaryNames) {
const names =
process.platform === 'win32' && !extname(binaryName)
? extensions.map((extension) => `${binaryName}${extension}`)
: [binaryName]
for (const name of names) {
try {
const executablePath = await realpath(join(directory, name))
const metadata = await stat(executablePath)
if (
metadata.isFile() &&
localToolExecutablePathSchema.safeParse(executablePath).success
) {
const candidate = await inspectLocalToolExecutable(
kind,
executablePath,
{ baseEnvironment: process.env }
)
if (candidate) {
return candidate.executablePath
}
}
} catch {
// Continue through PATH candidates.
}
}
}
}
return undefined
}
export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentResolver =
async () => {
const [nodeExecutablePath, pythonExecutablePath] = await Promise.all([
resolveExecutable('node', ['node']),
resolveExecutable(
'python',
process.platform === 'win32'
? ['python', 'python3']
: ['python3', 'python']
)
])
return {
...(nodeExecutablePath ? { nodeExecutablePath } : {}),
...(pythonExecutablePath ? { pythonExecutablePath } : {})
}
}
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true,
deviceSharingEnabled: false,
magicNotesEnabled: true,
heartbeatEnabled: false,
heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds,
supervisorOrganizeTimeoutSeconds: defaultSupervisionTimeoutSeconds,
supervisorModelConcurrency: defaultSupervisorModelConcurrency,
supervisorModelProfileId: null,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
magicNoteCanvasPageCount: 1
}
export class ApplicationSettingsStore {
private settings?: StoredApplicationSettings
private settingsLoad?: Promise<StoredApplicationSettings>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
private readonly listeners = new Set<(settings: ApplicationSettings) => void>()
constructor(
private readonly filePath: string,
private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths
) {}
onChanged(listener: (settings: ApplicationSettings) => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
| 'conversationHtmlRenderingEnabled'
| 'desktopNotificationsEnabled'
| 'transparentFrostedEffectEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
| 'localInferenceEnabled'
| 'deviceSharingEnabled'
| 'heartbeatEnabled'
| 'magicNoteCanvasPageCount'
> & {
conversationHtmlRenderingEnabled?: boolean
desktopNotificationsEnabled?: boolean
magicNoteCanvasPageCount?: number
version: number
}
): Promise<StoredApplicationSettings> {
const resolved = await this.legacyLocalToolEnvironmentResolver()
const selection = (
executablePath: string | undefined
): LocalToolEnvironmentSettings['node'] =>
executablePath &&
localToolExecutablePathSchema.safeParse(executablePath).success
? { source: 'custom', executablePath }
: { source: 'managed' }
const next: StoredApplicationSettings = {
...defaultApplicationSettings,
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
version: CURRENT_SETTINGS_VERSION,
localToolEnvironment: {
node: selection(resolved.nodeExecutablePath),
python: selection(resolved.pythonExecutablePath),
artifactDownloadSource: 'native'
}
}
await this.persist(next)
return next
}
private async isolateCorruptFile(): Promise<void> {
await isolateCorruptSettingsFile(
this.filePath,
'Application settings are corrupt and could not be isolated'
)
}
private async loadStored(): Promise<StoredApplicationSettings> {
if (this.settings) {
return this.settings
}
if (!this.settingsLoad) {
this.settingsLoad = this.readStored().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readStored(): Promise<StoredApplicationSettings> {
try {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
try {
parsed = JSON.parse(contents) as unknown
} catch {
await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
return this.settings
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持应用设置版本 ${version},请升级应用后重试`
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionElevenResult = versionElevenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionElevenResult.success) {
const next: StoredApplicationSettings = { ...versionElevenResult.data, version: CURRENT_SETTINGS_VERSION }
await this.persist(next)
return next
}
const versionTenResult =
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTenResult.success) {
const next: StoredApplicationSettings = {
...versionTenResult.data,
version: CURRENT_SETTINGS_VERSION,
conversationHtmlRenderingEnabled: true
}
await this.persist(next)
return next
}
const versionNineResult =
versionNineStoredApplicationSettingsSchema.safeParse(parsed)
if (versionNineResult.success) {
return this.migrateLegacy(versionNineResult.data)
}
const versionEightResult =
versionEightStoredApplicationSettingsSchema.safeParse(parsed)
if (versionEightResult.success) {
return this.migrateLegacy({
...versionEightResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false
})
}
const versionSevenResult =
versionSevenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSevenResult.success) {
return this.migrateLegacy({
...versionSevenResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
}
const versionSixResult =
versionSixStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSixResult.success) {
return this.migrateLegacy({
...versionSixResult.data,
version: CURRENT_SETTINGS_VERSION,
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
}
const versionFiveResult =
versionFiveStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFiveResult.success) {
return this.migrateLegacy({
...versionFiveResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
}
const versionFourResult =
versionFourStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFourResult.success) {
return this.migrateLegacy({
...versionFourResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
lastSeenReleaseNotesVersion: null
})
}
const versionThreeResult =
versionThreeStoredApplicationSettingsSchema.safeParse(parsed)
if (versionThreeResult.success) {
return this.migrateLegacy({
...versionThreeResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
}
const versionTwoResult =
versionTwoStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTwoResult.success) {
return this.migrateLegacy({
...versionTwoResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
}
const legacyResult =
legacyStoredApplicationSettingsSchema.safeParse(parsed)
if (legacyResult.success) {
return this.migrateLegacy({
version: CURRENT_SETTINGS_VERSION,
checkUpdatesOnStartup: legacyResult.data.checkUpdatesOnStartup,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
}
await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
return this.settings
}
this.settings = result.data
} catch (error) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('Application settings could not be read', {
cause: error
})
}
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
}
return this.settings
}
async get(): Promise<ApplicationSettings> {
const stored = await this.loadStored()
return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
applicationNavigation: stored.applicationNavigation,
localInferenceEnabled: stored.localInferenceEnabled,
deviceSharingEnabled: stored.deviceSharingEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
heartbeatEnabled: stored.heartbeatEnabled ?? false,
heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorOrganizeTimeoutSeconds: stored.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorModelConcurrency: stored.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
supervisorModelProfileId: stored.supervisorModelProfileId ?? null,
supervisionReview: stored.supervisionReview,
magicNotesShowIncompleteTodoCount:
stored.magicNotesShowIncompleteTodoCount,
magicNoteCommentMode: stored.magicNoteCommentMode,
magicNoteCommentFormat: stored.magicNoteCommentFormat,
magicNoteCanvasPageCount: stored.magicNoteCanvasPageCount,
...(this.warnings.length > 0 ? { warnings: [...this.warnings] } : {})
}
}
async getLastSeenReleaseNotesVersion(): Promise<string | null> {
return (await this.loadStored()).lastSeenReleaseNotesVersion
}
private async persist(next: StoredApplicationSettings): Promise<void> {
await writeJsonFileAtomically(this.filePath, next)
this.settings = next
}
update(input: unknown): Promise<ApplicationSettings> {
const operation = this.updateQueue.then(async () => {
const updates = applicationSettingsUpdateSchema.parse(input)
const current = await this.loadStored()
const next: StoredApplicationSettings = {
...current,
...updates,
version: CURRENT_SETTINGS_VERSION
}
await this.persist(next)
this.warnings = []
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
applicationNavigation: next.applicationNavigation,
localInferenceEnabled: next.localInferenceEnabled,
deviceSharingEnabled: next.deviceSharingEnabled,
magicNotesEnabled: next.magicNotesEnabled,
heartbeatEnabled: next.heartbeatEnabled ?? false,
heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorOrganizeTimeoutSeconds: next.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
supervisorModelConcurrency: next.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
supervisorModelProfileId: next.supervisorModelProfileId ?? null,
supervisionReview: next.supervisionReview,
magicNotesShowIncompleteTodoCount:
next.magicNotesShowIncompleteTodoCount,
magicNoteCommentMode: next.magicNoteCommentMode,
magicNoteCommentFormat: next.magicNoteCommentFormat,
magicNoteCanvasPageCount: next.magicNoteCanvasPageCount
}
for (const listener of this.listeners) listener(settings)
return settings
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
setLastSeenReleaseNotesVersion(version: unknown): Promise<void> {
const operation = this.updateQueue.then(async () => {
const parsedVersion = releaseVersionSchema.parse(version)
const current = await this.loadStored()
if (current.lastSeenReleaseNotesVersion === parsedVersion) {
return
}
await this.persist({
...current,
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: parsedVersion
})
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
}