《Bun 后端怎么变桌面软件:Tauri 2 三进程架构与崩溃自愈》

AgentHub 桌面端:打包构建与运行时架构

本文是 AgentHub 桌面端的架构拆解第一篇。要解决的问题很具体:一个后端是 Bun + TypeScript、前端是 Vue 的服务端程序,怎么变成一个双击就能用的 Windows 桌面软件,并且用户装完之后的升级、卸载、崩溃恢复、自动更新都不丢数据。

这块不涉及花哨的算法,但涉及进程编排、构建产物组装、数据持久化边界这些工程判断------为什么是三个进程而不是一个、为什么数据和程序分目录、sidecar 崩了怎么办。下面按"怎么打包 → 怎么运行 → 壳层管什么 → 怎么更新"的顺序展开。


设计总述

AgentHub 桌面端用 Tauri 2 做外壳 ,把已有的 Bun 后端原样编译成一个 sidecar 二进制 塞进安装包,前端 Vue 页面通过 localhost HTTP/WebSocket 直连这个后端。整套设计的核心取舍是:不为桌面端重写业务,后端在桌面和服务器上跑的是同一份代码、同一个 gateway 入口,桌面端只是给它套了个窗口和进程管家。

这样设计的好处:业务逻辑零分叉,桌面版和服务器版行为一致,只有"数据写哪、谁拉起进程、跑什么环境"三点不同;代价是运行时有三个进程要协调,进程生死管理的复杂度转移到了 Rust 壳层。

核心设计

1. 三层技术栈,各解决一件事

打包这件事本质是把三种独立产物拼成一个。理解为什么是这三样,比记住它们是什么更重要:

前端(界面层) ------ Vue 3 + TypeScript + Vite,配 shadcn-vue/Tailwind(UI)、Pinia(状态)、Vue Router(路由)。Vite 把它编译成 dist/ 里一坨纯静态 html/js/css。选 Vite 是因为它产物就是静态文件,能被任何 WebView 直接加载,不需要运行时------这点对下一步"塞进桌面壳"很关键。

后端(业务内核) ------ TypeScript + Bun,用 @anthropic-ai/sdk 调 Claude,@modelcontextprotocol/sdk 做 MCP 客户端,Zod 校验、yaml 读配置。关键动作是 bun build --compile:把 Bun 运行时 + 全部代码 + 依赖打成单个 exe (agenthub.exe),不依赖目标机器装 Bun 或 Node。

为什么后端要编译成独立 exe 而不是打包源码让壳去跑?因为终端用户机器上没有 Bun。--compile 把运行时一起塞进去,exe 自带解释器,双击即跑。这是 Bun 相比"分发一堆 .js + 要求装 Node"的直接优势。

桌面壳(变桌面软件层) ------ Tauri 2 + Rust。Tauri 不自带浏览器内核,而是复用系统的 WebView2(Windows)/ WKWebView(Mac)渲染前端。

为什么选 Tauri 而不是 Electron?最直接的技术理由是产物体积和内存:Electron 每个 app 自带一整个 Chromium(上百 MB),Tauri 复用系统 WebView,壳本身是 Rust 编译的原生二进制,安装包能小一个数量级。代价是 Windows 上依赖用户机器的 WebView2 Runtime(Win11 已内置,老系统需预装)------这是个真实的部署约束,不是纯优点。

打包分发 ------ 当前只出 Windows NSIS .exe 安装包,由 Tauri 配置 bundle.targets: "nsis" 单目标驱动,且 installMode: "currentUser" 按当前用户安装,不需要管理员权限;NSIS 还挂了一个自定义卸载钩子(installer-hooks.nsh,见下文自启一节)。Mac 的 dmg 不再随打包产出------配置里仍留着 macOS.infoPlist 和 mac 版 sidecar 编译脚本,要打 Mac 包需手动把 targets 改回 "all"。另外 createUpdaterArtifacts: true 会在打包时额外产出带签名的更新增量文件,供自动更新通道使用(见第四节)。

2. 装配流水线:两条独立支线 + 一次组装

关键设计是前端和后端各自独立打包成"零件",最后由 Tauri 组装,而不是揉在一个构建步骤里:

