418 lines
18 KiB
TypeScript
418 lines
18 KiB
TypeScript
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
|
||
}
|