Files
goodbuddy/src/main/index.ts
T

609 lines
19 KiB
TypeScript

import {
app,
BrowserWindow,
dialog,
globalShortcut,
Menu,
safeStorage,
session,
Tray,
utilityProcess
} from 'electron'
import { homedir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import {
createAgentRuntime,
createDefaultModelRuntime,
createModelProfileRuntime
} from './agent/create-runtime'
import { AgentRuntimeController } from './agent/runtime-controller'
import type { AgentRuntime } from './agent/runtime'
import { SelectedRuntimeManager } from './agent/selected-runtime-manager'
import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway'
import {
applyRuntimeSelection,
getConfiguredRuntimeTarget,
type SelectedRuntimeTarget
} from './agent/runtime-selection'
import { CapabilityService } from './capabilities/capability-service'
import { ContextManager } from './context-manager'
import { registerIpcHandlers } from './ipc'
import { KnowledgeService } from './knowledge/knowledge-service'
import { AssistantDatabase } from './assistant/assistant-database'
import { createModelGraphExtractor } from './knowledge/model-extractor'
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
import { RuntimeSettingsStore } from './runtime-settings-store'
import type { ResolvedRuntimeSettings } from './runtime-settings-store'
import { ToolApprovalBroker } from './tool-approval-broker'
import {
createMainWindow,
loadMainWindow,
showWindow,
toggleWindow
} from './window'
import { createTrayIcon } from './tray-icon'
import { resolveBundledRuntimePaths } from './agent/bundled-runtimes'
import type {
ContinueHostChild,
ContinueHostLauncher
} from './agent/continue-host-adapter'
import { resolvePortableUserDataPath } from './portable-user-data'
import { BrowserService } from './browser/browser-service'
import { SubagentService } from './assistant/subagent-service'
import { ChannelSettingsStore } from './channels/channel-settings-store'
import type {
WechatSidecarChild,
WechatSidecarLauncher
} from './channels/wechat-sidecar-client'
import { buildWechatSidecarEnvironment } from './channels/wechat-sidecar-environment'
import { ApplicationSettingsStore } from './application-settings-store'
import { VersionChecker } from './version-checker'
import { SpeechModelManager } from './speech/speech-model-manager'
import { SpeechTranscriptionService } from './speech/speech-transcription-service'
import { EmbeddingIndexCoordinator } from './knowledge/embedding-index-coordinator'
import { KnowledgeEmbeddingIndexRepository } from './knowledge/knowledge-embedding-index-repository'
import { GlobalTlsPolicy } from './global-tls-policy'
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
import { waitForCleanup } from './shutdown'
import { DocumentParsingSettingsStore } from './document-parsing-settings-store'
import { DocumentOcrModelManager } from './document-ocr-model-manager'
import { DocumentOcrBroker } from './document-ocr-broker'
import { DocumentParsingService } from './document-parsing-service'
const shortcut = 'CommandOrControl+Shift+Space'
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
const portableUserDataPath = resolvePortableUserDataPath({
packaged: app.isPackaged,
platform: process.platform,
executablePath: process.execPath
})
if (portableUserDataPath) {
app.setPath('userData', portableUserDataPath)
}
if (process.platform === 'win32') {
app.setAppUserModelId('live.digiman.goodbuddy')
}
const hasSingleInstanceLock = app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
app.quit()
}
let mainWindow: BrowserWindow | undefined
let tray: Tray | undefined
let isQuitting = false
let removeIpcHandlers: (() => Promise<void>) | undefined
let runtime: AgentRuntimeController | undefined
let selectedRuntimeManager: SelectedRuntimeManager | undefined
let knowledgeService: KnowledgeService | undefined
let knowledgeGateway: KnowledgeMcpGateway | undefined
let assistantDatabase: AssistantDatabase | undefined
let browserService: BrowserService | undefined
let globalTlsPolicy: GlobalTlsPolicy | undefined
let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined
function createEmbeddingProvider(
settings: ResolvedRuntimeSettings
): OpenAIEmbeddingClient | undefined {
return settings.knowledgeEmbeddingEnabled
? new OpenAIEmbeddingClient({
endpoint: settings.knowledgeEmbeddingBaseUrl,
model: settings.knowledgeEmbeddingModel,
apiKey: settings.knowledgeEmbeddingApiKey
})
: undefined
}
function createSubagentProfileRuntimes(
defaultWorkspace: string,
settings: ResolvedRuntimeSettings
): ReadonlyMap<string, AgentRuntime> {
return new Map(
settings.modelProfiles
.filter(
(profile) =>
profile.id !== settings.defaultModelProfileId &&
profile.protocol !== 'openai-images-generations'
)
.map(
(profile) =>
[
profile.id,
createModelProfileRuntime(
defaultWorkspace,
settings,
profile
)
] as const
)
)
}
const launchContinueHost: ContinueHostLauncher = (
entryPath,
args,
options
) => {
const utilityChild = utilityProcess.fork(
join(dirname(entryPath), 'utility-bootstrap.mjs'),
[entryPath, ...args],
{
cwd: options.cwd,
env: options.env,
serviceName: 'GoodBuddy Continue Host',
stdio: 'pipe'
}
)
let exitCode: number | null = null
let killed = false
utilityChild.on('exit', (code) => {
exitCode = code
})
const child: ContinueHostChild = {
get exitCode() {
return exitCode
},
get killed() {
return killed
},
get pid() {
return utilityChild.pid
},
stderr: utilityChild.stderr,
once: (_event, listener) => {
utilityChild.once('error', (_type, location, report) => {
listener(
new Error(
`Continue 宿主进程异常(${location}):${report.slice(0, 500)}`
)
)
})
return child
},
kill: () => {
killed = true
return utilityChild.kill()
}
}
return child
}
const launchWechatSidecar: WechatSidecarLauncher = () => {
const utilityChild = utilityProcess.fork(
join(mainModuleDirectory, 'wechat-sidecar.js'),
[],
{
env: buildWechatSidecarEnvironment(),
serviceName: 'GoodBuddy Weixin Transport',
stdio: 'ignore'
}
)
const child: WechatSidecarChild = {
postMessage: (message) => utilityChild.postMessage(message),
kill: () => utilityChild.kill(),
on: (_event, listener) => {
utilityChild.on('message', listener)
return child
},
once: (
event: 'exit' | 'error',
listener: ((code: number | null) => void) | ((error: Error) => void)
) => {
if (event === 'exit') {
utilityChild.once('exit', (code) => {
;(listener as (code: number | null) => void)(code)
})
} else {
utilityChild.once('error', (_type, location, report) => {
;(listener as (error: Error) => void)(
new Error(
`微信 Sidecar 异常(${location}):${report.slice(0, 300)}`
)
)
})
}
return child
}
}
return child
}
function buildTray(): Tray {
const nextTray = new Tray(createTrayIcon())
nextTray.setToolTip('GoodBuddy')
nextTray.setContextMenu(
Menu.buildFromTemplate([
{
label: '打开 GoodBuddy',
click: () => mainWindow && showWindow(mainWindow)
},
{
label: '新建对话',
click: () => {
if (mainWindow) {
showWindow(mainWindow)
mainWindow.webContents.send(ipcChannels.conversationNew)
}
}
},
{
label: '设置',
click: () => {
if (mainWindow) {
showWindow(mainWindow)
mainWindow.webContents.send(ipcChannels.settingsOpen)
}
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
)
nextTray.on('click', () => {
if (mainWindow) {
toggleWindow(mainWindow)
}
})
return nextTray
}
if (hasSingleInstanceLock) {
app.on('second-instance', () => {
if (mainWindow) {
showWindow(mainWindow)
}
})
void app.whenReady().then(async () => {
session.defaultSession.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
const mediaTypes =
'mediaTypes' in details && Array.isArray(details.mediaTypes)
? details.mediaTypes
: []
callback(
permission === 'media' &&
webContents === mainWindow?.webContents &&
mediaTypes.includes('audio') &&
!mediaTypes.includes('video')
)
}
)
session.defaultSession.setPermissionCheckHandler(
(webContents, permission, _origin, details) =>
permission === 'media' &&
webContents === mainWindow?.webContents &&
details.mediaType === 'audio'
)
mainWindow = createMainWindow(() => isQuitting)
tray = buildTray()
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
const secureCipher = {
isAvailable: () =>
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
[
'gnome_libsecret',
'kwallet',
'kwallet5',
'kwallet6'
].includes(safeStorage.getSelectedStorageBackend())),
encrypt: (value: string) => safeStorage.encryptString(value),
decrypt: (value: Buffer) => safeStorage.decryptString(value)
}
const settingsStore = new RuntimeSettingsStore(
join(app.getPath('userData'), 'runtime-settings.json'),
secureCipher
)
const initialRuntimeSettings =
await settingsStore.getPublicSettings()
const initialSettings = await settingsStore.getResolvedSettings()
globalTlsPolicy = new GlobalTlsPolicy(app)
globalTlsPolicy.install()
const capabilityService = new CapabilityService(
join(app.getPath('userData'), 'capabilities.json'),
app.isPackaged
? join(process.resourcesPath, 'skills')
: join(app.getAppPath(), 'resources', 'skills'),
join(app.getPath('userData'), 'skills', 'imported'),
secureCipher
)
const channelSettingsStore = new ChannelSettingsStore(
join(app.getPath('userData'), 'channel-settings.json'),
secureCipher
)
const applicationSettingsStore = new ApplicationSettingsStore(
join(app.getPath('userData'), 'application-settings.json')
)
const documentParsingSettingsStore =
new DocumentParsingSettingsStore(
join(app.getPath('userData'), 'document-parsing-settings.json')
)
documentOcrModelManager = new DocumentOcrModelManager({
userDataDirectory: app.getPath('userData'),
fetch: globalThis.fetch
})
documentOcrBroker = new DocumentOcrBroker(mainWindow)
const documentParsingService = new DocumentParsingService(
documentParsingSettingsStore,
documentOcrModelManager,
documentOcrBroker
)
const versionChecker = new VersionChecker({
fetch: globalThis.fetch,
currentVersion: app.getVersion(),
platform: process.platform,
arch: process.arch
})
const speechModelManager = new SpeechModelManager({
userDataDirectory: app.getPath('userData'),
fetch: globalThis.fetch
})
const speechTranscriptionService = new SpeechTranscriptionService(
speechModelManager
)
browserService = new BrowserService({ parentWindow: mainWindow })
const bundledRuntimePaths = resolveBundledRuntimePaths({
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
packaged: app.isPackaged
})
knowledgeService = new KnowledgeService({
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
managedRoot: join(app.getPath('userData'), 'knowledge'),
extractStructured: createModelGraphExtractor(settingsStore),
parseDocument: documentParsingService.parse
})
await knowledgeService.initialize()
const embeddingIndexCoordinator = new EmbeddingIndexCoordinator(
new KnowledgeEmbeddingIndexRepository(knowledgeService.database)
)
await embeddingIndexCoordinator.initialize()
void knowledgeService
.setEmbeddingProvider(
createEmbeddingProvider(await settingsStore.getResolvedSettings())
)
.catch(() => undefined)
assistantDatabase = new AssistantDatabase(
join(app.getPath('userData'), 'assistant.sqlite')
)
assistantDatabase.initialize(defaultWorkspace)
assistantDatabase.ensureChannelProjects(
defaultWorkspace,
initialRuntimeSettings.defaultModelProfileId
)
assistantDatabase.repairConversationRuntimeSelections(
initialRuntimeSettings
)
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
magicNotesDatabase: assistantDatabase
})
await knowledgeGateway.start()
const subagentService = new SubagentService(
createDefaultModelRuntime(defaultWorkspace, initialSettings),
assistantDatabase,
undefined,
createSubagentProfileRuntimes(
defaultWorkspace,
initialSettings
)
)
const createRuntimeWithCapabilities = async (
settings: ResolvedRuntimeSettings,
target: SelectedRuntimeTarget
): Promise<AgentRuntime> => {
const [
skillContext,
mcpServers,
browserCapability,
webSearchCapability
] =
await Promise.all([
capabilityService.getRuntimeSkillContext(target),
target === 'model'
? capabilityService.getResolvedMcpServers('model')
: Promise.resolve([]),
target === 'model'
? capabilityService.getComputerCapabilityStatus(
'host-browser-control'
)
: Promise.resolve(undefined),
target === 'model'
? capabilityService.getWebSearchCapabilityStatus()
: Promise.resolve(undefined)
])
return createAgentRuntime(defaultWorkspace, settings, {
skillInstructions: skillContext.instructions,
skillPackages: skillContext.packages,
mcpServers,
continueHostCacheRoot: join(
app.getPath('userData'),
'continue-host'
),
bundledRuntimePaths,
continueHostLauncher: launchContinueHost,
browserService:
browserCapability?.enabled && browserCapability.supported
? browserService
: undefined,
knowledgeGateway,
webSearchEnabled: webSearchCapability?.enabled
})
}
const createConfiguredRuntime = async (): Promise<AgentRuntime> => {
const settings = await settingsStore.getResolvedSettings()
return createRuntimeWithCapabilities(
settings,
getConfiguredRuntimeTarget(settings)
)
}
const createSelectedRuntime = async (
selection: AgentRuntimeSelection,
workspacePath?: string
): Promise<AgentRuntime> => {
const resolved = applyRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
)
return createRuntimeWithCapabilities(
workspacePath
? { ...resolved.settings, workspacePath }
: resolved.settings,
resolved.target
)
}
runtime = new AgentRuntimeController(
await createConfiguredRuntime()
)
selectedRuntimeManager = new SelectedRuntimeManager(
createSelectedRuntime
)
const contextManager = new ContextManager({
parseDocument: documentParsingService.parse
})
const approvalBroker = new ToolApprovalBroker()
const shortcutRegistered = globalShortcut.register(shortcut, () => {
if (mainWindow) {
toggleWindow(mainWindow)
}
})
removeIpcHandlers = registerIpcHandlers(
mainWindow,
runtime,
shortcutRegistered ? shortcut : '未注册',
settingsStore,
capabilityService,
contextManager,
knowledgeService,
assistantDatabase,
approvalBroker,
bundledRuntimePaths,
async () => {
const settings = await settingsStore.getResolvedSettings()
if (knowledgeService) {
void knowledgeService
.setEmbeddingProvider(createEmbeddingProvider(settings))
.catch(() => undefined)
}
if (runtime) {
await runtime.replace(
await createConfiguredRuntime()
)
}
await selectedRuntimeManager?.reset()
await subagentService.replaceRuntimes(
createDefaultModelRuntime(defaultWorkspace, settings),
createSubagentProfileRuntimes(defaultWorkspace, settings)
)
},
async () => {
await browserService?.clearSessions()
},
browserService,
subagentService,
channelSettingsStore,
applicationSettingsStore,
versionChecker,
speechModelManager,
embeddingIndexCoordinator,
selectedRuntimeManager,
speechTranscriptionService,
knowledgeGateway,
launchWechatSidecar,
documentParsingService,
documentOcrModelManager,
documentOcrBroker
)
loadMainWindow(mainWindow)
app.on('activate', () => {
if (mainWindow) {
showWindow(mainWindow)
}
})
}).catch(() => {
dialog.showErrorBox(
'GoodBuddy 启动失败',
'本地数据或 Runtime 服务初始化失败。请重启应用;若问题持续,请备份后清理应用数据。'
)
app.quit()
})
}
let cleanupStarted = false
let cleanupComplete = false
app.on('before-quit', (event) => {
isQuitting = true
if (cleanupComplete) {
return
}
event.preventDefault()
if (cleanupStarted) {
return
}
cleanupStarted = true
void (async () => {
try {
const cleanup = Promise.allSettled([
Promise.resolve().then(() => removeIpcHandlers?.()),
Promise.resolve().then(() => runtime?.dispose()),
Promise.resolve().then(() => selectedRuntimeManager?.dispose()),
Promise.resolve().then(() => knowledgeGateway?.dispose()),
Promise.resolve().then(() => knowledgeService?.dispose()),
Promise.resolve().then(() => browserService?.dispose()),
Promise.resolve().then(() => globalTlsPolicy?.dispose()),
Promise.resolve().then(() => documentOcrModelManager?.dispose()),
Promise.resolve().then(() => documentOcrBroker?.dispose())
])
globalShortcut.unregisterAll()
tray?.destroy()
await waitForCleanup(cleanup, 8_000)
} finally {
try {
assistantDatabase?.close()
} finally {
cleanupComplete = true
app.exit(0)
}
}
})()
})
app.on('will-quit', () => {
cleanupComplete = true
})