objectivec 复制代码
 ┌──────────┐     ┌──────────┐     ┌───────────────────────┐     ┌────────────┐
 │ Vue3 源码 │ ──▶ │  dist/   │     │  AgentHub.exe         │     │ 安装包      │
 │ + 配置    │ ①  │ 静态网页 │ ──▶ │  = Rust 壳            │ ④  │ .exe       │
 └──────────┘ Vite└──────────┘     │  + dist/              │ NSIS└────────────┘
 ┌──────────┐     ┌──────────┐     │  + agenthub.exe       │     ↑
 │ TS 后端  │ ──▶ │ agenthub │ ──▶ │  + resources/         │ ────┘
 │ 源码     │ ②  │  .exe    │ ③  └───────────────────────┘
 └──────────┘ Bun └──────────┘    Tauri 组装

三步组装由 Tauri 配置(desktop/src-tauri/tauri.conf.json)声明,它是"装配总指挥":

less 复制代码
AgentHub.exe(最终成品)
  = Rust 壳程序
  + dist/ 前端网页          (frontendDist: "../dist")
  + agenthub.exe            (externalBin: ["binaries/agenthub"])
  + resources/              (base.yaml、SOUL.md)
  • externalBin: ["binaries/agenthub"] ------ 把 sidecar 二进制打进安装包(Tauri 按平台三元组自动匹配 agenthub-x86_64-pc-windows-msvc.exe)
  • resources: {...} ------ 把 config/base.yamlconfig/SOUL.md 作为默认模板一起打包
  • frontendDist: "../dist" ------ 指向前端产物目录

3. 实际打包命令(Windows)

原理是 4 步,实操要敲的命令:

bash 复制代码
# 1. 根目录依赖(用 npm 不用 bun,见下方说明)
cd <项目根>
npm install

# 2. desktop 依赖
cd desktop
npm install

# 3. 编译 sidecar 二进制
#    产物:desktop/src-tauri/binaries/agenthub-x86_64-pc-windows-msvc.exe
cd ..
bun run build:sidecar:win

# 4. 打包(= tauri build:编 Rust 壳 + 出 NSIS 安装包)
cd desktop
npm run build

sidecar 编译脚本共四条分工(根 package.json):build:sidecaros.platform() 自动分发(darwin 走 mac-arm,其余走 win),build:sidecar:mac-arm / build:sidecar:mac-x64 是 Mac 两个架构,build:sidecar:win 是 Windows;另有一键命令 build:desktop(= build:sidecar + desktop build),把第 3、4 步合成一条。Windows 上手动敲 bun run build:sidecar:win 依然有效。

第 4 步内部发生了什么 :npm run build = tauri build。tauri build 启动后,按配置里的 beforeBuildCommand: "bun run build:vite" 自动跑前端打包 (vite builddist/),再编 Rust 壳、出安装包。所以前端只打一次,不需要手动先 vite build------这是 Tauri 的 build hook 机制。

注意一个看似矛盾的点:装依赖用 npm,但 tauri 的 hook 里仍用 bun 跑 vite。两条命令走不同运行时,这是刻意的、不冲突------原因见"设计取舍与已知局限"一节。

为什么装依赖用 npm install 而不是 bun install :Windows Defender 实时扫描会持有 bun 写入的临时文件句柄,导致 bun 报 EPERM: Operation not permitted。规避方式两种:用 npm 装依赖(绕过);或给 Defender 加排除项:

sql 复制代码
Add-MpPreference -ExclusionPath "C:\Users<用户名>.bun"
Add-MpPreference -ExclusionPath "<项目目录>"

构建环境要求:

工具 版本 用途
Bun >= 1.3 运行时 + 编译 sidecar
Node.js >= 18 desktop 前端依赖
Rust >= 1.80 编译 Tauri 壳
VS Build Tools 2019/2022 Rust Windows 编译链(勾 "Desktop development with C++")
WebView2 Runtime 最新 打包机通常已有,终端机需预装

产物路径 :desktop/src-tauri/target/release/bundle/nsis/AgentHub_<版本>_x64-setup.exe,版本号跟随 tauri.conf.jsonversion 字段(当前为 1.0.6,即 AgentHub_1.0.6_x64-setup.exe),同时产出 .sig 更新签名文件。首次 Rust 全量编译约 5--15 分钟,后续增量快。

4. 安装产物:程序本体与用户数据分两个目录(核心设计判断)

这是这层最值得讲的设计取舍。装完后,程序本体(只读)用户数据(可写) 放在两个物理隔离的目录:

安装目录(程序本体,只读) ------ 如 D:\AgentHub:

