deepseek-harness跨平台桌面端二开项目(附git仓库地址+安装包)
2026/8/24 15:22:54 网站建设 项目流程

把插件化 Agent 框架改造成跨平台桌面应用:从架构设计、sidecar 载荷装配到 electron-builder 打包的完整工程实录

微信公众原文地址

一次真实的"给 Agent 加一张桌面脸"的改造记录。没有泛泛而谈的 Demo 贴图,全部是在一个真实插件化 Agent 框架里落地跑通的工程细节:Electron 壳与 sidecar 子进程如何分工、3.4 万个文件的多运行时载荷如何被装配进安装包、原生.node模块为什么必须待在 asar 之外、以及那些只有在真实机器上才会踩到的坑。

Github仓库地址
Gitee仓库地址
AtomGit仓库地址
安装包地址
免安装绿色版地址

为什么要把 Agent 框架做成桌面应用

我手上维护着一个插件化 Agent 框架:它是一个很大的 monorepo,几十个能力包按「Service Definition / Provider / Consumer」能力缝拆分——shell、文件系统、终端、子代理、web 搜索、会话持久化……核心就是一句话:everything is a plugin。命令行(CLI)和浏览器版(web UI)都已经是跑通的产品。

但「命令行能用」和「做成一个普通用户愿意装的产品」之间,隔着最后一公里:

  • 命令行有门槛,多数人需要的是一个窗口
  • 已有 web UI 是成熟的,重新写一层前端是重复造轮子;
  • 但把浏览器页面"包"进桌面窗口,又不能动那些已经跑得很稳的分层。

于是目标很明确:不改任何现有 package,用最薄的壳,把一个已有的 web UI 变成桌面产品。

整体方案:一个壳 + 一个 sidecar 子进程,本地回环通信

目标形态只有两个角色:Electron 壳负责窗口、进程与分发;harness sidecar负责其余一切。前端不复制第二份——桌面窗口加载的就是apps/web的构建产物,由 sidecar 的 webserver 提供。

整条链路如下:

  1. 主进程requestSingleInstanceLock()抢占单实例锁,第二个实例只负责把已开窗口拉起来;
  2. 预留一个空闲回环端口spawnsidecar:dsh --profile web --host 127.0.0.1 --port <port>
  3. 主进程轮询POST /api/host.describe直到返回 2xx(健康探测通过),再loadURL加载该源;
  4. 窗口里跑的就是随包发布的 web UI,和浏览器访问完全一致。

用一张时序图概括:

