Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls. Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans. Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
1721 lines
60 KiB
TypeScript
1721 lines
60 KiB
TypeScript
import { localInferenceService } from './local-inference-service'
|
|
import { NativeClientCoordinator } from './agent/native-client-coordinator'
|
|
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
dialog,
|
|
globalShortcut,
|
|
Menu,
|
|
safeStorage,
|
|
session,
|
|
shell,
|
|
Tray,
|
|
utilityProcess
|
|
} from 'electron'
|
|
import { homedir } from 'node:os'
|
|
import { mkdir, readFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { dirname, join } from 'node:path'
|
|
import spawn from 'cross-spawn'
|
|
import { ipcChannels } from '../shared/ipc-channels'
|
|
import {
|
|
createAgentRuntime
|
|
} from './agent/create-runtime'
|
|
import { AgentRuntimeController } from './agent/runtime-controller'
|
|
import type { AgentRuntime } from './agent/runtime'
|
|
import { SelectedRuntimeManager } from './agent/selected-runtime-manager'
|
|
import { LocalRuntimeRegistry } from './agent/local-runtime-registry'
|
|
import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway'
|
|
import { ObsidianService } from './obsidian'
|
|
import {
|
|
applyRuntimeSelection,
|
|
getConfiguredRuntimeTarget,
|
|
resolveConfiguredAgentRuntimeSelection,
|
|
resolveLayeredRuntimeSelection,
|
|
type SelectedRuntimeTarget
|
|
} from './agent/runtime-selection'
|
|
import { CapabilityService } from './capabilities/capability-service'
|
|
import { ImageGenerationService } from './agent/image-generation-service'
|
|
import { ContextManager } from './context-manager'
|
|
import {
|
|
registerIpcHandlers,
|
|
sendRemoteProjectSaveProgress
|
|
} from './ipc'
|
|
import { KnowledgeService } from './knowledge/knowledge-service'
|
|
import { AssistantDatabase } from './assistant/assistant-database'
|
|
import { prepareAssistantStorage } from './assistant-storage-startup'
|
|
import { createModelGraphExtractor } from './knowledge/model-extractor'
|
|
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
|
|
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
|
|
import type { EmbeddingProvider } from './knowledge/types'
|
|
import { EmbeddingModelManager } from './knowledge/embedding-model-manager'
|
|
import { EmbeddingInferenceBroker } from './knowledge/embedding-inference-broker'
|
|
import { CohereRerankClient } from './knowledge/cohere-rerank-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 { 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 { SubagentScheduler } from './assistant/subagent-scheduler'
|
|
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 { GlobalTlsPolicy } from './global-tls-policy'
|
|
import { FeedbackIdentityStore } from './feedback/feedback-identity-store'
|
|
import {
|
|
createStrictFeedbackDispatcher,
|
|
StrictFeedbackHttpClient
|
|
} from './feedback/feedback-http-client'
|
|
import { FeedbackService } from './feedback/feedback-service'
|
|
import { registerFeedbackIpcHandler } from './feedback/feedback-ipc'
|
|
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
|
|
import type { SystemModelUsageInput } from '../shared/assistant-contracts'
|
|
import {
|
|
runCleanupBeforeDeadline,
|
|
settleCleanupPhases
|
|
} 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'
|
|
import { DocumentResultStorage } from './document-result-storage'
|
|
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
|
import { ReleaseNotesService } from './release-notes-service'
|
|
import { GoodBuddyConfigService } from './goodbuddy-config-service'
|
|
import {
|
|
createDeepSeekHarnessUtilityLauncher,
|
|
type DeepSeekHarnessFork
|
|
} from './agent/deepseek-harness-utility-launcher'
|
|
import {
|
|
buildControlledHarnessEnvironment,
|
|
buildCredentialFilteredUserEnvironment
|
|
} from './agent/process-environment'
|
|
import {
|
|
createStartupFailureDiagnostic,
|
|
formatStartupFailureMessage,
|
|
runStartupPrerequisites
|
|
} from './startup-prerequisites'
|
|
import { RuntimeExtensionStore } from './agent/runtime-extension-store'
|
|
import {
|
|
DshNpmExtensionInstaller,
|
|
DshNpmMarketplaceCatalog
|
|
} from './agent/dsh-extension-marketplace'
|
|
import { registerDesktopNotificationActivation } from './desktop-notification'
|
|
import {
|
|
isInstalledWindowsBuild,
|
|
repairStaleWindowsNotificationShortcuts,
|
|
resolveWindowsAppUserModelId
|
|
} from './windows-notification-identity'
|
|
import { ShortcutSettingsStore } from './shortcut-settings-store'
|
|
import { ShortcutSettingsService } from './shortcut-settings-service'
|
|
import { SshHostStore } from './ssh/ssh-host-store'
|
|
import { Ssh2Transport } from './ssh/ssh-transport'
|
|
import { SshHostService } from './ssh/ssh-host-service'
|
|
import { SshHostDirectoryBrowser } from './ssh/ssh-host-directory-browser'
|
|
import {
|
|
SshHostRemoteEnvironmentInspector
|
|
} from './ssh/ssh-host-remote-environment'
|
|
import { defaultGlobalShortcutSettings } from '../shared/shortcut'
|
|
import { requestProcessTreeTermination } from './agent/child-process-termination'
|
|
import { createContinueUtilityProcessChild } from './agent/continue-utility-process-adapter'
|
|
import {
|
|
ExecutionSpaceResolver,
|
|
type ExecutionSpaceDescriptor
|
|
} from './execution-space'
|
|
import { RemoteAgentServices } from './remote-agent/remote-agent-services'
|
|
import {
|
|
resolveBundledAgentResourcePaths,
|
|
resolveControlPlanePackageInstallerPath
|
|
} from './remote-agent/bundled-agent-resources'
|
|
import {
|
|
AgentPackageManager
|
|
} from './remote-agent/agent-package-manager'
|
|
import { ManagedRemoteExecutionServices } from './remote-agent/managed-remote-execution-services'
|
|
import {
|
|
RemoteEnvironmentUpdateService
|
|
} from './remote-agent/remote-environment-update-service'
|
|
import {
|
|
RemoteEnvironmentPreparer
|
|
} from './remote-agent/remote-environment-preparer'
|
|
import {
|
|
RemoteEnvironmentOperationStore
|
|
} from './remote-agent/remote-environment-operation-store'
|
|
import {
|
|
DesktopDiagnostics,
|
|
type DesktopDiagnosticFailureObserver
|
|
} from './desktop-diagnostics'
|
|
import { TerminalSessionManager } from './terminal/terminal-session-manager'
|
|
import {
|
|
LocalToolEnvironmentService,
|
|
resolveNpmCliPaths
|
|
} from './local-tool-environment'
|
|
|
|
const legacyDefaultShortcut =
|
|
defaultGlobalShortcutSettings.accelerator
|
|
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)
|
|
}
|
|
const desktopDiagnostics = new DesktopDiagnostics(
|
|
join(app.getPath('userData'), 'diagnostics')
|
|
)
|
|
const observeDesktopFailure: DesktopDiagnosticFailureObserver = (
|
|
failure
|
|
) => {
|
|
void desktopDiagnostics.recordFailure(failure).catch(() => undefined)
|
|
}
|
|
const installedWindowsBuild = isInstalledWindowsBuild({
|
|
packaged: app.isPackaged,
|
|
platform: process.platform,
|
|
executablePath: process.execPath
|
|
})
|
|
if (process.platform === 'win32') {
|
|
app.setAppUserModelId(
|
|
resolveWindowsAppUserModelId({
|
|
installed: installedWindowsBuild,
|
|
executablePath: process.execPath
|
|
})
|
|
)
|
|
}
|
|
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 removeFeedbackIpcHandler: (() => void) | undefined
|
|
let runtime: AgentRuntimeController | undefined
|
|
let selectedRuntimeManager: SelectedRuntimeManager | undefined
|
|
const localRuntimeRegistry = new LocalRuntimeRegistry()
|
|
let knowledgeService: KnowledgeService | undefined
|
|
let knowledgeGateway: KnowledgeMcpGateway | undefined
|
|
let assistantDatabase: AssistantDatabase | undefined
|
|
let imageGenerationService: ImageGenerationService | undefined
|
|
let browserService: BrowserService | undefined
|
|
let globalTlsPolicy: GlobalTlsPolicy | undefined
|
|
let feedbackService: FeedbackService | undefined
|
|
let documentOcrBroker: DocumentOcrBroker | undefined
|
|
let documentParsingService: DocumentParsingService | undefined
|
|
let conversationAttachmentStorage: ConversationAttachmentStorage | undefined
|
|
let documentOcrModelManager: DocumentOcrModelManager | undefined
|
|
let embeddingModelManager: EmbeddingModelManager | undefined
|
|
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
|
|
const sharedEmbeddingBrokers = new Map<string, { broker: EmbeddingInferenceBroker; users: number; unregister: () => void }>()
|
|
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
|
|
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
|
|
let remoteAgentServices: RemoteAgentServices | undefined
|
|
let terminalSessionManager: TerminalSessionManager | undefined
|
|
let nativeClientCoordinator: NativeClientCoordinator | undefined
|
|
let managedRemoteExecutionServices:
|
|
| ManagedRemoteExecutionServices
|
|
| undefined
|
|
let directModelSubagentScheduler: SubagentScheduler | undefined
|
|
let localToolEnvironmentService: LocalToolEnvironmentService | undefined
|
|
|
|
function recordKnowledgeUsage(
|
|
bucket: SystemModelUsageInput['bucket'],
|
|
usage: Omit<SystemModelUsageInput, 'source' | 'bucket'>
|
|
): void {
|
|
try {
|
|
assistantDatabase?.recordSystemModelUsage({
|
|
...usage,
|
|
source: 'knowledge',
|
|
bucket
|
|
})
|
|
} catch (error) {
|
|
void desktopDiagnostics
|
|
.recordFailure({
|
|
component: 'desktop',
|
|
stage: 'knowledge',
|
|
code: 'knowledge.usage.persist-failed',
|
|
error
|
|
})
|
|
.catch(() => undefined)
|
|
}
|
|
}
|
|
|
|
type ManagedEmbeddingProvider = EmbeddingProvider & {
|
|
dispose?: () => void | Promise<void>
|
|
}
|
|
|
|
async function createEmbeddingProvider(
|
|
settings: ResolvedRuntimeSettings,
|
|
manager: EmbeddingModelManager,
|
|
connectionId = settings.activeEmbeddingConnectionId
|
|
): Promise<ManagedEmbeddingProvider | undefined> {
|
|
if (!settings.knowledgeEmbeddingEnabled) {
|
|
return undefined
|
|
}
|
|
const connection = settings.embeddingConnections?.find(
|
|
(candidate) => candidate.id === connectionId
|
|
)
|
|
if (!connection) {
|
|
throw new Error('当前向量连接不存在')
|
|
}
|
|
if (connection.kind !== 'builtin') {
|
|
return new OpenAIEmbeddingClient({
|
|
endpoint: connection.baseUrl,
|
|
model: connection.modelName,
|
|
apiKey: connection.apiKey,
|
|
onUsage: ({ model, inputTokens }) =>
|
|
recordKnowledgeUsage('embedding', {
|
|
runtime: 'embedding',
|
|
provider: 'openai-compatible',
|
|
model,
|
|
input: inputTokens,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0
|
|
})
|
|
})
|
|
}
|
|
const snapshot = await manager.getSnapshot()
|
|
const model =
|
|
snapshot.catalog.find((candidate) => candidate.recommended) ??
|
|
snapshot.catalog[0]
|
|
if (!model) {
|
|
throw new Error('内置向量模型目录为空')
|
|
}
|
|
const modelDirectory =
|
|
await manager.getVerifiedModelDirectory(model.id)
|
|
const installed = snapshot.installed.find(
|
|
(candidate) => candidate.id === model.id
|
|
)
|
|
if (!installed) {
|
|
throw new Error('内置向量模型尚未安装')
|
|
}
|
|
const tokenizerDigest = installed.files.find(
|
|
(file) => file.role === 'tokenizer'
|
|
)?.sha256
|
|
const modelDigest = installed.files.find(
|
|
(file) => file.role === 'model'
|
|
)?.sha256
|
|
if (!tokenizerDigest || !modelDigest) {
|
|
throw new Error('内置向量模型安装清单不完整')
|
|
}
|
|
let shared = sharedEmbeddingBrokers.get(modelDirectory)
|
|
if (!shared) {
|
|
const broker = new EmbeddingInferenceBroker({
|
|
createTransport: () =>
|
|
createEmbeddingUtilityTransport(
|
|
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
|
|
modelDirectory
|
|
)
|
|
})
|
|
embeddingBrokers.add(broker)
|
|
const unregister = localInferenceService.register('embedding', {
|
|
snapshot: () => ({
|
|
id: 'embedding', name: '向量生成', engine: 'Granite / ONNX',
|
|
ownership: 'managed-process', state: broker.getManagementState(), model: model.id,
|
|
error: broker.getManagementError(),
|
|
resources: broker.getResources(),
|
|
detail: 'GoodBuddy 托管进程,由知识检索与索引共享。进程启动后模型在首个请求时加载;资源指标为整个向量服务进程占用。停止会中断使用方任务,启动不重放任务。',
|
|
actions: broker.getManagementState() === 'stopped' || broker.getManagementState() === 'idle' || broker.getManagementState() === 'error'
|
|
? ['start'] : broker.getManagementState() === 'running' ? ['stop', 'restart'] : []
|
|
}),
|
|
act: async (action) => {
|
|
if (action !== 'start') await broker.stop()
|
|
if (action !== 'stop') await broker.start()
|
|
}
|
|
})
|
|
shared = { broker, users: 0, unregister }
|
|
sharedEmbeddingBrokers.set(modelDirectory, shared)
|
|
}
|
|
shared.users += 1
|
|
const sharedBroker = shared
|
|
const broker = shared.broker
|
|
let disposed = false
|
|
return {
|
|
provider: 'builtin',
|
|
model: model.id,
|
|
fingerprint: embeddingProviderFingerprint({
|
|
provider: 'builtin',
|
|
dataPath: { kind: 'device' },
|
|
model: model.id,
|
|
dimensions: model.dimensions,
|
|
encodingRecipe: {
|
|
recipeId: 'granite-embedding-97m-r2',
|
|
artifactDigest: modelDigest,
|
|
tokenizerDigest,
|
|
pooling: 'cls',
|
|
normalization: 'l2',
|
|
queryTemplate: '{text}',
|
|
documentTemplate: '{text}',
|
|
maximumSequenceTokens: model.contextTokens
|
|
}
|
|
}),
|
|
embed: (texts, signal) =>
|
|
broker.embed(texts, 'document', signal),
|
|
embedQuery: (texts, signal) =>
|
|
broker.embed(texts, 'query', signal),
|
|
embedDocuments: (texts, signal) =>
|
|
broker.embed(texts, 'document', signal),
|
|
dispose: async () => {
|
|
if (disposed) return
|
|
disposed = true
|
|
sharedBroker.users -= 1
|
|
if (sharedBroker.users > 0) return
|
|
sharedEmbeddingBrokers.delete(modelDirectory)
|
|
sharedBroker.unregister()
|
|
try {
|
|
await broker.shutdown()
|
|
} finally {
|
|
embeddingBrokers.delete(broker)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createRerankProvider(
|
|
settings: ResolvedRuntimeSettings
|
|
): CohereRerankClient | undefined {
|
|
return settings.knowledgeRerankEnabled
|
|
? new CohereRerankClient({
|
|
endpoint: settings.knowledgeRerankEndpoint,
|
|
model: settings.knowledgeRerankModel,
|
|
apiKey: settings.knowledgeRerankApiKey
|
|
})
|
|
: undefined
|
|
}
|
|
|
|
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'
|
|
}
|
|
)
|
|
return createContinueUtilityProcessChild({
|
|
get pid() {
|
|
return utilityChild.pid
|
|
},
|
|
stdout: utilityChild.stdout,
|
|
stderr: utilityChild.stderr,
|
|
kill: () => utilityChild.kill(),
|
|
onExit: (listener) => {
|
|
utilityChild.on('exit', listener)
|
|
},
|
|
onceExit: (listener) => {
|
|
utilityChild.once('exit', listener)
|
|
},
|
|
onceError: (listener) => {
|
|
utilityChild.once('error', listener)
|
|
},
|
|
removeExitListener: (listener) => {
|
|
utilityChild.removeListener('exit', listener)
|
|
},
|
|
removeErrorListener: (listener) => {
|
|
utilityChild.removeListener('error', listener)
|
|
}
|
|
})
|
|
}
|
|
|
|
const forkDeepSeekHarness: DeepSeekHarnessFork = (
|
|
modulePath,
|
|
args,
|
|
options
|
|
) =>
|
|
utilityProcess.fork(modulePath, args, {
|
|
...options,
|
|
allowLoadingUnsignedLibraries: false,
|
|
disclaim: false
|
|
})
|
|
|
|
function terminateHarnessUtilityProcess(
|
|
child: ReturnType<DeepSeekHarnessFork>
|
|
): void {
|
|
requestProcessTreeTermination(child, { spawn })
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const storageUpgradeController = new AbortController()
|
|
let storageUpgrade: Promise<void> | undefined
|
|
|
|
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, observeDesktopFailure)
|
|
registerDesktopNotificationActivation(mainWindow)
|
|
tray = buildTray()
|
|
storageUpgrade = prepareAssistantStorage(
|
|
mainWindow,
|
|
join(app.getPath('userData'), 'assistant.sqlite'),
|
|
storageUpgradeController.signal
|
|
)
|
|
await storageUpgrade
|
|
if (storageUpgradeController.signal.aborted) return
|
|
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 applicationSettingsStore = new ApplicationSettingsStore(
|
|
join(app.getPath('userData'), 'application-settings.json')
|
|
)
|
|
const toolEnvironmentRoot = join(
|
|
app.getPath('userData'),
|
|
'tool-environment'
|
|
)
|
|
const npmCliPaths = resolveNpmCliPaths({
|
|
appPath: app.getAppPath(),
|
|
resourcesPath: process.resourcesPath,
|
|
packaged: app.isPackaged
|
|
})
|
|
const startupLocalToolEnvironmentService =
|
|
new LocalToolEnvironmentService({
|
|
settingsStore: applicationSettingsStore,
|
|
binDirectory: join(toolEnvironmentRoot, 'bin'),
|
|
managedPythonRoot: join(
|
|
toolEnvironmentRoot,
|
|
'managed-python'
|
|
),
|
|
pythonArtifactCatalogPath: app.isPackaged
|
|
? join(
|
|
process.resourcesPath,
|
|
'tool-environment',
|
|
'managed-python-artifacts.json'
|
|
)
|
|
: join(
|
|
app.getAppPath(),
|
|
'resources',
|
|
'tool-environment',
|
|
'managed-python-artifacts.json'
|
|
),
|
|
packagedNpmCliPath: npmCliPaths.npmCliPath,
|
|
packagedNpxCliPath: npmCliPaths.npxCliPath,
|
|
electronExecutablePath: process.execPath,
|
|
selectExecutable: async (kind) => {
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
title:
|
|
kind === 'python'
|
|
? '选择 Python 可执行文件'
|
|
: '选择 Node.js 可执行文件',
|
|
properties: ['openFile'],
|
|
filters:
|
|
process.platform === 'win32'
|
|
? [
|
|
{
|
|
name:
|
|
kind === 'python'
|
|
? 'Python 可执行文件'
|
|
: 'Node.js 可执行文件',
|
|
extensions: ['exe', 'cmd', 'bat']
|
|
},
|
|
{ name: '所有文件', extensions: ['*'] }
|
|
]
|
|
: [{ name: '所有文件', extensions: ['*'] }]
|
|
})
|
|
return result.canceled ? undefined : result.filePaths[0]
|
|
},
|
|
baseEnvironment: buildCredentialFilteredUserEnvironment()
|
|
})
|
|
await startupLocalToolEnvironmentService.initialize()
|
|
localToolEnvironmentService =
|
|
startupLocalToolEnvironmentService
|
|
const sshHostStore = new SshHostStore(
|
|
join(app.getPath('userData'), 'ssh-hosts.json'),
|
|
secureCipher
|
|
)
|
|
const agentMetadataPaths = resolveBundledAgentResourcePaths({
|
|
appPath: app.getAppPath(),
|
|
resourcesPath: process.resourcesPath,
|
|
packaged: app.isPackaged
|
|
})
|
|
const controlPlanePackageInstaller = await readFile(
|
|
resolveControlPlanePackageInstallerPath({
|
|
appPath: app.getAppPath()
|
|
})
|
|
)
|
|
const agentPackageManager = new AgentPackageManager({
|
|
userDataPath: app.getPath('userData'),
|
|
desktopVersion: app.getVersion(),
|
|
keyRegistryPath: agentMetadataPaths.keyRegistryPath,
|
|
getUpdateSource: async () =>
|
|
(await applicationSettingsStore.get()).updateSource
|
|
})
|
|
const startupRemoteAgentServices = new RemoteAgentServices({
|
|
sshHostStore,
|
|
goodBuddyVersion: app.getVersion(),
|
|
userDataPath: app.getPath('userData'),
|
|
appPath: app.getAppPath(),
|
|
resourcesPath: process.resourcesPath,
|
|
packaged: app.isPackaged,
|
|
controlPlanePackageInstaller,
|
|
observeFailure: observeDesktopFailure
|
|
})
|
|
remoteAgentServices = startupRemoteAgentServices
|
|
const startupManagedRemoteExecutionServices =
|
|
new ManagedRemoteExecutionServices({
|
|
sshHostStore,
|
|
agentServices: startupRemoteAgentServices,
|
|
userDataPath: app.getPath('userData'),
|
|
appPath: app.getAppPath(),
|
|
resourcesPath: process.resourcesPath,
|
|
packaged: app.isPackaged,
|
|
resolveRuntimeSelection: async (layer) => {
|
|
const settings = await settingsStore.getResolvedSettings()
|
|
return resolveConfiguredAgentRuntimeSelection(
|
|
settings,
|
|
resolveLayeredRuntimeSelection(settings, { project: layer }, { remote: true }).selection
|
|
)
|
|
},
|
|
resolveModelProfile: async (selection) => {
|
|
const resolved = applyRuntimeSelection(
|
|
await settingsStore.getResolvedSettings(),
|
|
selection
|
|
).settings
|
|
return selection.provider === 'continue' ? resolved.continueModelProfile : resolved.opencodeModelProfile
|
|
}
|
|
})
|
|
await startupManagedRemoteExecutionServices.initialize()
|
|
managedRemoteExecutionServices =
|
|
startupManagedRemoteExecutionServices
|
|
const beginRemoteHostInvalidation = (hostId: string): void => {
|
|
const invalidations = [
|
|
startupRemoteAgentServices.invalidateHost(hostId)
|
|
]
|
|
startupManagedRemoteExecutionServices.invalidateHost(hostId)
|
|
if (selectedRuntimeManager) {
|
|
invalidations.push(
|
|
selectedRuntimeManager.invalidateHost(hostId)
|
|
)
|
|
}
|
|
void Promise.allSettled(invalidations).then((results) => {
|
|
if (results.some((result) => result.status === 'rejected')) {
|
|
console.warn(
|
|
'Failed to invalidate complete remote Host state'
|
|
)
|
|
}
|
|
})
|
|
}
|
|
const sshHostService = new SshHostService(
|
|
sshHostStore,
|
|
new Ssh2Transport(),
|
|
{
|
|
onHostEdited: beginRemoteHostInvalidation,
|
|
onHostRemoved: beginRemoteHostInvalidation
|
|
}
|
|
)
|
|
const sshHostDirectoryBrowser = new SshHostDirectoryBrowser({
|
|
sshHosts: sshHostStore,
|
|
sshPool: startupRemoteAgentServices.sshPool
|
|
})
|
|
const sshHostRemoteEnvironmentInspector =
|
|
new SshHostRemoteEnvironmentInspector({
|
|
sshHosts: sshHostStore,
|
|
sshPool: startupRemoteAgentServices.sshPool,
|
|
agentRuntimeLockPath:
|
|
startupRemoteAgentServices.resourcePaths.runtimeLockPath,
|
|
remoteRuntimeLockPath:
|
|
startupManagedRemoteExecutionServices.runtimeResourcePaths
|
|
.runtimeLockPath,
|
|
loadRemoteEnvironmentCatalog: (architecture, options) =>
|
|
agentPackageManager.getRemoteEnvironmentCatalog(
|
|
architecture,
|
|
options
|
|
)
|
|
})
|
|
const [initialRuntimeSettings, initialResolvedSettings] =
|
|
await Promise.all([
|
|
settingsStore.getPublicSettings(),
|
|
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,
|
|
{ getSavedModelProfiles: () => settingsStore.getSavedModelProfiles() }
|
|
)
|
|
const channelSettingsStore = new ChannelSettingsStore(
|
|
join(app.getPath('userData'), 'channel-settings.json'),
|
|
secureCipher
|
|
)
|
|
const startupFeedbackService = new FeedbackService({
|
|
appVersion: app.getVersion(),
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
identityStore: new FeedbackIdentityStore(
|
|
join(app.getPath('userData'), 'feedback-identity.json')
|
|
),
|
|
client: new StrictFeedbackHttpClient({
|
|
appVersion: app.getVersion(),
|
|
dispatcher: createStrictFeedbackDispatcher()
|
|
}),
|
|
diagnosticsProvider: desktopDiagnostics
|
|
})
|
|
feedbackService = startupFeedbackService
|
|
const startupEmbeddingModelManager = new EmbeddingModelManager({
|
|
userDataDirectory: app.getPath('userData'),
|
|
fetch: globalThis.fetch,
|
|
getDownloadSource: async () =>
|
|
(await applicationSettingsStore.get()).modelDownloadSource
|
|
})
|
|
embeddingModelManager = startupEmbeddingModelManager
|
|
const releaseNotesService = new ReleaseNotesService({
|
|
currentVersion: app.getVersion(),
|
|
filePath: app.isPackaged
|
|
? join(process.resourcesPath, 'release-notes.json')
|
|
: join(app.getAppPath(), 'resources', 'release-notes.json'),
|
|
settingsStore: applicationSettingsStore
|
|
})
|
|
const documentParsingSettingsStore =
|
|
new DocumentParsingSettingsStore(
|
|
join(app.getPath('userData'), 'document-parsing-settings.json'),
|
|
secureCipher
|
|
)
|
|
documentOcrModelManager = new DocumentOcrModelManager({
|
|
userDataDirectory: app.getPath('userData'),
|
|
fetch: globalThis.fetch,
|
|
getDownloadSource: async () =>
|
|
(await applicationSettingsStore.get()).modelDownloadSource
|
|
})
|
|
documentOcrBroker = new DocumentOcrBroker(mainWindow)
|
|
documentParsingService = new DocumentParsingService(
|
|
documentParsingSettingsStore,
|
|
documentOcrModelManager,
|
|
documentOcrBroker,
|
|
new DocumentResultStorage(join(app.getPath('userData'), 'temp', 'document-parsing'))
|
|
)
|
|
localInferenceService.register('ocr', {
|
|
snapshot: async () => {
|
|
const snapshot = await documentParsingService!.snapshot()
|
|
const model = snapshot.status.localOcr
|
|
return {
|
|
id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker',
|
|
resources: { scope: 'unavailable', reason: 'Web Worker 与界面共享渲染进程,无法独立统计 CPU / 内存' },
|
|
state: model.available ? 'unknown' : 'unavailable', model: model.id,
|
|
detail: `${model.detail}。模型由 Renderer Worker 持有,文档解析按需加载,空闲 60 秒自动释放;仅允许无活动任务时手动释放。`,
|
|
actions: []
|
|
}
|
|
}
|
|
})
|
|
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,
|
|
getDownloadSource: async () =>
|
|
(await applicationSettingsStore.get()).modelDownloadSource
|
|
})
|
|
const speechTranscriptionService = new SpeechTranscriptionService(
|
|
speechModelManager
|
|
)
|
|
localInferenceService.register('asr', {
|
|
snapshot: async () => {
|
|
const model = await speechModelManager.getSelectedRuntimeModel()
|
|
return {
|
|
id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker',
|
|
resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' },
|
|
state: model ? 'unknown' : 'unavailable', model: model?.id,
|
|
detail: model ? '每个识别请求独立加载模型,完成后释放;支持取消单次识别,没有常驻服务可启停。' : '请在设置中安装并选择本地语音模型。',
|
|
actions: []
|
|
}
|
|
}
|
|
})
|
|
browserService = new BrowserService({ parentWindow: mainWindow })
|
|
const bundledRuntimePaths = resolveBundledRuntimePaths({
|
|
appPath: app.getAppPath(),
|
|
resourcesPath: process.resourcesPath,
|
|
packaged: app.isPackaged
|
|
})
|
|
const deepSeekHarnessHome = join(
|
|
app.getPath('userData'),
|
|
'deepseek-harness'
|
|
)
|
|
const startupDshExtensionInstaller = new DshNpmExtensionInstaller({
|
|
dshHome: deepSeekHarnessHome,
|
|
npmCliPath: app.isPackaged
|
|
? join(
|
|
process.resourcesPath,
|
|
'runtimes',
|
|
'npm',
|
|
'bin',
|
|
'npm-cli.js'
|
|
)
|
|
: join(
|
|
app.getAppPath(),
|
|
'node_modules',
|
|
'npm',
|
|
'bin',
|
|
'npm-cli.js'
|
|
)
|
|
})
|
|
dshExtensionInstaller = startupDshExtensionInstaller
|
|
const runtimeExtensionStore = new RuntimeExtensionStore(
|
|
app.getPath('userData'),
|
|
{
|
|
catalog: new DshNpmMarketplaceCatalog(),
|
|
install: (input) =>
|
|
startupDshExtensionInstaller.install(input)
|
|
}
|
|
)
|
|
const launchDeepSeekHarness =
|
|
createDeepSeekHarnessUtilityLauncher({
|
|
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
|
dshHome: deepSeekHarnessHome,
|
|
environment: buildControlledHarnessEnvironment(
|
|
deepSeekHarnessHome
|
|
),
|
|
launchEnvironmentProvider:
|
|
startupLocalToolEnvironmentService.launchEnvironmentProvider,
|
|
fork: forkDeepSeekHarness,
|
|
terminateProcess: terminateHarnessUtilityProcess,
|
|
onExtensionStartupFailures: (extensionIds) =>
|
|
runtimeExtensionStore.markStartupFailed(extensionIds)
|
|
})
|
|
const startupKnowledgeService = new KnowledgeService({
|
|
documentResults: documentParsingService.results,
|
|
credentialCipher: secureCipher,
|
|
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
|
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
|
extractStructured: createModelGraphExtractor(
|
|
settingsStore,
|
|
fetch,
|
|
undefined,
|
|
(usage) =>
|
|
recordKnowledgeUsage('graph-extraction', {
|
|
runtime: 'model',
|
|
...usage
|
|
})
|
|
),
|
|
parseDocument: documentParsingService.parse
|
|
})
|
|
knowledgeService = startupKnowledgeService
|
|
let activeEmbeddingProvider:
|
|
| Awaited<ReturnType<typeof createEmbeddingProvider>>
|
|
| undefined
|
|
let activeRerankProvider:
|
|
| ReturnType<typeof createRerankProvider>
|
|
| undefined
|
|
const startupAssistantDatabase = new AssistantDatabase(
|
|
join(app.getPath('userData'), 'assistant.sqlite'),
|
|
{
|
|
onMagicNotesChanged: () => {
|
|
queueMicrotask(() => {
|
|
if (
|
|
mainWindow &&
|
|
!mainWindow.isDestroyed() &&
|
|
!mainWindow.webContents.isDestroyed()
|
|
) {
|
|
mainWindow.webContents.send(ipcChannels.magicNotesChanged)
|
|
}
|
|
})
|
|
},
|
|
onMagicTodosChanged: () => {
|
|
queueMicrotask(() => {
|
|
if (
|
|
mainWindow &&
|
|
!mainWindow.isDestroyed() &&
|
|
!mainWindow.webContents.isDestroyed()
|
|
) {
|
|
mainWindow.webContents.send(
|
|
ipcChannels.magicTodosStatusChanged
|
|
)
|
|
}
|
|
})
|
|
},
|
|
onModelUsageChanged: () => {
|
|
if (
|
|
mainWindow &&
|
|
!mainWindow.isDestroyed() &&
|
|
!mainWindow.webContents.isDestroyed()
|
|
) {
|
|
mainWindow.webContents.send(ipcChannels.tokenUsageChanged)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
assistantDatabase = startupAssistantDatabase
|
|
imageGenerationService = new ImageGenerationService({
|
|
database: startupAssistantDatabase,
|
|
getSettings: () => settingsStore.getResolvedSettings(),
|
|
onError: (error) => {
|
|
void desktopDiagnostics.recordFailure({ component: 'desktop', stage: 'image-generation', code: 'image-generation.observer.failed', error }).catch(() => undefined)
|
|
},
|
|
onOperation: (operation) => {
|
|
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
|
mainWindow.webContents.send(ipcChannels.imageOperationChanged, operation)
|
|
}
|
|
},
|
|
onUsage: (event) => startupAssistantDatabase.upsertModelUsageCall({
|
|
requestId: event.requestId, callId: event.callId, runtime: event.runtime,
|
|
provider: event.provider, model: event.model, input: event.inputTokens,
|
|
output: event.outputTokens, cacheRead: event.cacheReadTokens, cacheWrite: event.cacheWriteTokens
|
|
})
|
|
})
|
|
directModelSubagentScheduler = new SubagentScheduler({
|
|
concurrency: 3,
|
|
queueLimit: 20,
|
|
timeoutMs: 10 * 60_000
|
|
})
|
|
const executionSpaceResolver = new ExecutionSpaceResolver(
|
|
startupManagedRemoteExecutionServices.workspaceAccessFactory
|
|
)
|
|
const goodbuddyConfigService = new GoodBuddyConfigService(
|
|
applicationSettingsStore,
|
|
capabilityService
|
|
)
|
|
const obsidianService = new ObsidianService({
|
|
appPath: app.getAppPath(),
|
|
launchEnvironmentProvider:
|
|
startupLocalToolEnvironmentService.launchEnvironmentProvider
|
|
})
|
|
const startupKnowledgeGateway = new KnowledgeMcpGateway(
|
|
startupKnowledgeService,
|
|
{
|
|
observeFailure: observeDesktopFailure,
|
|
storyGraphService: {
|
|
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
|
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
|
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal)
|
|
},
|
|
magicNotesDatabase: startupAssistantDatabase,
|
|
configService: goodbuddyConfigService,
|
|
obsidianService,
|
|
browserService,
|
|
launchEnvironmentProvider:
|
|
startupLocalToolEnvironmentService.launchEnvironmentProvider
|
|
}
|
|
)
|
|
knowledgeGateway = startupKnowledgeGateway
|
|
const createRuntimeWithCapabilities = async (
|
|
settings: ResolvedRuntimeSettings,
|
|
target: SelectedRuntimeTarget,
|
|
executionSpace?: ExecutionSpaceDescriptor
|
|
): Promise<AgentRuntime> => {
|
|
const [
|
|
skillContext,
|
|
mcpServers,
|
|
browserCapability,
|
|
webSearchCapability,
|
|
deepseekHarnessExtensions
|
|
] =
|
|
await Promise.all([
|
|
capabilityService.getRuntimeSkillContext(target),
|
|
capabilityService.getResolvedMcpServers(target),
|
|
target === 'model'
|
|
? capabilityService
|
|
.getEnabledBuiltinMcpServerIds(target)
|
|
.then((enabledServers) =>
|
|
enabledServers.includes('builtin-browser')
|
|
? capabilityService.getComputerCapabilityStatus(
|
|
'host-browser-control'
|
|
)
|
|
: undefined
|
|
)
|
|
: Promise.resolve(undefined),
|
|
target === 'model' || target === 'deepseek-harness'
|
|
? capabilityService.getWebSearchCapabilityStatus()
|
|
: Promise.resolve(undefined),
|
|
target === 'deepseek-harness'
|
|
? runtimeExtensionStore.getEnabledExtensions()
|
|
: Promise.resolve([])
|
|
])
|
|
return createAgentRuntime(defaultWorkspace, settings, {
|
|
observeFailure: observeDesktopFailure,
|
|
localRuntimeRegistry,
|
|
skillInstructions: skillContext.instructions,
|
|
skillPackages: skillContext.packages,
|
|
mcpServers,
|
|
continueHostCacheRoot: join(
|
|
app.getPath('userData'),
|
|
'continue-host'
|
|
),
|
|
opencodeSharedCacheRoot: join(
|
|
app.getPath('userData'),
|
|
'opencode-runtime',
|
|
app.getVersion()
|
|
),
|
|
bundledRuntimePaths,
|
|
continueHostLauncher: launchContinueHost,
|
|
deepseekHarnessLauncher: launchDeepSeekHarness,
|
|
deepseekHarnessExtensions,
|
|
browserService:
|
|
browserCapability?.enabled && browserCapability.supported
|
|
? browserService
|
|
: undefined,
|
|
knowledgeGateway: startupKnowledgeGateway,
|
|
webSearchEnabled: webSearchCapability?.enabled,
|
|
executionSpace,
|
|
workspaceAccess: executionSpace?.workspaceAccess,
|
|
directModelSubagentScheduler,
|
|
launchEnvironmentProvider:
|
|
startupLocalToolEnvironmentService.launchEnvironmentProvider
|
|
})
|
|
}
|
|
const createConfiguredRuntime = async (
|
|
resolvedSettings?: ResolvedRuntimeSettings
|
|
): Promise<AgentRuntime> => {
|
|
const settings =
|
|
resolvedSettings ?? await settingsStore.getResolvedSettings()
|
|
return createRuntimeWithCapabilities(
|
|
settings,
|
|
getConfiguredRuntimeTarget(settings)
|
|
)
|
|
}
|
|
const createSubagentRuntime = (
|
|
settings: ResolvedRuntimeSettings,
|
|
profileId = settings.defaultModelProfileId
|
|
): Promise<AgentRuntime> =>
|
|
createRuntimeWithCapabilities(
|
|
{
|
|
...settings,
|
|
provider: 'model',
|
|
defaultModelProfileId: profileId
|
|
},
|
|
'model'
|
|
)
|
|
const createSubagentProfileRuntimes = async (
|
|
settings: ResolvedRuntimeSettings
|
|
): Promise<ReadonlyMap<string, AgentRuntime>> =>
|
|
new Map(
|
|
await Promise.all(
|
|
settings.modelProfiles
|
|
.filter(
|
|
(profile) =>
|
|
profile.id !== settings.defaultModelProfileId &&
|
|
profile.protocol !== 'openai-images-generations'
|
|
)
|
|
.map(async (profile) =>
|
|
[
|
|
profile.id,
|
|
await createSubagentRuntime(settings, profile.id)
|
|
] as const
|
|
)
|
|
)
|
|
)
|
|
const createSelectedRuntime = async (
|
|
selection: AgentRuntimeSelection,
|
|
executionSpace?: ExecutionSpaceDescriptor
|
|
): Promise<AgentRuntime> => {
|
|
const resolved = applyRuntimeSelection(
|
|
await settingsStore.getResolvedSettings(),
|
|
selection
|
|
)
|
|
if (executionSpace?.kind === 'ssh') {
|
|
const profile = selection.provider === 'continue' ? resolved.settings.continueModelProfile : resolved.settings.opencodeModelProfile
|
|
if (profile === undefined) {
|
|
throw new Error(
|
|
'托管远程 Runtime 需要选择一个可用的文本模型配置'
|
|
)
|
|
}
|
|
return await startupManagedRemoteExecutionServices.createRuntime({
|
|
executionSpace,
|
|
selection,
|
|
modelProfile: profile,
|
|
usage: {
|
|
provider: profile.protocol,
|
|
model: profile.modelName
|
|
}
|
|
})
|
|
}
|
|
return createRuntimeWithCapabilities(
|
|
executionSpace?.kind === 'local'
|
|
? {
|
|
...resolved.settings,
|
|
workspacePath: executionSpace.rootPath
|
|
}
|
|
: resolved.settings,
|
|
resolved.target,
|
|
executionSpace
|
|
)
|
|
}
|
|
const createSelectedStatusRuntime = async (
|
|
selection: AgentRuntimeSelection
|
|
): Promise<AgentRuntime> => {
|
|
const resolved = applyRuntimeSelection(
|
|
await settingsStore.getResolvedSettings(),
|
|
selection
|
|
)
|
|
return resolved.target === 'opencode'
|
|
? createAgentRuntime(defaultWorkspace, resolved.settings, {
|
|
bundledRuntimePaths
|
|
})
|
|
: createRuntimeWithCapabilities(
|
|
resolved.settings,
|
|
resolved.target
|
|
)
|
|
}
|
|
const configuredRuntime = await runStartupPrerequisites({
|
|
prepareDeepSeekHome: async () => {
|
|
await mkdir(deepSeekHarnessHome, {
|
|
recursive: true,
|
|
mode: 0o700
|
|
})
|
|
},
|
|
initializeKnowledgeAndGateway: async () => {
|
|
await startupKnowledgeService.initialize()
|
|
const embeddingProvider = await createEmbeddingProvider(
|
|
initialResolvedSettings,
|
|
startupEmbeddingModelManager
|
|
)
|
|
const rerankProvider = createRerankProvider(
|
|
initialResolvedSettings
|
|
)
|
|
await Promise.all([
|
|
startupKnowledgeService.setEmbeddingProvider(
|
|
embeddingProvider
|
|
).then(
|
|
() => {
|
|
activeEmbeddingProvider = embeddingProvider
|
|
},
|
|
() => undefined
|
|
),
|
|
startupKnowledgeService.setRerankProvider(
|
|
rerankProvider
|
|
).then(
|
|
() => {
|
|
activeRerankProvider = rerankProvider
|
|
},
|
|
() => undefined
|
|
)
|
|
])
|
|
await startupKnowledgeGateway.start()
|
|
},
|
|
hydrateConfiguredRuntime: () =>
|
|
createConfiguredRuntime(initialResolvedSettings),
|
|
initializeAssistant: () => {
|
|
startupAssistantDatabase.initialize(defaultWorkspace)
|
|
imageGenerationService!.initialize()
|
|
startupAssistantDatabase.ensureChannelProjects(
|
|
defaultWorkspace,
|
|
initialRuntimeSettings.defaultModelProfileId
|
|
)
|
|
channelSettingsStore.reportRuntimeSelectionRepairs(
|
|
startupAssistantDatabase.repairConversationRuntimeSelections(
|
|
initialRuntimeSettings
|
|
)
|
|
)
|
|
}
|
|
})
|
|
const [initialSubagentRuntime, initialSubagentProfileRuntimes] =
|
|
await Promise.all([
|
|
createSubagentRuntime(initialResolvedSettings),
|
|
createSubagentProfileRuntimes(initialResolvedSettings)
|
|
])
|
|
const subagentService = new SubagentService(
|
|
initialSubagentRuntime,
|
|
startupAssistantDatabase,
|
|
undefined,
|
|
initialSubagentProfileRuntimes
|
|
)
|
|
runtime = new AgentRuntimeController(
|
|
configuredRuntime,
|
|
undefined,
|
|
observeDesktopFailure
|
|
)
|
|
selectedRuntimeManager = new SelectedRuntimeManager(
|
|
createSelectedRuntime,
|
|
undefined,
|
|
undefined,
|
|
observeDesktopFailure,
|
|
createSelectedStatusRuntime,
|
|
(conversationId) => localRuntimeRegistry.releaseConversation(conversationId)
|
|
)
|
|
conversationAttachmentStorage = new ConversationAttachmentStorage(app.getPath('userData'), documentParsingService.results!)
|
|
conversationAttachmentStorage.reconcile((conversationId, kind, ownerId) => startupAssistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), true)
|
|
const contextManager = new ContextManager({
|
|
parseDocument: documentParsingService.parse,
|
|
assets: conversationAttachmentStorage,
|
|
validateConversation: (id) => {
|
|
if (!startupAssistantDatabase.hasAttachmentOwner(id, 'draft', id)) throw new Error('目标会话已删除')
|
|
}
|
|
})
|
|
const approvalBroker = new ToolApprovalBroker()
|
|
|
|
const shortcutSettingsService = new ShortcutSettingsService(
|
|
new ShortcutSettingsStore(
|
|
join(app.getPath('userData'), 'shortcut-settings.json')
|
|
),
|
|
globalShortcut,
|
|
() => {
|
|
if (mainWindow) {
|
|
toggleWindow(mainWindow)
|
|
}
|
|
},
|
|
process.platform
|
|
)
|
|
await shortcutSettingsService.initialize()
|
|
|
|
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
|
|
let runtimeReconfigurationClosing = false
|
|
const reconfigureRuntimes = (): Promise<void> => {
|
|
const operation = runtimeReconfigurationQueue.then(async () => {
|
|
if (runtimeReconfigurationClosing) {
|
|
throw new Error('Runtime 配置正在关闭')
|
|
}
|
|
const settings = await settingsStore.getResolvedSettings()
|
|
const nextEmbeddingProvider =
|
|
await createEmbeddingProvider(
|
|
settings,
|
|
startupEmbeddingModelManager
|
|
)
|
|
const nextRerankProvider = createRerankProvider(settings)
|
|
let nextRuntime: AgentRuntime | undefined
|
|
let nextSubagentRuntime: AgentRuntime | undefined
|
|
let nextSubagentProfileRuntimes:
|
|
| ReadonlyMap<string, AgentRuntime>
|
|
| undefined
|
|
try {
|
|
nextSubagentRuntime = await createSubagentRuntime(settings)
|
|
nextSubagentProfileRuntimes =
|
|
await createSubagentProfileRuntimes(settings)
|
|
if (runtime) {
|
|
nextRuntime = await createConfiguredRuntime(settings)
|
|
}
|
|
} catch (error) {
|
|
await Promise.allSettled([
|
|
nextRuntime?.dispose(),
|
|
nextSubagentRuntime?.dispose(),
|
|
...[
|
|
...(nextSubagentProfileRuntimes?.values() ?? [])
|
|
].map((candidate) => candidate.dispose())
|
|
])
|
|
throw error
|
|
}
|
|
|
|
let runtimeConsumed = false
|
|
let subagentRuntimesConsumed = false
|
|
try {
|
|
if (knowledgeService) {
|
|
await Promise.all([
|
|
knowledgeService.setEmbeddingProvider(
|
|
nextEmbeddingProvider
|
|
),
|
|
knowledgeService.setRerankProvider(
|
|
nextRerankProvider
|
|
)
|
|
])
|
|
}
|
|
if (runtime && nextRuntime) {
|
|
runtimeConsumed = true
|
|
await runtime.replace(nextRuntime)
|
|
}
|
|
subagentRuntimesConsumed = true
|
|
await subagentService.replaceRuntimes(
|
|
nextSubagentRuntime,
|
|
nextSubagentProfileRuntimes
|
|
)
|
|
await selectedRuntimeManager?.reset()
|
|
localRuntimeRegistry.reset(nextRuntime)
|
|
const previousEmbeddingProvider = activeEmbeddingProvider
|
|
activeEmbeddingProvider = nextEmbeddingProvider
|
|
activeRerankProvider = nextRerankProvider
|
|
await previousEmbeddingProvider?.dispose?.()
|
|
} catch (activationError) {
|
|
const rollbackResults = knowledgeService
|
|
? await Promise.allSettled([
|
|
knowledgeService.setEmbeddingProvider(
|
|
activeEmbeddingProvider
|
|
),
|
|
knowledgeService.setRerankProvider(
|
|
activeRerankProvider
|
|
)
|
|
])
|
|
: []
|
|
await Promise.allSettled([
|
|
nextEmbeddingProvider?.dispose?.(),
|
|
runtimeConsumed ? undefined : nextRuntime?.dispose(),
|
|
subagentRuntimesConsumed
|
|
? undefined
|
|
: nextSubagentRuntime.dispose(),
|
|
...(subagentRuntimesConsumed
|
|
? []
|
|
: [...nextSubagentProfileRuntimes.values()].map(
|
|
(candidate) => candidate.dispose()
|
|
))
|
|
])
|
|
const rollbackErrors = rollbackResults.flatMap((result) =>
|
|
result.status === 'rejected' ? [result.reason] : []
|
|
)
|
|
if (rollbackErrors.length > 0) {
|
|
throw new AggregateError(
|
|
[activationError, ...rollbackErrors],
|
|
'Runtime 激活失败,且模型服务回滚未能完成',
|
|
{ cause: activationError }
|
|
)
|
|
}
|
|
throw activationError
|
|
}
|
|
})
|
|
runtimeReconfigurationQueue = operation.catch(() => undefined)
|
|
return operation
|
|
}
|
|
stopRuntimeReconfiguration = async () => {
|
|
runtimeReconfigurationClosing = true
|
|
await runtimeReconfigurationQueue
|
|
}
|
|
const setCurrentEmbeddingConnection = async (
|
|
connectionId: string
|
|
): Promise<void> => {
|
|
const selection =
|
|
await settingsStore.setActiveEmbeddingConnection(connectionId)
|
|
if (!selection.changed) {
|
|
return
|
|
}
|
|
try {
|
|
await reconfigureRuntimes()
|
|
} catch (activationError) {
|
|
try {
|
|
await selection.restore()
|
|
await reconfigureRuntimes()
|
|
} catch (rollbackError) {
|
|
throw new AggregateError(
|
|
[activationError, rollbackError],
|
|
'向量模型连接激活失败,且回滚未能完成',
|
|
{ cause: rollbackError }
|
|
)
|
|
}
|
|
throw activationError
|
|
}
|
|
}
|
|
|
|
const remoteProjectSaveService =
|
|
startupManagedRemoteExecutionServices.createProjectSaveService({
|
|
database: startupAssistantDatabase,
|
|
notify: sendRemoteProjectSaveProgress
|
|
})
|
|
const remoteEnvironmentPreparer =
|
|
new RemoteEnvironmentPreparer({
|
|
resolver: startupRemoteAgentServices.targetResolver,
|
|
sshPool: startupRemoteAgentServices.sshPool,
|
|
agentPackageManager,
|
|
operationStore:
|
|
new RemoteEnvironmentOperationStore(
|
|
app.getPath('userData')
|
|
)
|
|
})
|
|
const remoteEnvironmentUpdateService =
|
|
new RemoteEnvironmentUpdateService(
|
|
remoteEnvironmentPreparer,
|
|
async (hostId) => {
|
|
startupManagedRemoteExecutionServices.invalidateHost(hostId)
|
|
const invalidations = [
|
|
startupRemoteAgentServices.invalidateHost(hostId)
|
|
]
|
|
if (selectedRuntimeManager) {
|
|
invalidations.push(
|
|
selectedRuntimeManager.invalidateHost(hostId)
|
|
)
|
|
}
|
|
await Promise.all(invalidations)
|
|
}
|
|
)
|
|
terminalSessionManager = new TerminalSessionManager({
|
|
database: startupAssistantDatabase,
|
|
executionSpaceResolver,
|
|
targetResolver: startupRemoteAgentServices.targetResolver,
|
|
sshPool: startupRemoteAgentServices.sshPool,
|
|
remoteEnabled: async () =>
|
|
(await applicationSettingsStore.get()).remoteProjectsEnabled,
|
|
deliverEvent: (ownerWebContentsId, event) => {
|
|
const targetWindow = mainWindow
|
|
if (
|
|
targetWindow &&
|
|
!targetWindow.isDestroyed() &&
|
|
targetWindow.webContents.id === ownerWebContentsId &&
|
|
!targetWindow.webContents.isDestroyed()
|
|
) {
|
|
targetWindow.webContents.send(ipcChannels.terminalEvent, event)
|
|
}
|
|
}
|
|
})
|
|
nativeClientCoordinator = new NativeClientCoordinator({
|
|
database: startupAssistantDatabase, settingsStore, applicationSettingsStore,
|
|
capabilities: capabilityService, executionSpaceResolver, terminalManager: terminalSessionManager,
|
|
localEnvironment: startupLocalToolEnvironmentService, bundledRuntimePaths,
|
|
rootDirectory: join(app.getPath('userData'), 'native-clients'),
|
|
managedNodeDirectory: join(toolEnvironmentRoot, 'native-node-22.22.0'),
|
|
npmCliPath: npmCliPaths.npmCliPath,
|
|
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
|
|
openExternal: (url) => shell.openExternal(url),
|
|
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
|
|
observeFailure: observeDesktopFailure,
|
|
storyGraphService: {
|
|
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
|
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
|
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal)
|
|
},
|
|
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
|
|
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
|
|
})
|
|
})
|
|
removeIpcHandlers = registerIpcHandlers(
|
|
mainWindow,
|
|
runtime,
|
|
legacyDefaultShortcut,
|
|
settingsStore,
|
|
capabilityService,
|
|
contextManager,
|
|
knowledgeService,
|
|
assistantDatabase,
|
|
approvalBroker,
|
|
bundledRuntimePaths,
|
|
reconfigureRuntimes,
|
|
async () => {
|
|
await Promise.all([
|
|
browserService?.clearSessions(),
|
|
startupFeedbackService.clear()
|
|
])
|
|
},
|
|
browserService,
|
|
subagentService,
|
|
channelSettingsStore,
|
|
applicationSettingsStore,
|
|
versionChecker,
|
|
speechModelManager,
|
|
undefined,
|
|
selectedRuntimeManager,
|
|
speechTranscriptionService,
|
|
knowledgeGateway,
|
|
launchWechatSidecar,
|
|
documentParsingService,
|
|
documentOcrModelManager,
|
|
documentOcrBroker,
|
|
releaseNotesService,
|
|
goodbuddyConfigService,
|
|
runtimeExtensionStore,
|
|
shortcutSettingsService,
|
|
sshHostService,
|
|
executionSpaceResolver,
|
|
remoteProjectSaveService,
|
|
sshHostDirectoryBrowser,
|
|
sshHostRemoteEnvironmentInspector,
|
|
startupEmbeddingModelManager,
|
|
async (connectionId) => {
|
|
const settings = await settingsStore.getResolvedSettings()
|
|
const provider = await createEmbeddingProvider(
|
|
settings,
|
|
startupEmbeddingModelManager,
|
|
connectionId
|
|
)
|
|
if (!provider) {
|
|
throw new Error('请先启用并保存向量模型设置')
|
|
}
|
|
return provider
|
|
},
|
|
setCurrentEmbeddingConnection,
|
|
remoteEnvironmentUpdateService,
|
|
agentPackageManager,
|
|
startupRemoteAgentServices.connectionManager,
|
|
terminalSessionManager,
|
|
startupLocalToolEnvironmentService,
|
|
imageGenerationService,
|
|
obsidianService,
|
|
nativeClientCoordinator
|
|
)
|
|
removeFeedbackIpcHandler = registerFeedbackIpcHandler(
|
|
mainWindow,
|
|
startupFeedbackService
|
|
)
|
|
loadMainWindow(mainWindow)
|
|
setImmediate(() => {
|
|
void repairStaleWindowsNotificationShortcuts({
|
|
platform: process.platform,
|
|
installed: installedWindowsBuild,
|
|
executablePath: process.execPath,
|
|
programsDirectory: join(
|
|
app.getPath('appData'),
|
|
'Microsoft',
|
|
'Windows',
|
|
'Start Menu',
|
|
'Programs'
|
|
),
|
|
shortcutAccess: {
|
|
readShortcutLink: (shortcutPath) =>
|
|
shell.readShortcutLink(shortcutPath),
|
|
writeShortcutLink: (
|
|
shortcutPath,
|
|
operation,
|
|
options
|
|
) =>
|
|
shell.writeShortcutLink(
|
|
shortcutPath,
|
|
operation,
|
|
options
|
|
)
|
|
}
|
|
}).then(
|
|
({ failed }) => {
|
|
if (failed > 0) {
|
|
console.warn(
|
|
`Failed to repair ${failed} stale notification shortcut(s)`
|
|
)
|
|
}
|
|
},
|
|
(error: unknown) => {
|
|
console.warn(
|
|
'Failed to inspect stale notification shortcuts',
|
|
error
|
|
)
|
|
}
|
|
)
|
|
})
|
|
|
|
app.on('activate', () => {
|
|
if (mainWindow) {
|
|
showWindow(mainWindow)
|
|
}
|
|
})
|
|
}).catch((error: unknown) => {
|
|
void desktopDiagnostics.recordFailure({
|
|
component: 'desktop',
|
|
stage: 'startup',
|
|
code: 'desktop.startup.failed',
|
|
error
|
|
}).catch(() => undefined)
|
|
console.error(
|
|
'GoodBuddy startup failed',
|
|
createStartupFailureDiagnostic(error)
|
|
)
|
|
dialog.showErrorBox(
|
|
'GoodBuddy 启动失败',
|
|
formatStartupFailureMessage(error)
|
|
)
|
|
app.quit()
|
|
})
|
|
}
|
|
|
|
let cleanupStarted = false
|
|
let cleanupComplete = false
|
|
|
|
app.on('before-quit', (event) => {
|
|
isQuitting = true
|
|
storageUpgradeController.abort()
|
|
if (cleanupComplete) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
if (cleanupStarted) {
|
|
return
|
|
}
|
|
cleanupStarted = true
|
|
void (async () => {
|
|
try {
|
|
const cleanup = settleCleanupPhases([
|
|
[
|
|
() => storageUpgrade,
|
|
() => feedbackService?.dispose(),
|
|
() => removeFeedbackIpcHandler?.(),
|
|
() => dshExtensionInstaller?.dispose(),
|
|
() => imageGenerationService?.dispose(),
|
|
() => knowledgeService?.beginShutdown(),
|
|
() => nativeClientCoordinator?.dispose(),
|
|
() => removeIpcHandlers?.()
|
|
],
|
|
[() => stopRuntimeReconfiguration?.()],
|
|
[
|
|
() => runtime?.detachForApplicationExit(),
|
|
() => selectedRuntimeManager?.detachForApplicationExit()
|
|
],
|
|
[
|
|
() => runtime?.dispose(),
|
|
() => selectedRuntimeManager?.dispose(),
|
|
() => {
|
|
directModelSubagentScheduler?.dispose()
|
|
return directModelSubagentScheduler?.waitForIdle()
|
|
},
|
|
() => browserService?.dispose(),
|
|
() => globalTlsPolicy?.dispose(),
|
|
() =>
|
|
Promise.allSettled(
|
|
[...embeddingBrokers].map((broker) => broker.shutdown())
|
|
).then(() => undefined),
|
|
() => embeddingModelManager?.dispose(),
|
|
() => documentOcrModelManager?.dispose(),
|
|
() => documentOcrBroker?.dispose(),
|
|
() => documentParsingService?.dispose(),
|
|
() => conversationAttachmentStorage?.close()
|
|
],
|
|
[() => localRuntimeRegistry.dispose()],
|
|
[() => terminalSessionManager?.dispose()],
|
|
[() => localToolEnvironmentService?.dispose()],
|
|
[() => managedRemoteExecutionServices?.dispose()],
|
|
[() => remoteAgentServices?.dispose()],
|
|
[() => knowledgeGateway?.dispose()],
|
|
[() => knowledgeService?.dispose()],
|
|
[() => desktopDiagnostics.dispose()]
|
|
])
|
|
globalShortcut.unregisterAll()
|
|
tray?.destroy()
|
|
await runCleanupBeforeDeadline(cleanup, 8_000, () => {
|
|
assistantDatabase?.close()
|
|
})
|
|
} finally {
|
|
cleanupComplete = true
|
|
app.exit(0)
|
|
}
|
|
})()
|
|
})
|
|
|
|
app.on('will-quit', () => {
|
|
cleanupComplete = true
|
|
})
|