csharp 复制代码
D:\AgentHub\
├── AgentHub.exe                ← Rust 主进程(Tauri Shell,外壳)
├── agenthub-x86_64-pc-windows-msvc.exe  ← Bun 编译的 sidecar(业务内核,bundle 后带 target triple 后缀)
├── resources\
│   ├── base.yaml               ← 默认配置模板
│   └── SOUL.md                 ← 人格定义模板
└── icons\                      ← 窗口/托盘图标

工作目录(用户数据,可写) ------ %APPDATA%\com.agenthub.desk(由 tauri.conf.jsonidentifier 决定这层子目录名;换 Windows 用户登录,用户名变但这层不变,每个系统用户独立一份):

lua 复制代码
com.agenthub.desk\
├── .env                          ← 敏感配置(配置页保存后写这里,注意在工作目录根)
├── config\                       ← 启动即有(init_workspace 从 resources 拷入)
│   ├── base.yaml                 ← 配置基线,运行时读这个(不是安装目录的模板)
│   └── SOUL.md                   ← 人格定义(同样从 resources 拷入)
├── data\                         ← per-agent 会话与记忆(见下方说明)
│   └── <agentName>\
│       ├── sessions<session_key>.jsonl   ← 该 agent 的会话历史
│       └── memory\MEMORY.md                ← 该 agent 的 L3 记忆
├── auth\
│   └── USER.md                   ← 用户画像(全局共享,不分 agent)
├── agents\                       ← 各 agent 的配置目录(agent.json + SOUL.md)
├── workspace\                    ← Agent 工作区(default agent 用 workspace,其他用 workspace_<name>)
├── logs\
│   └── gateway.log               ← sidecar 业务日志(入口处 setLogFile 指向 logs/)
├── init.log                      ← ★ 在根目录,不在 logs/。workspace 初始化记录
├── sidecar-debug.log             ← ★ 在根目录,不在 logs/。sidecar 的 stdout/stderr
└── tauri-debug.log               ← ★ 在根目录,不在 logs/。Rust 侧 spawn 诊断

关于 data/<agentName>/:AgentHub 是多 agent 架构 ------同一份程序里可以并存多个可配置的 agent 实例(某业务团队的审核助手、咨询助手各是一个),每个 agent 有独立的会话历史和记忆,所以会话/记忆按 data/<agentName>/ 分桶存放。USER.md(用户画像)是例外,放 auth/ 下全局共享,因为所有 agent 服务的是同一个用户,画像不该分裂。这层跟打包/进程编排关系不大,但目录结构上要点出来,否则会以为还是早期"全局一个 agent、会话直接堆在 sessions/"的布局。

按需生成(用到才出现) :

文件/目录 何时生成 说明
config/base.local.yaml 开发模式下手工建的本地覆盖 深度合并到 base.yaml 之上;仅开发模式加载 ,桌面端打包后跑 production 环境不读它(见 src/config/loader.ts)
.env 配置页保存了敏感字段(API key 等) 真实值写 .env(工作目录根),config/base.yaml 里始终写回 ${VAR} 占位
data/<agent>/memory/MEMORY.md L3 记忆处理器成功跑完一次 LLM 整理会话后写;调用失败则不生成
auth/USER.md L3 处理器整理用户画像后 全局一份
workspace/backups/ write_file 覆盖了授权目录下已存在文件 自动备份,写 workspace 不备份
workspace/email-attachments/ read_email 读了带附件的邮件 附件落 <uid>/ 子目录
cron/jobs.json manage_cron_jobs 建了定时任务 或手工编辑

为什么要分两个目录 ------ 答案是两类文件的生命周期完全不同:

关注点 安装目录 工作目录
谁写 安装器 程序运行时
可写性 只读 用户可写
升级时 整体替换 保留
卸载时 删除 保留
放什么 二进制 + 静态资源 用户数据 + 日志

核心结论:安装目录的 resources/base.yaml 是模板,升级会被覆盖;运行时真正读的是工作目录的 config/base.yaml (首次启动从模板拷来,之后用户改动都落这里)。如果程序和数据混在一个目录,升级替换程序时就会连带冲掉用户配置和会话------分目录是为了让"升级/卸载安装目录"这个高频动作永远碰不到用户数据。配置页保存时走的也是这套边界:非敏感字段 read-merge-write 进工作目录的 config/base.yaml,敏感字段(API key)真实值写工作目录根的 .env,yaml 里只留 ${NAME}_API_KEY 占位符------密钥永远不落进可能被升级覆盖或随包分发的 yaml。

