609 lines
19 KiB
TypeScript
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
|
|
})
|