import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { mkdirSync, readFileSync } from 'node:fs' import { userInfo } from 'node:os' import { AgentService } from './pi/agent-service' import { getPluginHost } from './plugins/plugin-host' import { getBrowserController } from './browser/browser-controller' import { detectBrowserSources, importBrowserProfile } from './browser/browser-profile' import { getLogger } from './logger' const log = getLogger('ipc') /* eslint-disable @typescript-eslint/no-explicit-any */ /** ipcMain.handle 追踪包装:成功 debug(含耗时)、失败 error(含通道/耗时)——Agent 自诊断的数据源之一 */ function handle(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any): void { ipcMain.handle(channel, async (event, ...args: any[]) => { const started = Date.now() try { const result = await listener(event, ...args) log.debug('ipc ok', { channel, ms: Date.now() - started }) return result } catch (err) { log.error('ipc failed', { channel, ms: Date.now() - started, err }) throw err } }) } /* eslint-enable @typescript-eslint/no-explicit-any */ /** * 注册 IPC 通道并把 agent 事件转发到所有渲染窗口。 * 返回 service 实例供入口进程使用。 */ export function registerIpc(): AgentService { const service = new AgentService() service.onEvent((event) => { for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { win.webContents.send('agent:event', event) } } }) handle('agent:send', (_event, text: unknown, images: unknown) => { if (typeof text !== 'string' || !text.trim()) { throw new Error('empty prompt') } const sendImages = Array.isArray(images) ? images.filter( (img): img is { mimeType: string; data: string } => typeof img === 'object' && img !== null && typeof (img as { mimeType?: unknown }).mimeType === 'string' && typeof (img as { data?: unknown }).data === 'string' ) : undefined return service.send(text, sendImages) }) handle('agent:steer', (_event, text: unknown) => { if (typeof text !== 'string' || !text.trim()) { throw new Error('empty steer text') } return service.steer(text) }) handle('agent:stop', () => service.stop()) handle('agent:state', () => service.getState().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) ) // ---- 会话管理 ---- handle('sessions:list', () => service.listSessions().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) ) handle('sessions:new', () => service.newSession().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) ) handle('sessions:open', (_event, path: unknown) => { if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path') return service.openSession(path).catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) }) handle('sessions:rename', (_event, name: unknown) => { if (typeof name !== 'string' || !name.trim()) throw new Error('invalid session name') return service.renameSession(name.trim()).catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) }) handle('sessions:delete', (_event, path: unknown) => { if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path') return service.deleteSession(path).catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) }) // ---- 模型与凭据 ---- const pass = (fn: () => Promise): Promise => fn().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) handle('models:catalog', () => pass(() => service.getModelCatalog())) // ---- 自定义 API 提供商(models.json)---- handle('models:custom:list', () => service.listCustomProviders()) handle('models:custom:save', (_event, config: unknown) => { if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider') const c = config as Record if ( typeof c.id !== 'string' || typeof c.baseUrl !== 'string' || typeof c.apiKey !== 'string' || typeof c.api !== 'string' || !Array.isArray(c.models) ) { throw new Error('invalid custom provider') } const normalized = { id: c.id, name: typeof c.name === 'string' ? c.name : c.id, baseUrl: c.baseUrl, apiKey: c.apiKey, api: c.api, models: c.models.filter((m): m is string => typeof m === 'string') } return pass(() => service.saveCustomProvider(normalized)) }) handle('models:custom:delete', (_event, id: unknown) => { if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id') return pass(() => service.deleteCustomProvider(id)) }) handle('models:set', (_event, providerId: unknown, modelId: unknown) => { if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model') return pass(() => service.setModel(providerId, modelId)) }) handle('auth:setKey', (_event, providerId: unknown, apiKey: unknown) => { if (typeof providerId !== 'string' || typeof apiKey !== 'string' || !apiKey.trim()) { throw new Error('invalid api key') } return pass(() => service.setApiKey(providerId, apiKey.trim())) }) handle('auth:logout', (_event, providerId: unknown) => { if (typeof providerId !== 'string') throw new Error('invalid provider') return pass(() => service.logoutProvider(providerId)) }) // ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ---- handle('thinking:set', (_event, level: unknown) => { if (typeof level !== 'string') throw new Error('invalid thinking level') return pass(() => service.setThinkingLevel(level as never)) }) handle('session:compact', (_event, instructions: unknown) => { if (instructions !== undefined && instructions !== null && typeof instructions !== 'string') { throw new Error('invalid compaction instructions') } return pass(() => service.compactSession(instructions || undefined)) }) handle('session:compact:abort', () => pass(() => service.abortCompaction())) handle('session:autoCompact', (_event, enabled: unknown) => { if (typeof enabled !== 'boolean') throw new Error('invalid flag') return pass(() => service.setAutoCompactionEnabled(enabled)) }) handle('session:forkPoints', () => pass(() => service.listForkPoints())) handle('session:navigate', (_event, entryId: unknown, options: unknown) => { if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id') const o = (options ?? {}) as { summarize?: unknown; customInstructions?: unknown } if (o.summarize !== undefined && typeof o.summarize !== 'boolean') throw new Error('invalid navigate options') if (o.customInstructions !== undefined && typeof o.customInstructions !== 'string') { throw new Error('invalid navigate options') } return pass(() => service.navigateTree(entryId, { ...(o.summarize === true ? { summarize: true } : {}), ...(typeof o.customInstructions === 'string' ? { customInstructions: o.customInstructions } : {}) }) ) }) // 会话末尾分叉为新会话(pi fork,原会话保留) handle('session:fork', () => pass(() => service.forkSession())) // 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止) handle('session:forkAt', (_event, entryId: unknown, position: unknown) => { if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id') if (position !== 'at' && position !== 'before') throw new Error('invalid fork position') return pass(() => service.forkSessionAt(entryId, position)) }) // ---- 应用设置 ---- handle('settings:get', () => service.getSettings()) // 当前生效的系统提示词(pi 原生 + 技能注入,只读展示) handle('settings:getSystemPrompt', () => service.getSystemPrompt()) handle('settings:setTheme', (_event, id: unknown) => { if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id') service.setThemeColor(id.trim()) }) handle('settings:setThemeMode', (_event, mode: unknown) => { if (mode !== 'light' && mode !== 'dark' && mode !== 'system') throw new Error('invalid theme mode') service.setThemeMode(mode) }) // 启用/禁用单个技能或命令(重建运行时使发现阶段过滤生效) handle('settings:setResourceEnabled', (_event, kind: unknown, name: unknown, enabled: unknown) => { if (kind !== 'skill' && kind !== 'command') throw new Error('invalid resource kind') if (typeof name !== 'string' || !name.trim() || name.length > 200) throw new Error('invalid resource name') if (typeof enabled !== 'boolean') throw new Error('invalid enabled flag') return service.setResourceEnabled(kind, name, enabled) }) // ---- 工作区 ---- handle('workspaces:list', () => service.listWorkspaces().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) ) handle('workspaces:create', (_event, name: unknown) => { if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name') return service.createWorkspace(name).catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) }) handle('workspaces:open', (_event, path: unknown) => { if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path') return service.openWorkspace(path).catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) }) // 调起系统目录选择器;取消返回 null handle('workspaces:browse', async () => { const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] const result = await dialog.showOpenDialog(win, { title: '选择工作区目录', defaultPath: service.workspacesRoot, properties: ['openDirectory', 'createDirectory'] }) if (result.canceled || result.filePaths.length === 0) return null return result.filePaths[0] }) // ---- 工具审批 ---- handle('approval:resolve', (_event, id: unknown, approved: unknown) => { if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval') // settle 内部经 onSettle 订阅发出 approval_settled,此处无需重复广播 service.approvals.settle(id, approved) return Promise.resolve() }) handle('app:versions', () => ({ app: process.env.npm_package_version ?? '0.0.0', electron: process.versions.electron, node: process.versions.node, chrome: process.versions.chrome, user: userInfo().username })) // ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)---- handle('chat:resources', () => service.listChatResources().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) ) handle('queue:set', (_event, steering: unknown, followUp: unknown) => { const norm = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] return service.setQueue(norm(steering), norm(followUp)) }) handle('permissions:set', (_event, mode: unknown) => { if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode') service.setPermissionMode(mode) }) // ---- 设置页:技能 / 命令详情 ---- handle('resources:details', () => service.listResourceDetails().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) ) // 在系统文件管理器中打开(技能/命令来源文件所在目录) handle('shell:openPath', (_event, target: unknown) => { if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path') return shell.openPath(target) }) // 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立) handle('shell:openDirectory', (_event, target: unknown) => { if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path') try { mkdirSync(target, { recursive: true }) } catch { // 已存在等情况忽略,交给 openPath 反馈 } return shell.openPath(target).then((result) => { // shell.openPath 失败时以字符串返回错误信息,成功返回空串 if (result) throw new Error(result) }) }) handle('files:list', () => service.listWorkspaceFiles().catch((err: unknown) => { throw new Error(err instanceof Error ? err.message : String(err)) }) ) handle('attachments:pick', async (event) => { const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0] return service.pickAttachments(win ?? undefined) }) // ---- 内置受管浏览器 ---- // 控制器构造会调 session.fromPartition,必须等 app ready; // IPC 处理器在渲染层就绪后才可能被调用,惰性取用是安全的 void app.whenReady().then(() => { getBrowserController().onState((state) => { for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send('browser:event', { type: 'state', state }) } }) }) const browserPass = (fn: () => T): T => { try { return fn() } catch (err) { throw new Error(err instanceof Error ? err.message : String(err)) } } handle('browser:getState', () => browserPass(() => getBrowserController().getState())) handle('browser:navigate', (_e, input: unknown) => browserPass(() => getBrowserController().navigateDisplay(typeof input === 'string' ? input : '')) ) handle('browser:goBack', () => browserPass(() => getBrowserController().goBack())) handle('browser:goForward', () => browserPass(() => getBrowserController().goForward())) handle('browser:reload', () => browserPass(() => getBrowserController().reload())) handle('browser:newTab', (_e, url: unknown) => browserPass(() => getBrowserController().newTab(typeof url === 'string' && url ? url : undefined, false)) ) handle('browser:selectTab', (_e, tabId: unknown) => browserPass(() => getBrowserController().selectTab(typeof tabId === 'string' ? tabId : '')) ) handle('browser:closeTab', (_e, tabId: unknown) => browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined)) ) handle('browser:close', () => browserPass(() => getBrowserController().close())) // 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位 handle('browser:captureTab', (_e, tabId: unknown) => browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined)) ) handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk())) // 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局) handle('browser:layout', (_e, layout: unknown) => { const l = layout as { tabId?: unknown; visible?: unknown; revision?: unknown; bounds?: unknown } const { tabId, visible, revision } = l const bounds = l.bounds as { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | undefined const x = bounds?.x const y = bounds?.y const width = bounds?.width const height = bounds?.height if ( typeof tabId !== 'string' || typeof visible !== 'boolean' || typeof revision !== 'number' || typeof x !== 'number' || typeof y !== 'number' || typeof width !== 'number' || typeof height !== 'number' ) { throw new Error('invalid browser layout') } return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } })) }) // ---- 本地浏览器配置导入 ---- handle('browser:detectSources', () => browserPass(() => detectBrowserSources())) handle('browser:importProfile', (_e, sourceId: unknown, profileDir: unknown) => { if (typeof sourceId !== 'string' || typeof profileDir !== 'string') throw new Error('invalid import source') return browserPass(() => importBrowserProfile(sourceId, profileDir)) }) // ---- 插件系统 ---- void app.whenReady().then(() => { // 插件启停/重载会重建 agent 运行时(extensionFactories 重跑) getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime()) }) handle('plugins:list', () => getPluginHost().list()) handle('plugins:uiState', () => getPluginHost().uiState()) // 外置插件来源目录(设置页「打开目录」) handle('plugins:dirs', () => getPluginHost().pluginDirs()) // 外置插件渲染层模块源码(渲染层以 Blob URL import,绕开 dev http 源对 plugin:// 的 CORS 限制) handle('plugins:readRenderer', (_e, id: unknown) => { if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id') return getPluginHost().readRendererSource(id) }) // ---- 插件设置表单(声明式 schema;值持久化与动作回推在 plugin-host) ---- handle('plugins:settingsSchemas', () => getPluginHost().settingsSchemasList()) handle('plugins:settingsValues', () => getPluginHost().settingsValuesList()) handle('plugins:setSettingValue', (_e, id: unknown, key: unknown, value: unknown) => { if (typeof id !== 'string' || typeof key !== 'string' || !key) throw new Error('invalid setting value') getPluginHost().setSettingValue(id, key, value) }) handle('plugins:setSettingsNav', (_e, id: unknown, on: unknown) => { if (typeof id !== 'string' || typeof on !== 'boolean') throw new Error('invalid settings nav toggle') return getPluginHost().setSettingsNav(id, on) }) handle('plugins:settingsAction', (_e, id: unknown, key: unknown) => { if (typeof id !== 'string' || typeof key !== 'string') throw new Error('invalid settings action') getPluginHost().settingsAction(id, key) }) // path/image 控件的系统选择框 handle('plugins:pickPath', async (event, mode: unknown) => { if (mode !== 'file' && mode !== 'directory' && mode !== 'image') throw new Error('invalid path mode') const win = BrowserWindow.fromWebContents(event.sender) const filters = mode === 'image' ? [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif'] }] : undefined const options = { filters } const result = mode === 'directory' ? await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openDirectory'] }) : dialog.showOpenDialog({ ...options, properties: ['openDirectory'] })) : await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openFile'] }) : dialog.showOpenDialog({ ...options, properties: ['openFile'] })) return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0] }) // image 控件缩略图:渲染层(dev 是 http 源)不能直接 ,回传 data URL handle('plugins:readImage', (_e, path: unknown) => { if (typeof path !== 'string' || !path || !/\.(png|jpe?g|gif|webp|bmp|svg|ico|avif)$/i.test(path)) return null try { const buf = readFileSync(path) if (buf.length > 5 * 1024 * 1024) return null const ext = path.split('.').pop()!.toLowerCase() return `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}` } catch { return null } }) handle('plugins:setEnabled', (_e, id: unknown, enabled: unknown) => { if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle') return getPluginHost().setEnabled(id, enabled) }) // 打开插件私有数据目录(资源管理器中显示;不存在则先建) handle('plugins:openDataDir', (_e, id: unknown) => { if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id') return getPluginHost().openPluginDataDir(id) }) // 清除插件全部存储数据(设置页二次确认后调用;连接关闭 + 删数据目录) handle('plugins:clearData', (_e, id: unknown) => { if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id') return getPluginHost().clearPluginData(id) }) handle('plugins:reload', () => getPluginHost().reload()) handle('plugins:openView', (_e, view: unknown, params: unknown) => { if (typeof view !== 'string' || !view) throw new Error('invalid view') getPluginHost().openView(view, params) }) // 插件页(iframe)经渲染层桥转发来的状态胶囊写请求;pluginId 键控防越权 handle('plugins:setStatus', (_e, pluginId: unknown, itemId: unknown, item: unknown) => { if (typeof pluginId !== 'string' || typeof itemId !== 'string') throw new Error('invalid status item') getPluginHost().setStatusItem(pluginId, itemId, (item ?? null) as never) return Promise.resolve() }) return service }