首次启动由 Rust 的 init_workspace 从 resources 拷 base.yaml/SOUL.md 到工作目录 config/,已存在则跳过 (保护用户改动)。另含一个 macOS 专属逻辑:从旧 identifier 目录迁移 .env 到新工作目录(lib.rsinit_workspace 尾部),是 identifier 变更后给老用户留的数据衔接。


运行时:三个进程怎么配合

用户双击 AgentHub.exe 后,实际跑起来的是三个进程:

scss 复制代码
   用户双击 AgentHub.exe
          │
          ▼
   ┌──────────────────────────────────────────────────┐
   │  Rust 壳程序启动(AgentHub.exe)                    │
   │  ① 开桌面窗口                                     │
   │  ② 把 dist/ 网页塞进窗口(WebView 渲染)            │  ← 界面
   │  ③ 悄悄启动后台 agenthub.exe(gateway 模式)       │  ← 大脑
   └──────────────────────────────────────────────────┘
          │                              │
          │  界面点按钮                   │ 后台开 127.0.0.1:8900
          ▼                              ▼
   ┌─────────────┐  HTTP/WS 请求   ┌──────────────┐
   │  Vue 前端   │ ──────────────▶ │ agenthub      │
   │  (WebView)  │ ◄────────────── │  (Bun 后端)   │
   └─────────────┘    返回数据     └──────────────┘
                                       │
                                       ▼
                                 调 Claude AI
                                 调工具(文件/Shell/邮件/Skill)
                                 存会话/记忆(per-agent)
进程 角色 干什么
Rust 壳(AgentHub.exe) 房子 开窗口、管后台进程生死、托盘、SSO、自动更新
Vue 前端(WebView) 装修 用户看到的界面,点按钮发请求
Bun 后端(agenthub.exe) 大脑 干所有活:调 AI、存数据、接渠道、跑各 agent

关键取舍:前端和后端不在一个进程,前端通过 HTTP/WebSocket 直连 127.0.0.1:8900,Rust 不转发业务数据。

为什么前端不经 Rust 转发、而是直连 sidecar 端口?因为后端本来就是个完整的 HTTP 服务(服务器部署时企微/飞书/HTTP API 都连它)。桌面端复用同一个 HTTP 接口,前端就是又一个 HTTP 客户端,Rust 不用为业务 API 再写一层转发胶水。Rust 只管进程级操作(重启/停止/SSO/开文件夹),这些才走 Tauri command。职责边界清晰:业务走 HTTP,进程管理走 command。


Rust 壳层具体干了什么(进程编排,不碰业务)

Rust 一行业务逻辑都不写,只做进程编排。理解它"只当进程管家"是理解整个架构的关键:

职责 实现
算工作目录 = app_data_dir() 决定 sidecar 把数据写到哪
首次启动从 resources 拷 base.yaml/SOUL.md init_workspace,已存在则跳过
spawn 前先 taskkill 清理残留 sidecar kill_sidecar_by_name,防上次异常退出留下孤儿进程占着 8900
spawn sidecar:agenthub.exe gateway spawn_with_monitor,传 AGENTHUB_WORKSPACE + AGENTHUB_ENV=production
sidecar 崩溃自动重启 监控线程 500ms 轮询,指数退避 + 60s 兜底 + 5 分钟稳定重置(见下)
Windows 托盘、关窗=隐藏到托盘 托盘菜单 + CloseRequested 事件拦截
单实例保护 tauri_plugin_single_instance,第二个进程唤起已有窗口后退出
开机自启 tauri_plugin_autostart + get/set_autostart command
自动更新 tauri_plugin_updater + updater 模块(见第四节)
SSO 子窗口 + ticket 拦截 open_sso_login,子 WebView 导航拦截 ticket
调 explorer/open 打开文件夹 open_path
dev 模式下 8900 已被占则跳过 spawn tauri::is_dev() && port_is_open(8900),不抢外部进程(如开发时手动跑的 bun run gateway)
启动窗口按显示器工作区自适应收缩居中 解决高 DPI 屏固定 1200x800 底部出屏:宽取 min(1200, 工作区宽 95%),高取 min(800, 工作区高 90%)

spawn sidecar 的关键细节

