前言:Desktop 这层最容易被一句“Electron 套壳”带过去,但源码并不是这么组织的:窗口和本机能力属于 Electron,聊天状态属于 React,真正的 Agent 仍在独立 Python Backend。本文沿着一次请求从输入框走到模型、工具再流式返回,并用可交互动画把协议和模块逐步展开。
系列导航:这组文章按“总览 → 入口 → 运行时 → 扩展 → 状态 → 协作 → 安全”的顺序展开;第一次阅读可以从总览开始,带着具体问题也可以直接跳到专题。
- 01|Hermes Agent 架构总览:从 Agent Loop 到智能体平台
- 02|Hermes Desktop 架构:Electron 如何连接本地 Python Agent
- 03|Hermes Agent 运行时:AIAgent、模型协议与工具执行
- 04|Hermes Agent 扩展体系:Skill、Plugin 与 MCP
- 05|实战:同一个 Markdown 检查器的 Skill、Plugin 与 MCP 三种实现
- 06|Hermes Agent 的持续学习系统:Memory、Review、Skill 与 Curator
- 07|Hermes Agent 的长会话架构:Prompt Cache、压缩与 SessionDB
- 08|Hermes Agent 的多 Agent 与自动化:从 Goal 到 Kanban
- 09|Hermes Agent 安全架构:审批、沙箱、回滚与凭据隔离
Electron Desktop:不是把网页套进一个壳
前面的入口层里,我只把 Desktop 画成一个方框,这确实省略了很重要的一层。Hermes Agent 的桌面端不是把 Dashboard 塞进 Electron,也不是在窗口里嵌入 TUI;它有自己的 React 聊天界面,而真正的 Agent 仍运行在 Python 进程里。桌面架构的关键不是“用了 Electron”,而是把本机控制、用户体验和 Agent 工作分给了三个权威边界。
| 权威边界 | 主要实现 | 负责什么 | 刻意不负责什么 |
|---|---|---|---|
| Electron Main | apps/desktop/electron/main.ts | 窗口、进程生命周期、本机文件与 Git、安装更新、Backend 启停 | 不执行 Agent Loop,不保存聊天 UI 状态 |
| Preload Bridge | apps/desktop/electron/preload.ts | 通过 contextBridge 暴露窄而有类型的本机能力 | 不把完整 Node.js / Electron API 交给网页上下文 |
| React Renderer | apps/desktop/src/ | 聊天、导航、流式消息、终端与预览 Pane、临时交互状态 | 不直接 import Python,也不拥有 Agent 语义 |
| Python Backend | hermes serve、tui_gateway/ | 会话、模型调用、工具、审批、事件流与持久化 | 不决定窗口布局和本机 UI 表现 |
flowchart LR
User[用户] --> UI[React 渲染进程
React Renderer]
UI --> Store[assistant-ui 与状态存储]
UI --> Preload[Preload 类型化桥]
Preload --> Main[Electron 主进程
Electron Main]
Main --> Native[文件 Git 窗口与更新]
UI <-->|基于 WebSocket 的 JSON-RPC
JSON-RPC over WebSocket| Serve[Python 服务进程
hermes serve]
Serve --> Gateway[TUI 网关
tui_gateway]
Gateway --> Agent[智能体运行时
AIAgent]
Agent --> Tools[工具与状态层]这张图里有两条完全不同的通道。Renderer 需要选择目录、读本机文件、操作剪贴板或创建原生通知时,走 window.hermesDesktop 暴露的 Preload IPC;需要创建会话、提交 Prompt、接收模型流和工具事件时,则绕过 Electron Main,直接通过 WebSocket 连接 Python Gateway。把两条通道分开,才能避免“所有调用都从 Electron 中转”的错误理解。
桌面能力为什么又能被 Agent 当成工具调用
这里还有一个看似反向的需求:终端 Pane 和预览 Pane 的真实状态在 Renderer,Python Agent 却可能调用 read_terminal、open_preview 或 focus_pane。Hermes 没有让 Backend 穿透进 Renderer 内存,而是把这类操作注册为仅 Desktop 会话可见的 desktop_ui Toolset,再用 Gateway 事件做一次请求—应答。
sequenceDiagram
participant A as 智能体运行时
AIAgent
participant T as desktop_ui 工具
participant G as TUI 网关
tui_gateway
participant R as React 渲染进程
React Renderer
participant P as 终端或预览 Pane
A->>T: 调用 read_terminal
T->>G: 终端读取请求
terminal.read.request
G-->>R: WebSocket 事件
WebSocket event
R->>P: 读取当前 Pane 状态
P-->>R: 文本与元数据
R->>G: 终端读取响应
terminal.read.respond
G-->>T: 完成等待中的请求
T-->>A: 标准 Tool Result桌面工具是否出现,判断依据是会话的 SessionSource,而不是简单检查 Python 进程有没有 HERMES_DESKTOP 环境变量。这一点很重要:当 Backend 在 SSH 主机或云端运行时,当前会话仍然可能来自 Desktop;UI 工具应该回到发起会话的桌面 Renderer,而 Terminal、文件和代码执行则应该留在远端运行环境。
Electron 如何启动并连接本地 Python
如果只用一句话概括:Electron Main 找到可用的 Hermes CLI,启动 hermes serve 子进程;React Renderer 再通过 JSON-RPC/WebSocket 连接它。这里没有 Electron 原生模块直接链接 CPython,也没有把 Python 函数暴露成 IPC 方法。
本地 Backend 的发现与启动
backend-command.ts 会按候选梯子解析 Backend:显式指定的源码根目录、开发态当前 checkout、已经完成的托管安装、用户指定或 PATH 中的 hermes、可以 import Hermes 的系统 Python,最后才是安装引导。候选项不是“找到文件就算成功”,还要经过能力探测;旧运行时不支持 serve 时,才兼容回退到 dashboard --no-open。
flowchart TD
Start[Electron 启动] --> Resolve[解析 Backend 候选梯子]
Resolve --> Probe[探测 CLI 与 serve 能力]
Probe --> Spawn[启动 hermes serve]
Spawn --> Bind[绑定 127.0.0.1 端口 0]
Bind --> Ready[读取 ready 信息与实际端口]
Ready --> HTTP[HTTP 就绪探测]
HTTP --> WS[WebSocket 握手探测]
WS --> Connect[Renderer 建立 JSON-RPC 连接]
Probe -->|仅旧版本兼容| Legacy[兼容旧版仪表板
dashboard --no-open]规范启动参数是 hermes serve --host 127.0.0.1 --port 0,可再附加 Profile。端口设为 0 让操作系统分配临时空闲端口,Electron 从 ready 文件或子进程输出得到实际端口,完成 HTTP 和 WebSocket 两级探测后才把连接交给 Renderer。启动时还会注入独立的 HERMES_HOME、工作目录、会话令牌、父进程标记等环境;退出时则终止整个进程组,避免 MCP 等孙进程残留。
一条消息怎样从 React 走进 AIAgent
共享包里的 JsonRpcGatewayClient 把请求编码成标准 JSON-RPC 2.0:{ jsonrpc, id, method, params }。会话创建和恢复分别调用 session.create、session.resume,用户发送消息调用 prompt.submit,停止生成调用 session.interrupt。Backend 的普通响应按 id 兑现 Promise,流式内容则通过名为 event 的通知持续推给 Renderer。
sequenceDiagram
participant U as React 输入框
participant C as JSON-RPC 网关客户端
JsonRpcGatewayClient
participant W as WebSocket 通道
/api/ws
participant G as TUI 网关
tui_gateway
participant A as 智能体运行时
AIAgent
participant X as 工具执行器
Tool Executor
U->>C: 提交用户消息
C->>W: 提交提示
prompt.submit
W->>G: JSON-RPC 分发
G->>A: 运行对话
run_conversation
A->>X: 可选工具调用
X-->>A: 工具结果
A-->>G: token、reasoning、tool 事件
G-->>C: event 通知流
C-->>U: 增量刷新消息与状态动画:一次 Electron 请求如何走完 Agent Turn
上面的时序图适合快速定位参与者,下面这个动画则把“输入什么、走什么协议、模块做什么、返回什么”放在同一条路径里。点击任一节点可以暂停并查看该阶段;工具分支是可选的,模型直接回答时会跳过它。
“连接本地 Python”其实有四种不同含义
| 场景 | 连接方式 | 适合解决什么 | 是否为 Desktop 主路径 |
|---|---|---|---|
| 桌面端连接 Agent Backend | Electron 启动 hermes serve;Renderer 走 WebSocket JSON-RPC | 会话、模型、工具和事件流 | 是 |
| 模型用 Python 编排工具 | execute_code 启动脚本,通过生成的 hermes_tools.py RPC 回调 Registry | 循环、过滤、批处理,减少多轮 Tool Call | 是 Agent 工具路径之一 |
| 业务程序嵌入 Agent | Python 代码直接 import 并创建 AIAgent | 服务端集成、测试或自定义宿主 | 否,这是程序化集成路径 |
| 运行普通 Python 脚本 | Terminal 工具执行 python script.py | 项目脚本、数据处理、构建任务 | 只是 Terminal 的一种命令 |
execute_code 如何让本地 Python 反过来调用 Hermes 工具
这条路线叫 Programmatic Tool Calling。模型不是连续产生十几个离散 Tool Call,而是写一段 Python,让脚本 import 自动生成的 hermes_tools.py。Stub 把 web_search、read_file、patch、terminal 等受控工具请求发回父 Agent,父进程仍通过 Registry、审批和实际执行后端完成调用。脚本中间结果留在 Python 进程里,最终只有标准输出回到模型上下文。
flowchart LR
Model[模型生成 Python] --> Runner[代码执行运行器
execute_code Runner]
Runner --> Script[隔离的 Python 脚本]
Script --> Stub[生成的 hermes_tools.py]
Stub --> Channel{RPC 通道}
Channel -->|本机 POSIX| UDS[Unix 套接字
Unix Socket]
Channel -->|本机 Windows| TCP[回环 TCP
Loopback TCP]
Channel -->|远程环境| FileRPC[文件 RPC]
UDS --> Registry[父进程 Tool Registry]
TCP --> Registry
FileRPC --> Registry
Registry --> Result[结果返回脚本]
Script --> Stdout[仅 stdout 回到模型]当前实现中,本地 POSIX 优先用 Unix Domain Socket,Windows 使用回环 TCP;Docker、SSH 等远程环境则使用可搬运请求与结果的文件 RPC。这个设计的重点不是让任意 Python 绕过安全边界,而是把“多步工具编排”移到代码层:可调用工具仍取会话允许集与沙箱白名单的交集,父 Agent 仍然是唯一的工具执行权威。
参考资料与源码入口
- 官方源码核验基线:59795c40
- 官方 Desktop 文档
- Programmatic Integration
- ACP Host Integration
- ACP Internals
- Desktop Backend 解析源码
题图来源:《海、地平线与帆船》,创作者 PublicDomainPictures,来自 Pixabay;依据 Pixabay Content License(Pixabay 内容许可)使用。