Files
inkreach-agent/apps/desktop/src/main/ipc.ts
T

418 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync } 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'
/**
* 注册 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)
}
}
})
ipcMain.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)
})
ipcMain.handle('agent:steer', (_event, text: unknown) => {
if (typeof text !== 'string' || !text.trim()) {
throw new Error('empty steer text')
}
return service.steer(text)
})
ipcMain.handle('agent:stop', () => service.stop())
ipcMain.handle('agent:state', () =>
service.getState().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
// ---- 会话管理 ----
ipcMain.handle('sessions:list', () =>
service.listSessions().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.handle('sessions:new', () =>
service.newSession().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.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))
})
})
ipcMain.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))
})
})
ipcMain.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<unknown>): Promise<unknown> =>
fn().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog()))
// ---- 自定义 API 提供商(models.json)----
ipcMain.handle('models:custom:list', () => service.listCustomProviders())
ipcMain.handle('models:custom:save', (_event, config: unknown) => {
if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider')
const c = config as Record<string, unknown>
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))
})
ipcMain.handle('models:custom:delete', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id')
return pass(() => service.deleteCustomProvider(id))
})
ipcMain.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))
})
ipcMain.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()))
})
ipcMain.handle('auth:logout', (_event, providerId: unknown) => {
if (typeof providerId !== 'string') throw new Error('invalid provider')
return pass(() => service.logoutProvider(providerId))
})
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
ipcMain.handle('thinking:set', (_event, level: unknown) => {
if (typeof level !== 'string') throw new Error('invalid thinking level')
return pass(() => service.setThinkingLevel(level as never))
})
ipcMain.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))
})
ipcMain.handle('session:compact:abort', () => pass(() => service.abortCompaction()))
ipcMain.handle('session:autoCompact', (_event, enabled: unknown) => {
if (typeof enabled !== 'boolean') throw new Error('invalid flag')
return pass(() => service.setAutoCompactionEnabled(enabled))
})
ipcMain.handle('session:forkPoints', () => pass(() => service.listForkPoints()))
ipcMain.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,原会话保留)
ipcMain.handle('session:fork', () => pass(() => service.forkSession()))
// 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止)
ipcMain.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))
})
// ---- 应用设置 ----
ipcMain.handle('settings:get', () => service.getSettings())
// 当前生效的系统提示词(pi 原生 + 技能注入,只读展示)
ipcMain.handle('settings:getSystemPrompt', () => service.getSystemPrompt())
ipcMain.handle('settings:setTheme', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id')
service.setThemeColor(id.trim())
})
ipcMain.handle('settings:setThemeMode', (_event, mode: unknown) => {
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') throw new Error('invalid theme mode')
service.setThemeMode(mode)
})
// 启用/禁用单个技能或命令(重建运行时使发现阶段过滤生效)
ipcMain.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)
})
// ---- 工作区 ----
ipcMain.handle('workspaces:list', () =>
service.listWorkspaces().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.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))
})
})
ipcMain.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
ipcMain.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]
})
// ---- 工具审批 ----
ipcMain.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()
})
ipcMain.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 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
ipcMain.handle('chat:resources', () =>
service.listChatResources().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.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))
})
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode')
service.setPermissionMode(mode)
})
// ---- 设置页:技能 / 命令详情 ----
ipcMain.handle('resources:details', () =>
service.listResourceDetails().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
// 在系统文件管理器中打开(技能/命令来源文件所在目录)
ipcMain.handle('shell:openPath', (_event, target: unknown) => {
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
return shell.openPath(target)
})
// 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立)
ipcMain.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)
})
})
ipcMain.handle('files:list', () =>
service.listWorkspaceFiles().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.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 = <T,>(fn: () => T): T => {
try {
return fn()
} catch (err) {
throw new Error(err instanceof Error ? err.message : String(err))
}
}
ipcMain.handle('browser:getState', () => browserPass(() => getBrowserController().getState()))
ipcMain.handle('browser:navigate', (_e, input: unknown) =>
browserPass(() => getBrowserController().navigateDisplay(typeof input === 'string' ? input : ''))
)
ipcMain.handle('browser:goBack', () => browserPass(() => getBrowserController().goBack()))
ipcMain.handle('browser:goForward', () => browserPass(() => getBrowserController().goForward()))
ipcMain.handle('browser:reload', () => browserPass(() => getBrowserController().reload()))
ipcMain.handle('browser:newTab', (_e, url: unknown) =>
browserPass(() => getBrowserController().newTab(typeof url === 'string' && url ? url : undefined, false))
)
ipcMain.handle('browser:selectTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().selectTab(typeof tabId === 'string' ? tabId : ''))
)
ipcMain.handle('browser:closeTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined))
)
ipcMain.handle('browser:close', () => browserPass(() => getBrowserController().close()))
// 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位
ipcMain.handle('browser:captureTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined))
)
ipcMain.handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk()))
// 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局)
ipcMain.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 } }))
})
// ---- 本地浏览器配置导入 ----
ipcMain.handle('browser:detectSources', () => browserPass(() => detectBrowserSources()))
ipcMain.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())
})
ipcMain.handle('plugins:list', () => getPluginHost().list())
ipcMain.handle('plugins:uiState', () => getPluginHost().uiState())
ipcMain.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)
})
ipcMain.handle('plugins:reload', () => getPluginHost().reload())
ipcMain.handle('plugins:openView', (_e, view: unknown, params: unknown) => {
if (typeof view !== 'string' || !view) throw new Error('invalid view')
getPluginHost().openView(view, params)
})
// 插件页(iframe)经渲染层桥转发来的状态胶囊写请求;pluginId 键控防越权
ipcMain.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
}