arduino 复制代码
let child = cmd
    .arg("gateway")                                    // 跑完整网关模式
    .env("AGENTHUB_WORKSPACE", &workspace)             // 告诉后端数据写哪
    .env("AGENTHUB_ENV", "production")
    .stdin(Stdio::null())
    .stdout(log_file)                                  // 重定向到 sidecar-debug.log
    .stderr(log_file2)
    .spawn()?;
  • sidecar 路径按 target triple 探测候选(lib.rsspawn_with_monitor):优先 agenthub-<arch>-<triple>.exe(与 tauri bundle 对 externalBin 的命名一致),找不到回退裸名 agenthub.exe,两个 exe 与主程序平级;全部候选都不存在时,把尝试过的路径写进 sidecar-debug.log 再报 NotFound------不再是静默拼单一文件名,排查 spawn 失败有迹可循
  • 工作目录通过 AGENTHUB_WORKSPACE 环境变量传给 sidecar ------ 这是 Rust 和 Bun 之间唯一的"数据写哪"约定
  • CREATE_NO_WINDOW(0x08000000)确保 sidecar 不弹黑色控制台窗
  • 绕过 tauri_plugin_shell,改用原生 std::process::Command ------ 这是个踩过的坑:用 Tauri 官方 shell 插件 spawn Bun 二进制会让它挂起,换原生 Command 才正常。代码里保留着明确注释(bypasses tauri_plugin_shell which causes Bun to hang)

Rust 暴露给前端的 command

scss 复制代码
restart_gateway             重启 sidecar(kill → 重置计数 → 重新 spawn)
stop_gateway                停止 sidecar
is_managed                  查询是否被 Rust 托管
get_autostart/set_autostart 查询/开关机自启(dev 构建禁止开启)
open_sso_login              打开 SSO 登录子窗口
clear_sso_session           清除 SSO 会话(隐藏 WebView 访问 logout URL,3 秒后自动关窗)
open_path                   调系统资源管理器打开文件夹
get_app_version             当前版本号(编译期从 tauri.conf.json 注入)
check_for_update            手动检查更新(只查不装)
download_and_install_update 下载并安装更新(见第四节)

open_sso_login 打开的 SSO 登录 URL 带回调参数 &target=http://127.0.0.1:8900/login-callback:登录成功后 SSO 服务端把 ticket 拼到这个 target 上跳转,子窗口的导航拦截捕获 ticket、emit("sso-ticket") 给前端并关窗。

崩溃恢复:退避、兜底与"稳定即原谅"

监控线程每 500ms try_wait() 一次 sidecar 进程,状态机如下:

  • 进程还活着 → 继续轮询

  • 进程退出且 managed == true(不是主动停的)→ 记一条 [terminated] uptime=<秒> restart_attempt=<N> 到 sidecar-debug.log,然后按下面的策略决定何时拉起:

    • 本次运行时长 ≥ 300 秒 → 先把 restart_count 清零。意思是"稳定跑了 5 分钟以上才死,属于偶发崩溃(端口冲突/OOM/被杀),快速重试额度视为已恢复"
    • 额度内(第 1--3 次)→ 指数退避 1s / 10s / 30s 后重新 spawn
    • 超过 3 次 → 降为每 60 秒低频兜底重试,不再永久放弃
  • respawn 本身失败(比如二进制丢失这类硬错误)→ 写日志放弃,不再空转

managed 这个 AtomicBool 是关键:主动 stop_gateway/restart_gateway 时先置 managed=false,监控线程看到就知道"这是人为停的,别自动拉起来",避免和主动操作打架。

三个策略各有明确动机,都是从真实故障模式倒推的:

  • 为什么退避而不是立刻连试:启动即崩的典型原因是 8900 端口被占,1 秒内连试三次只会烧光额度,等端口释放才能成功;
  • 为什么 60s 兜底而不是放弃:sidecar 死亡对用户是静默的,界面上只是请求全部失败,没有兜底意味着用户必须手动重启整个应用;
  • 为什么 5 分钟稳定就重置额度:如果不重置,一个白天偶发崩一次的进程会慢慢耗尽额度,最后落进 60s 慢速通道------而它本该享受快速恢复。

单实例、开机自启与自动更新

这三件事是"桌面软件"区别于"网页"的标配,壳层一次配齐。

单实例保护

tauri_plugin_single_instance:第二个进程启动时,唤起已有实例的主窗口(show + unminimize + set_focus)后自己退出,避免同时跑两个壳、两个 sidecar 抢 8900。

一个容易踩的实现细节:这个插件必须第一个注册。它靠初始化顺序抢占单实例锁,如果注册晚于其他插件,锁竞争失败时仍会走完整 setup 流程(杀 sidecar + spawn),保护就失效了------代码里有注释专门标注这一点。

