deepseek-harness跨平台桌面端二开项目(附git仓库地址+安装包)

把插件化 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. 预留一个空闲回环端口spawn sidecar:dsh --profile web --host 127.0.0.1 --port <port>
  3. 主进程轮询 POST /api/host.describe 直到返回 2xx(健康探测通过 ),再 loadURL 加载该源;
  4. 窗口里跑的就是随包发布的 web UI,和浏览器访问完全一致。

用一张时序图概括:
BrowserWindow harness sidecar(dsh --profile web) Electron 主进程 BrowserWindow harness sidecar(dsh --profile web) Electron 主进程 #mermaid-svg-YbmThuGtphfxTqXL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YbmThuGtphfxTqXL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YbmThuGtphfxTqXL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YbmThuGtphfxTqXL .error-icon{fill:#552222;}#mermaid-svg-YbmThuGtphfxTqXL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YbmThuGtphfxTqXL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YbmThuGtphfxTqXL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YbmThuGtphfxTqXL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YbmThuGtphfxTqXL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YbmThuGtphfxTqXL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YbmThuGtphfxTqXL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YbmThuGtphfxTqXL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YbmThuGtphfxTqXL .marker.cross{stroke:#333333;}#mermaid-svg-YbmThuGtphfxTqXL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YbmThuGtphfxTqXL p{margin:0;}#mermaid-svg-YbmThuGtphfxTqXL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YbmThuGtphfxTqXL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YbmThuGtphfxTqXL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-YbmThuGtphfxTqXL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL .sequenceNumber{fill:white;}#mermaid-svg-YbmThuGtphfxTqXL #sequencenumber{fill:#333;}#mermaid-svg-YbmThuGtphfxTqXL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-YbmThuGtphfxTqXL .messageText{fill:#333;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YbmThuGtphfxTqXL .labelText,#mermaid-svg-YbmThuGtphfxTqXL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .loopText,#mermaid-svg-YbmThuGtphfxTqXL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YbmThuGtphfxTqXL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-YbmThuGtphfxTqXL .noteText,#mermaid-svg-YbmThuGtphfxTqXL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-YbmThuGtphfxTqXL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YbmThuGtphfxTqXL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YbmThuGtphfxTqXL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YbmThuGtphfxTqXL .actorPopupMenu{position:absolute;}#mermaid-svg-YbmThuGtphfxTqXL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-YbmThuGtphfxTqXL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YbmThuGtphfxTqXL .actor-man circle,#mermaid-svg-YbmThuGtphfxTqXL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-YbmThuGtphfxTqXL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 抢占单实例锁、预留空闲端口 spawn(显式传入 --port) 启动 Cordis 插件树(web-app bundle) 监听 127.0.0.1:port 轮询 /api/host.describe 直到 2xx loadURL(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 需要的全部依赖:

sh 复制代码
pnpm --filter dsh-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 外:

yaml 复制代码
# apps/desktop/electron-builder.yml
extraResources:
  - from: .sidecar
    to: sidecar
    filter:
      - "**/*"

固定 Node 运行时并校验

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

sh 复制代码
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 次重启失败才弹错误框放弃:
ts 复制代码
async function handleUnexpectedExit(code: number | null): Promise<void> {
  if (isQuitting()) return
  consecutiveStartFailures += 1
  if (consecutiveStartFailures > MAX_CONSECUTIVE_START_FAILURES) {
    fatal(`the harness sidecar keeps crashing ...`)
    return
  }
  await delay(RESTART_DELAY_MS)
  await startSidecar()
  mainWindow?.webContents.reload()
}
  • 探测不阻塞退出 :用 AbortController 贯穿整个启动链路,quit 一开始就 abort 掉在途的等待。

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

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

ts 复制代码
const PROBE_TIMEOUT_MS_DEV = 120_000
const PROBE_TIMEOUT_MS_PACKAGED = 120_000

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

核心难点四:跨平台打包

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

yaml 复制代码
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"? 或者留下你踩过最疼的那个坑,我们一起挖。

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

相关推荐
张忠琳2 小时前
【deepseek-harness】DSH 文档合辑 · 篇一:核心架构与概览
ai·agent·deepseek·harness
海兰3 小时前
【插件】OpenClaw 上下文引擎指南
人工智能·agent·openclaw
云烟成雨TD3 小时前
LlamaIndex 系列【5】智能体开发:大语言模型接入与基础调用
ai·agent·rag·llamaindex
海兰3 小时前
【原理】OpenClaw Agent 运行时回顾一文清
人工智能·agent
新知图书3 小时前
11.4 基于扣子编程的实现过程(AI 数据质检工作流)
人工智能·agent·ai agent·智能体
weixin_471383033 小时前
22 多 Agent 架构
agent
Justin3go4 小时前
DeepSeek Harness 如何做到 99% 缓存命中率(原理详解)
人工智能·开源·agent·deepseek
comedate4 小时前
DeepSeek Harness 配置 deepseek-v4-flash-vision-exp 视觉模型
agent·skill·deepseekharness
程序猿DD4 小时前
OctaFuse Gateway 2.7.0:按星期计价、用户级模型折扣、百炼ASR模型支持优化
后端·agent