BrowserWindowharness sidecar(dsh --profile web)Electron 主进程BrowserWindowharness sidecar(dsh --profile web)Electron 主进程抢占单实例锁、预留空闲端口spawn(显式传入 --port)启动 Cordis 插件树(web-app bundle)监听 127.0.0.1:port轮询 /api/host.describe 直到 2xxloadURL(http://127.0.0.1:port)session.list / session.prompt 等 RPC会话事件、审批、问答(WebSocket 下行)

服务端之所以能"随包发布、零改动",是因为 web UI 的 dist 通过require.resolve由 sidecar 提供——也就是说,web 前端必须打包进 sidecar 的依赖闭包,桌面窗口看的和网页看的是同一份产物。

核心难点一:怎么把"一个运行时 + 一个框架的一堆插件"装进安装包

这是整个改造里最硬核的部分。桌面版不是只发一个 Electron 壳就行,它要自缚 Node 运行时 + 完整的插件依赖闭包

用 pnpm deploy 物化生产闭包

我们有一个"纯依赖部署根",用它拉出 sidecar 需要的全部依赖:

pnpm--filterdsh-desktop-sidecar-runtime deploy\--legacy--prod--config.node-linker=hoisted\--config.auto-install-peers=false --config.link-workspace-packages=true\apps/desktop/.sidecar/app

但依赖关系是会"漂移"的。因为auto-install-peers=false,那些以 peer 身份骑行的 Service Definition 包会被丢掉。应对办法是机械"补全":遍历每个已部署包的peerDependencies,凡是在 workspace 里存在的就补进来(注册表 peer 如react保持缺席,因为浏览器 bundle 在apps/web/dist,node 半边从不需要它们)。

原生模块必须放在 asar 之外

node-pty、原生解压工具等.node模块和子进程运行时路径,穿过 asar 虚拟文件系统会坏,所以 sidecar 整个载荷作为extraResources放在 asar 外:

# apps/desktop/electron-builder.ymlextraResources:-from:.sidecarto:sidecarfilter:-"**/*"

固定 Node 运行时并校验

sidecar 自带一个固定版本的 Node,跨平台下载后要做 sha256 校验,再用载荷自己的 Node 跑dsh --version冒烟,证明运行时 + 依赖图 + 入口三者一起是通的:

apps/desktop/.sidecar/node/node.exe\apps/desktop/.sidecar/app/lib/bin.js--version# => 0.1.0-rc.5

实测这一份载荷:app 约 247 MB / 3.2 万+ 文件node 约 101 MB / 约 2000 文件,最终安装包 180 MB 出头。3.4 万个小文件这个数字,后面还会回来咬我们一口。

核心难点二:进程的生命周期管理

桌面壳要管好一个子进程的"生老病死":

  • 优雅退出before-quit先停 sidecar 再退。POSIX 走SIGTERM→SIGKILL阶梯;Windows 上因为 Node 把所有信号都映射成强杀TerminateProcess,改用整树taskkill /T /F——崩溃一致性由持久层(SQLite WAL)承担。
  • 崩溃自动重启:sidecar 意外退出后,冷却 1 秒在同端口重启,然后重载窗口;会话历史在磁盘上,视图可重建。连续 3 次重启失败才弹错误框放弃:
asyncfunctionhandleUnexpectedExit(code:number|null):Promise<void>{if(isQuitting())returnconsecutiveStartFailures+=1if(consecutiveStartFailures>MAX_CONSECUTIVE_START_FAILURES){fatal(`the harness sidecar keeps crashing ...`)return}awaitdelay(RESTART_DELAY_MS)awaitstartSidecar()mainWindow?.webContents.reload()}
  • 探测不阻塞退出:用AbortController贯穿整个启动链路,quit 一开始就 abort 掉在途的等待。

核心难点三:冷启动、杀软与"安装后自启失败"

真实机器让const PROBE_TIMEOUT_MS = 30_000现了形:首次安装后勾选"启动应用",可能 30 秒内 sidecar 都起不来——因为文件刚从安装包解压,杀毒软件正在实时扫描整个载荷,冷启动 web 配置远慢于平常。于是把打包模式的探测预算也拉到和 dev 一致:

constPROBE_TIMEOUT_MS_DEV=120_000constPROBE_TIMEOUT_MS_PACKAGED=120_000

更新注释时我也补了一句:冷启动可能远超 30 秒——源码侧要经 tsx 把整个 profile 跑热,刚装好的打包载荷还要过一遍实时杀软扫描。

核心难点四:跨平台打包

目标用一份配置覆盖主流平台。这里有个反直觉但重要的点:sidecar 的原生模块是按主机构架编译的,所以 arm64 的安装包必须在 arm64 机器上重新组装载荷再打包,不能指望在一台 x64 上"一次全出"。

win:target:[nsis]mac:target:[dmg]linux:target:[AppImage,deb]

还有个容易栽的坑:每个平台下的arch字段其实是defaultArch,只接受单个字符串,不支持[x64, arm64]这种数组——多架构要用--x64 --arm64命令行传参,一开始把数组写进去会直接 schema 校验失败。

踩坑实录(都是真实复盘的干货)

  1. Electron 的 ESM 顶层await死锁:Electron 要等 ESM 主模块求值完才发ready事件,一旦在模块顶层await app.whenReady()就永远等不到——必须包成显式boot()函数。
  2. EXDEV: cross-device link not permitted:下载解压 Node 运行时不能塞系统临时目录,要把工作目录建在 staging 内部,靠同卷rename落地。
  3. pnpm deploy会破坏工作区状态:legacy deploy 会把node_modules标记成"待生产修复",下次pnpm run会把 devDependencies 剪掉。于是组装脚本在finally里跑一次pnpm install复原开发环境。
  4. readFileSync is not defined:脚本里少导了个node:fs导入,纯环境问题。
  5. 编译产物没重装:改了main.ts的探测预算,忘了重新assemble,web dist 的 favicon 没进安装包——因为前端是从依赖闭包里require.resolve出来的,必须重新汇编,提醒自己"改前端 = 要重装配,不只是一条 dist"。
  6. Windows 上git add:在一个有几十万文件(node_modules.pnpm-store.sidecar)的仓库里天然慢,不是配错了 .gitignore。

目录结构:原则是"壳只管壳该管的"

apps/desktop/ ├── src/ │ ├── main.ts # 单实例、端口预留、spawn、崩溃重启、优雅退出 │ └── sidecar/ │ ├── controller.ts # 子进程控制器(spawn/stop) │ ├── paths.ts # dev 源码入口 vs 打包载荷的 launch plan │ ├── ports.ts # 空闲端口 │ └── probe.ts # 健康探测 ├── scripts/assemble-sidecar.mjs # 载荷装配:deploy + peer 补全 + Node 运行时 ├── electron-builder.yml # 跨平台打包配置 └── sidecar-runtime/ # 纯依赖部署根

效果与收益

一次"零侵入"的改造:没有改动任何现有 capability package,agent-loop、持久化、审批流程原样复用。得到的是:

  • 一个可双击运行、带单实例锁、崩溃自愈的桌面窗口;
  • 跨平台安装包(NSIS / dmg / AppImage),自带 Node 运行时,无需用户装 Node;
  • 会话数据与 CLI 共享同一份~/.dsh,命令行和桌面入口指向同一个产品。


免安装绿色版

写在最后

给 Agent 加一张"桌面的脸",难点从来不在 Electron 本身,而在如何让一个多运行时、多插件、几万个文件的应用,以可复现、可校验、可安装的方式被分发出去。理解了 sidecar 载荷的物化与原生模块的边界,你就把握住了这类"壳 + 子进程"架构的命门。

如果你也在做 Agent / AI 工具的桌面化,欢迎在评论区聊聊:你更倾向"浏览器里跑 web UI"还是"本地壳 + sidecar"?或者留下你踩过最疼的那个坑,我们一起挖。

如果本文对你有帮助,点赞 + 收藏是对我最大的鼓励,也让我知道这类"工程化落地"的内容是不是你想要的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询