开机自启:默认开启 + 用户选择三态记忆

自启用 tauri_plugin_autostart 实现(写注册表 Run 键),暴露 get_autostart/set_autostart 两个 command。两条规则:

  • dev 构建禁止开启自启:dev 的 exe 路径注册进 Run 键,会让每次开机拉起开发构建;
  • 用户选择三态记忆 (前端 desktop/src/lib/autostart.ts):null(用户从未碰过开关)= 首次启动自动开启;"on"/"off"(用户手动设置过)= 永不自动改写。main.ts 启动时调 ensureDefaultAutostart(),只有用户从没表达过意愿时才替他打开------尊重已有关闭选择,又不至于让"默认常驻"的功能没人发现。

自启还牵出一个卸载清理问题 :Run 键是应用运行时写的(值名 = productName),NSIS 卸载器不认识它,不清理的话卸载后每次开机都会报"找不到 exe"。所以 installer-hooks.nsh 里挂了 NSIS_HOOK_POSTUNINSTALL 钩子,卸载时显式删掉这条注册表值。这是"装的时候顺手开的开关,卸的时候要负责关掉"的完整闭环。

自动更新:后台定时检查 + 手动检查 + 一条热更新链路

Tauri 的 updater 插件负责签名校验和下载安装,配置在 tauri.conf.json(createUpdaterArtifacts: true 打包时产出更新产物 + minisign 公钥 <MINISIGN_PUBKEY> + 内网更新源 <UPDATE_ENDPOINT>)。业务逻辑在 desktop/src-tauri/src/updater.rs,分三条路径:

后台定时检查 (仅生产模式,dev 跳过避免开发期误触):启动后延迟 30 秒 首次检查,之后每 4 小时 一次。发现新版 emit("update-available"),前端弹更新对话框。两个容易被忽略的细节:

  • 为什么延迟 30 秒:setup() 执行时 webview 还没加载,前端的 listen("update-available") 要等 App onMounted 才注册,立即检查会在监听注册前 emit,通知丢失,用户得干等 4 小时------30 秒让前端先就绪;
  • 为什么用 std::thread + sleep 而不是前端 setInterval:托盘隐藏/系统休眠时前端定时器不可靠,且 sleep 在休眠后顺延而非补偿触发,对"每 4h 复查"这个精度足够。

手动检查 (check_for_update command,配置页按钮):只查不装,返回 UpdateInfo 结构。关键设计是 check_failed 字段------更新源不可达时返回"检查失败",与"确认无新版"区分开,避免把网络故障误报成"已是最新版本"。事件通道由后台线程独占 emit(走静默规则),手动检查走返回值由前端自行弹窗,两条路径互不干扰。dev 模式直接返回无更新,前端按钮不用按 dev 状态分支处理。

下载安装 (download_and_install_update command),是一条带失败恢复的链路:

  1. updating = true(此后 CloseRequested 事件拦截关窗,emit("update-close-blocked") 提示用户稍候);
  2. 先停 sidecar------Windows 上更新要替换安装目录的 exe,sidecar 二进制被占用会导致安装失败;杀完立即释放锁,不持锁跨下载;
  3. 下载安装:on_chunk 回调节流 100ms emit("update-progress")(逐 chunk emit 高频事件 + CSS transition 跟不上会让进度条抖动),总大小来自响应头 Content-Length;下载完成 emit("update-installing"),让前端切到"正在安装"态而不是僵在 100%;
  4. Windows 上安装成功后,安装器会自动退出并重启应用(插件 restart_after_install 默认开启),Rust 侧无需再调 app.restart();
  5. 失败路径 :复位 updating(避免卡 true 关不了窗)、把第 2 步杀掉的 sidecar 复活 ------不复活的话界面还在但 8900 已死,聊天/配置全挂只能重启应用;最后 emit("update-error"),错误按是否签名问题分类(signature/other),前端给出不同的提示文案。

三进程通信机制

scss 复制代码
┌──────────────┐  invoke()   ┌──────────────┐  HTTP/WS    ┌──────────────┐
│  WebView     │ ──────────► │  Rust 主进程  │             │  Bun sidecar │
│  (Vue3 前端) │             │  (Tauri 壳)  │             │  (:8900)     │
│              │ ◄────────── │              │ ◄─────────► │              │
└──────────────┘  emit()     └──────────────┘             └──────────────┘
       │                                                          │
       │  HTTP/WS 直连 :8900(前端 fetch,绕过 Rust)                │
       └────────────────────────────────────────────────────────────┘
