数据截至 (上游 commit 1916c9046c4e)
顶层全景:本地栈、ingress 与前端分层
这章给你「大盘」:一条
agent-canvas命令到底拉起了哪几个进程、浏览器的一个动作怎么穿过各层到达 Agent Server、以及这套代码有哪几种部署形态。读之前请先看过index.md的 pivot 说明:agent 循环不在这个仓库,本章所有「后端」都是外部 pip 包。
1. 本地栈:一条命令拉起 四个进程
agent-canvas CLI(bin/agent-canvas.mjs)是 npm 包暴露的唯一命令(package.json:16-18)。它做的事很薄:检查一下构建产物存在,然后把活全交给启动器脚本(bin/agent-canvas.mjs:151-167 导入 scripts/dev-with-automation.mjs 的 main 并以 static 模式调用)。
main 拉起的进程组(scripts/dev-with-automation.mjs:1544-1596):
浏览器
│ http://localhost:8000
▼
┌──────────────────────┐
│ ingress 代理 :8000 │ 唯一对外入口
└──────────────────────┘
│ 按路径前缀分流(见 §2)
┌─────┼─────────────────┐
▼ ▼ ▼
静态前端 Agent Server Automation 后端
:3001 (uvx) :18000 (uvx) :18001
(build/ 真正跑 agent 定时/webhook
的 SPA) 循环的 pip 包 触发器的 pip 包
三个要点:
- 两个后端都是
uvx拉起的 pip 包,版本锁在config/defaults.json:4-7(agent-server 1.42.1、automation 1.8.0)。agent-server 的启动命令由buildAgentServerCommand拼出(scripts/dev-with-automation.mjs:913-914),automation 同理(scripts/dev-with-automation.mjs:963-982的startAutomationBackend)。 - 端口默认值也锁在同一文件:
config/defaults.json:21-27(ingress 8000、agent-server 18000、automation 18001)。 - 启动有先后:
main先起 agent-server 并轮询它的/server_info直到就绪(scripts/dev-with-automation.mjs:1553-1561的waitForService),再把会话密钥写进它的 secrets 存储,最后才起 automation、前端、ingress。
一把钥匙开两把锁。 启动器生成一个 session API key,同时喂给两个后端:agent-server 通过环境变量 OH_SESSION_API_KEYS_0 拿到(scripts/dev-with-automation.mjs:936),automation 拿到同一把(scripts/dev-with-automation.mjs:1041 的 AUTOMATION_LOCAL_API_KEY)。两边都认 X-Session-API-Key 请求头。这把钥匙还会被 seedAutomationSecret 写进 agent-server 的 secrets 存储(scripts/dev-with-automation.mjs:1215-1229),让会话里的 agent 自己也能调 automation API。
2. ingress:一个端口,按路径前缀分流
浏览器只跟一个源(origin)打交道。ingress 是个极简反向代理,按最长路径前缀匹配转发(scripts/ingress.mjs:18-22 的路由说明)。分流表由启动器生成(scripts/dev-with-automation.mjs:729-781):
| 路径前缀 | 转发到 |
|---|---|
/api/automation | Automation 后端 :18001 |
/api、/sockets、/server_info、/health 等 | Agent Server :18000 |
其余(/*) | 前端(Vite dev server 或静态构建) |
这个设计的直接收益:前端代码里不需要写死后端地址。生产模式下前端的 base URL 就是 window.location.origin(src/api/agent-server-config.ts:181-190 的 getAgentServerBaseUrl),一切相对路径都由 ingress 送到正确的地方。
密钥怎么进前端? 静态服务器在返回 index.html 时把 session key 注入一个 window 全局变量;前端启动时从两处之一读它:构建期烧进去的 VITE_SESSION_API_KEY,或运行期注 入的 window.__AGENT_CANVAS_SESSION_API_KEY__(src/api/agent-server-config.ts:119-132 的 getBakedSessionApiKey)。--public 模式则不注入,用户首次打开页面时手动粘贴密钥(bin/agent-canvas.mjs:71-74 的帮助文本)。
3. 前端内部:四层,每层只做一件事
前端是 React 19 + React Router 7 应用(package.json:49-55)。从 UI 到网络,一次调用穿过四层:
React 组件(只渲染)
│ 调用
▼
TanStack Query hook(缓存/去重/加载态)
│ 调用
▼
src/api 服务层(拼参数、选后端、双路由)
│ 用
▼
@openhands/typescript-client(typed HTTP 客户端)
│ HTTP / WebSocket
▼
Agent Server(外部 pip 包)
两条硬约定,都写在 src/api/README.md 里:
- 服务层是 UI 与后端 API 之间唯一的抽象层(
src/api/README.md:5),每个 service 是「一个 plain object + async 方法」,命名固定为feature-service/feature-service.api.ts(src/api/README.md:91-98)。 - 组件不许直接调 service,必须包一层 TanStack Query hook——换来缓存、去重、加载态(
src/api/README.md:63-76)。
服务层与后端之间还有一个固定版本的中介:@openhands/typescript-client(package.json:27 锁 1.38.1),它是 Agent Server REST API 的官方 typed 客户端(ConversationClient、FileClient、BashClient 等)。每个 service 调它之前先问一句「当前该连哪个后端、用什么密钥」——这就是 getAgentServerClientOptions(src/api/agent-server-client-options.ts:52-69):它从后端注册表取当前活动 local 后端的 host 与 apiKey,拼成连接参数;没有可用后端就抛 NoBackendAvailableError(src/api/agent-server-client-options.ts:22-27)。后端注册表本身很大,是第 03 章的主题。
4. 一条请求的完整流向(以「列 出会话」为例)
把 §3 走一遍真实代码。你在首页打开会话列表:
- 组件挂载,TanStack Query hook 触发
AgentServerConversationService.searchConversations(src/api/conversation-service/agent-server-conversation-service.api.ts:747-764)。 - service 先看当前后端种类:cloud 走云代理,local 走 typed client(
src/api/conversation-service/agent-server-conversation-service.api.ts:751-752的分支)。 - local 分支 new 一个
ConversationClient,连接参数来自getAgentServerClientOptions(src/api/conversation-service/agent-server-conversation-service.api.ts:755-756)。 - typescript-client 发 HTTP
GET /api/conversations/search,带X-Session-API-Key头。 - ingress 按前缀
/api转给 agent-server :18000。 - 响应回来后,service 用
toConversationPage把 wire 格式适配成 UI 模型(src/api/agent-server-adapter.ts:404-412),hook 缓存结果,组件渲染。
「看后端种类再决定走哪条路」这个双路由(local typed client / cloud 代理)模式在几乎每个 service 里都会出现,是读 src/api/ 代码时最先要认识的模式,细节见第 03 章。
5. 四种部署形态
同一份前端代码,以四种方式交付(docs/architecture.md:58-66 也 列了打包形态):
| 形态 | 入口 | 适合谁 |
|---|---|---|
| npm CLI | bin/agent-canvas.mjs:161-167(static 模式起全套栈) | 大多数人,npm install -g 即用 |
| Docker 全一体镜像 | docker/Dockerfile:5-16(agent-server + automation + 前端 + ingress 一容器) | 想要文件系统隔离的笔记本用户 |
| Electron 桌面壳 | electron/main.mjs:1-8(内置全套栈,uvx 都打包进去) | 不想碰命令行的用户 |
| 库构建 | package.json:206-248 的 exports(把 conversation/files/terminal 等组件当库用) | 把 Canvas 嵌进自己应用的宿主 |
开发模式下还有几种变体(docs/architecture.md:47-56):npm run dev 起全栈(Vite dev server 代替静态前端)、dev:minimal 不起 automation、dev:mock 用 MSW mock 后端。
安全提示值得单独说一句。 本地形态( npm / 源码)意味着 agent-server 直接跑在你的机器上,agent 拥有你整个文件系统的权限——README 在「Without a Sandbox」选项上挂了显眼的 WARNING(README.md:63-66)。想要隔离就用 Docker 形态,或把后端放到远程机器/云上。这就是 pivot 后「沙箱」的落点:隔离是部署决策,不是本仓库的运行时机制(第 03 章展开)。
6. 本章小结 + 下一步
- 本地栈 = 前端 + ingress + 两个
uvx拉起的 pip 包后端;ingress 按路径前缀分流。 - 一把 session API key 贯穿启动器、两个后端、前端注入。
- 前端四层:组件 → TanStack Query hook →
src/api服务层 → typescript-client。 - 接下来:第 02 章顺着「建一个会话」把服务层最厚的一段走完;第 03 章讲后端注册表与运行时抽象。