通信方向 走什么通道 管什么
前端 → Rust Tauri command(invoke) 进程级:restart/stop/自启/SSO/open_path/更新
前端 → sidecar HTTP/WebSocket 直连 127.0.0.1:8900 业务数据:REST API + /ws/logs 日志流
Rust → sidecar 原生进程管理(spawn/kill) 无业务数据通道
Rust → 前端 emit(sso-ticket/update-available/update-progress 等) 推票据、更新进度等事件

三条通道各管一摊,互不越界------这种"进程管理走 command、业务走 HTTP、事件走 emit"的三分法,是判断某个新功能该走哪条通道的依据。


Bun sidecar 内部装配(业务内核)

agenthub.exe gateway 启动后的装配顺序。这里体现了多 agent 架构------运行时对象不再是全局单例,而是每个 agent 一套:

scss 复制代码
loadConfig(workspace)             读工作目录 config/base.yaml + .env
       ↓
构建共享工具池                     builtinToolsMap(内置)+ mcpToolsMap(MCP)全局构建一次
       ↓
GatewayCore + MessageBus          持有两个异步队列(渠道 ↔ Agent 解耦)
       │                          dispatch 内部:找 agent(agent_hint 直取 / routeAgent 两轮匹配) → 标准化 → 命令路由(3 级:priority/exact/prefix) → 并发控制 → Mid-turn Queue
       ↓
AgentRegistry.create(...)         加载所有 agent,每个 agent 一个 AgentEntry
       │                          AgentEntry 内含该 agent 专属的 loop / toolRegistry /
       │                          contextBuilder / consolidator / provider ------ 不再全局单例
       ↓
ChannelManager                    CLI/HTTP 全局单例;企微/飞书按 active agent 的
       │                          channel binding 多实例注册(register(channel, agentName))
       ↓
后台调度器                         Heartbeat / Cron / MemoryProcessor / AutoCompact
                                  (Heartbeat/MemoryProcessor 遍历 active agent;Cron 按 job 自带 agent 字段;AutoCompact 遍历 data/ 所有 agent 目录不区分 active)

启动后在 127.0.0.1:8900 监听 HTTP + WebSocket。

这层跟"打包"关系不大,但要点出装配出来的不是单个 agent,而是一个 AgentRegistry 管着多个 agent 。工具池(builtinToolsMap/mcpToolsMap)是全局构建一次共享的,每个 agent 的 ToolRegistry 只是从共享池按自己的白名单过滤挂载------不是每个 agent 复制一份工具代码。这个"共享池 + per-agent 过滤视图"的设计,是多 agent 内存开销可控的原因。打包/进程编排层不感知这些,Rust 只管拉起 gateway 进程,进程内是单 agent 还是多 agent 是 Bun 侧的事。

打包后与开发模式的区别

打包后 sidecar 仍执行后端入口的 gateway 分支,与开发时 bun run gateway 完全一致。区别只有三点:

区别点 开发模式 打包后
入口 命令行 bun run gateway 被 Rust 主进程 spawn
workspace 项目根目录 %APPDATA%\com.agenthub.desk(由 AGENTHUB_WORKSPACE 指定)
env development production(AGENTHUB_ENV=production)

这个"三点差异之外完全一致"是整个桌面端设计的落脚点:开发时怎么跑,打包后就怎么跑,只是换了个数据目录和环境标记。


生命周期总览(用户视角)

用户动作 发生的事
双击安装包 NSIS 按当前用户解压到安装目录,sidecar.exe + resources 落地
启动 AgentHub 单实例检查 → Rust spawn sidecar(gateway,workspace=APPDATA),sidecar 在 :8900 起服务,前端连上
关主窗口(Win) 隐藏到托盘,sidecar 继续跑(服务不中断);更新进行中则拦截关窗并提示
托盘「退出」 quitting=trueshutdown_sidecar(kill child + taskkill 兜底)→ app.exit(0)
sidecar 崩溃 监控线程 500ms 检测,1s/10s/30s 退避重启;稳定 5 分钟后额度重置;额度耗尽转 60s 低频兜底,不放弃
仪表盘点「重启」 restart_gateway:kill → 重置计数 → 重新 spawn
后台/手动检查到新版 弹窗 → 确认后停 sidecar → 下载(进度事件)→ 安装 → 安装器自动重启应用
升级/卸载 安装目录被替换,工作目录在 APPDATA,用户数据/会话/记忆/配置保留;卸载钩子顺带清掉自启 Run 键

Windows 上"关窗=隐藏到托盘"而非退出,是刻意的:后台 sidecar 还在跑定时任务和 AI 巡检,关窗就停服会让这些静默任务失效。只有托盘菜单「退出」才真正走 shutdown_sidecar。macOS 行为不同(关窗直接停 sidecar)。


设计取舍与已知局限

最后把几个"为什么"和"哪里不完美"集中说清楚。

为什么选 Tauri 而不是 Electron? 用一点部署约束换体积和内存:Electron 每个 app 自带整个 Chromium(上百 MB、常驻内存高),Tauri 复用系统 WebView、壳是 Rust 原生二进制,安装包小一个数量级;代价是 Windows 上需要 WebView2 Runtime(Win11 内置,老系统要预装),等于把一部分环境依赖甩给了用户机器。这不是纯粹的"Tauri 更好",而是一笔明确的交换。

为什么装依赖用 npm,但跑 vite 又用 bun? 刻意分开的。装依赖用 npm 是为了规避 Windows Defender 的问题:Defender 实时扫描会持有 bun 写入的临时文件句柄,导致 bun install 报 EPERM。跑 vite 用 bun 则没这个问题(不涉及大量小文件的并发写入),而且 Tauri 的 beforeBuildCommand 里配的就是 bun run build:vite。两条命令各自选了没坑的那个运行时。另一个规避方式是给 Defender 加 .bun 目录和项目目录的排除项。

这套架构目前已知的不彻底之处,几个真实存在的:

  1. 两个平台生命周期语义不一致 。Windows 托盘和"关窗=隐藏"是 #[cfg(target_os = "windows")] 门控的,macOS 走的是关窗直接停 sidecar,分平台维护两套语义。
  2. spawn sidecar 绕过了 Tauri 官方的 tauri_plugin_shell 。官方插件会让 Bun 编译产物挂起,改用原生 std::process::Command 才正常------官方插件对 Bun 产物的兼容还不完善,代码注释里明确标注了这个规避。
  3. 端口是写死的 8900。如果端口被占(比如本机跑着开发用的 gateway),桌面端会和它冲突,目前靠启动时 taskkill 残留进程兜底。更稳的做法是端口动态分配 + Rust 把实际端口 emit 给前端。
  4. 三个调试日志(init/sidecar-debug/tauri-debug)散在工作目录根 ,没有轮转,长期运行会无限增长,可以收敛到统一的 logs/ 下加轮转。

小结

NSIS 把 Rust 壳 + Bun 核 + Vue 脸打包到安装目录(per-user 安装);Rust 启动后先清残留再 spawn Bun 核,用环境变量把数据指向 %APPDATA% 工作目录;Vue 脸直连 Bun 核的 :8900;三者靠 localhost HTTP/WS + Tauri command + emit 三条通道各管一摊。壳层除了管进程生死(退避重启 + 稳定重置),还配齐了桌面软件三件套------单实例、自启(带卸载清理)、自动更新(后台定时 + 手动检查 + 失败回滚)。后端在桌面和服务器上跑的是同一份 gateway 代码,只有数据目录和环境标记不同。

相关推荐
lucas_AI1 小时前
第 10 讲 · PGO 实战:让程序的真实运行数据指导编译
人工智能
lucas_AI1 小时前
第 8 讲 · oeAware 与中断绑核:把手工调优自动化
人工智能
hyunbar7771 小时前
LangChain 实战:上下文工程长期记忆管理
人工智能
lucas_AI1 小时前
第 11 讲 · KAE 硬件加速:不改一行业务代码的性能提升
人工智能
hanbon1 小时前
技术参数响应表怎么写?
人工智能·招投标·ai写标书·评分表
byte轻骑兵1 小时前
【BlueZ 】sdp 模块:服务发现协议的用户态实现基础
linux·人工智能·bluez·电脑蓝牙·嵌入式蓝牙
小白学大数据1 小时前
Python 项目实战:用 Flask 构建生产级 MySQL 增删改查 REST API
开发语言·人工智能·python·mysql·flask
YangYang9YangYan1 小时前
资金分析校招 JD 拆解,现金流建模与数据分析能力怎么准备
大数据·人工智能·数据分析
名不经传的养虾人1 小时前
从0到1:企业级AI项目迭代日记 Vol.110|协作有了预算,延迟有了归因,知识有了保真
大数据·人工智能·机器人·ai编程·企业ai