Files
Hermit/docs/webui.md
T
WHC 2d1052fd5f
ci / linux (clang) (push) Successful in 5m56s
ci / linux (gcc) (push) Successful in 8m38s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 9m21s
v0.8.3: WebUI models, providers and keys; garbled thinking fixed
Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

src/provider_switch.c: model list fetch/cache, key verify-and-save and
provider switching moved out of tui.c and shared by TUI and WebUI.

WebUI API:
- GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected
  key, still listing built-ins), POST /api/provider, POST /api/key
  (key checked against /v1/models, saved to credentials 0600, never
  echoed in replies, events or logs)
- /api/config validates everything before applying, persists model,
  effort and approval (not yolo), warns when effort does not apply;
  plan_mode no longer forces read-only
- state: turn_usage, api_base, key_set/key_from, model_info,
  effort_applies, sees_images, max_steps, tool_profile, perm_timeout;
  permission events carry their timeout

Frontend: searchable model picker with refresh and manual entry,
provider picker and key dialog, effort/vision hints, toasts instead of
silent failures, Chinese error text, real protocol in the settings and
usage panels, approval countdown from the server's timeout.

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
2026-10-09 15:54:53 +08:00

214 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WebUI(内置 HTTP + SSE 前端)
`cst-agent --web` 让同一个进程同时提供命令行以外的前端:浏览器(或任何 HTTP 客户端)
连上后能看到流式回答、工具卡片、审批弹窗和任务清单。它是 TUI 之外的第二个前端,
**引擎没有为它改一行**——两边用的都是 `cst_agent_hooks_t` 这组回调。
这份文档同时是**引擎 API v1 的规范**:以后专门客户端(Tauri / Qt / 移动端)直接照它实现,
不需要读 C 代码。
## 快速开始
```bash
# 默认只监听 127.0.0.1:8787
cst-agent --web
# 指定端口;0 = 让系统分配(启动日志里会打印实际 URL)
cst-agent --web --web-port 8787
# 让手机/另一台机器访问(必须给 token,否则拒绝启动在非 loopback 上)
cst-agent --web --web-host 0.0.0.0 --web-token 我自己定的口令
# 开发前端:直接从目录读 web/,改完刷新页面即可,不用重新编译
cst-agent --web --web-dev ./web
# 启动后自动打开浏览器
cst-agent --web --web-open
# 启动即跑一轮(不点页面也能用)
cst-agent --web -p "看看这个仓库有什么问题"
```
启动日志:
```
webui: http://127.0.0.1:8787/
webui: token 3f9a… (append #token=3f9a… to the URL)
```
带 token 时把 `#token=…` 贴到浏览器地址栏后面即可(`#` 之后不会发给服务器,
页面会用 `?token=` 再请求一次)。
环境变量:`CST_WEB_PORT`、`CST_WEB_HOST`、`CST_WEB_TOKEN`、`CST_WEB_ROOT`。
退出:终端里 Ctrl+C,或 `POST /api/quit`。
## 安全模型
| 场景 | 行为 |
|---|---|
| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding);带 `Origin` 的请求必须来自本页面自己(`http://<Host>`),否则 401 —— 防止任意网页跨站 POST 驱动 agent |
| 静态资源 | 页面本身与 `app.js`/`style.css` 不需要 token;`/api/attach/<rel>` 图片仍要(页面会把 token 写进 `SameSite=Strict` cookie) |
| 请求解析 | 鉴权与大小检查在读请求体**之前**完成;`Content-Length` 必须是纯数字且只有一个值;请求头整体 15 s 内收完 |
| `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token |
| token 传递 | `Authorization: Bearer <t>`、`Cookie: cst_token=<t>` 或 `?token=<t>`(`EventSource` 不能设 header,所以查询参数是必需的一条路) |
| 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) |
| 输出渲染 | 模型与工具输出都当不可信内容:Markdown 由服务端渲染,并经过标签/属性白名单(`javascript:` 链接、站外图片、`<script>` 全部丢弃) |
| 请求体 | 默认 1 MiB,图片上传 32 MiB;超限 413 |
**不要**把 WebUI 直接暴露到公网:没有 TLS,token 会以明文出现在网络里。需要远程访问时
放在反向代理(https)后面,并确认 `X-Accel-Buffering: no` 之类的缓冲设置不会掐断 SSE。
## 引擎 API(协议 `cst-webui/1`)
所有响应都是 JSON(`Content-Type: application/json`)。`GET /api/state` 的
`protocol` 字段给出版本号,客户端应当先看它再决定支持哪些端点。
### 事件流
```
GET /api/events?since=<seq> → text/event-stream (chunked)
```
* 每条事件都有 `id: <seq>`(单调递增)与 `data: {"type":…,"seq":…, …}`。
* 断线重连带上 `since=<最后收到的 seq>`(浏览器用 `Last-Event-ID` 也行),
服务端保留最近 2000 条,超出后从可用的最老一条开始;比当前 seq 还大(服务端重启过)
就当作新连接。
* 新连接(不带 `since`)只收之后的事件;但若有尚未回答的 `permission`/`ask_user`,
会从那条事件开始补发,刷新页面不会丢掉正在等的问题。
* 心跳是 SSE 注释 `: ping`(每秒一次,客户端忽略)。
* `state` 事件在状态变化时整份重发,客户端可以直接替换本地状态。
事件类型:
| type | 字段 | 说明 |
|---|---|---|
| `state` | 见下表 | 会话/模型/审批/todo/用量快照 |
| `status` | `message` | 引擎自己的一句话状态("calling model (step 2)…") |
| `assistant_html` | `text` | **已渲染且已消毒的 HTML 片段**,按顺序追加 |
| `assistant_delta` | `text` | 原始文本增量(逐字节真相,做复制/导出时用) |
| `think_delta` | `text` | 推理过程增量(Thought) |
| `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`done` |
| `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` |
| `todo` | `text` | `todo_write` 替换后的清单(一行一项) |
| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox`,`timeout` | 需要回答(见下);`timeout` 秒后按拒绝处理 |
| `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) |
| `ask_user` | `id`,`question`,`options[]` | 需要回答 |
| `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` |
| `turn_end` | `rc`,`error` | 本轮结束(`rc=0` 正常,非 0 见 `error`) |
`state` 字段:`workspace` `session` `provider` `model` `effort` `approval`
(`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo`
`context_tokens` `undo_available` `pending_images[]` `usage{requests,prompt_tokens,
completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
`turn_usage{…}`(上一轮,字段同 `usage`)`api_base` `key_set` `key_from`
(`saved` / 环境变量名 / `session` / 空,**从不含 key 本身**)
`model_info{label,context,vision,reasoning,source,price?}` `effort_applies`
`sees_images`(1 / 0 / -1 未知)`max_steps` `tool_profile` `perm_timeout`。
`cached_tokens` / `cache_miss_tokens` 来自上游的 prompt cache 统计,前端用它算命中率。
`think_delta` / `assistant_delta` 的 `text` 总是完整的 UTF-8:上游在多字节字符中间
断开时,残缺的尾巴留到下一块再发。
### 请求
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/state` | 上面那份快照 |
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致;assistant 消息额外带 `html`) |
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
| POST | `/api/sessions/new` | `{}` → `{id}` |
| POST | `/api/sessions/switch` | `{"id":"…"}` |
| POST | `/api/workspace` | `{"path":"/abs/dir"}` —— 换工作目录(权限规则/埋点/AGENTS.md 跟着换),并**在新目录里开一个新会话**(会话是按目录存的);返回 `{id}`,坏路径 → 400 |
| POST | `/api/turn` | `{"text":"…"}` → 202 `{ok:true}`;已有轮次在跑时 **409** |
| POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程);挂起的权限问题按拒绝、提问按忽略处理 |
| POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix |
| POST | `/api/ask_user` | `{"id":N,"text":"…"}` 或 `{"id":N,"dismiss":true}` |
| POST | `/api/config` | `{model?,effort?,approval?,plan_mode?}`;先整体校验再生效,model/effort/approval(`yolo` 除外)写进 `settings.json`;当前模型不吃 effort 时回 `warning`;带 `provider` → 400(改用 `/api/provider`) |
| GET | `/api/providers` | `{current, providers:[{name,api_base,model,has_key,key_from,key_env,source,current}]}` |
| GET | `/api/models[?refresh=1]` | 当前提供商的模型:`{provider,current,api_base,key_set,api_count,models:[{id,api,label,context,vision,reasoning,source,price?}]}`;先用缓存,过期/缺失/`refresh=1` 时向 `/v1/models` 拉;key 被拒 → 401(仍带内置模型和 `auth:true`),其它失败放在 `api_error` |
| POST | `/api/provider` | `{"name":"…","key":"…"?}`:切换提供商。缺 key → 400 `{need_key:true}`;key 被拒 → 401;没这个提供商 → 404;成功 → `{ok,provider,model,warning?}`,key 验证后存进 `credentials`(0600) |
| POST | `/api/key` | `{"key":"…"}`:换当前提供商的 key,验证与回复同上 |
| POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
| POST | `/api/compact` | 手动压缩上下文 |
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤32 MiB),进入"待发送"列表 |
| DELETE | `/api/attach` | 清空待发送图片 |
| GET | `/api/attach/<rel>` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) |
| POST | `/api/quit` | 关闭进程 |
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`workspace`、
`undo`、`compact`、`attach`(上传/清空)、`provider`、`key` 以及改 `model/effort` 的
`config` 一律 **409**;`approval`/`plan_mode` 可以中途改。
key 只出现在请求体里:回复、事件、日志都不会带它。`/api/provider` 与 `/api/key`
跟其它 `/api/*` 一样受 loopback 同源检查或 token 保护。
### 客户端接入示例(10 行)
```python
import json, urllib.request
base = "http://127.0.0.1:8787"
def api(path, body=None, token=None):
req = urllib.request.Request(base + path, method="POST" if body is not None else "GET")
if token: req.add_header("Authorization", "Bearer " + token)
data = json.dumps(body).encode() if body is not None else None
return urllib.request.urlopen(req, data).read()
api("/api/turn", {"text": "hello"}) # 之后读 /api/events 收流
```
### 空 body 的 POST
`POST /api/cancel`、`/api/undo`、`/api/compact`、`/api/quit` 不需要 body。
没有 `Content-Length` 头的 POST 按 RFC 9112 §6.3 视为**空 body**(`curl -X POST`
正是这样发的),但该连接不再复用(服务端随后关闭它):后面是否跟着未声明的
字节无法判断。
## 前端实现(web/)
没有构建步骤:`web/index.html` + `web/app.js` + `web/style.css` 是纯 ES module,
编译时被 `scripts/embed_web.sh` 塞进二进制(`.gen/web_assets.inc`,跟内置模型数据同一招),
所以 Windows 发布包依旧是单个 exe。`--web-dev DIR` 可以绕过内嵌版本直接读目录。
Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),客户端只贴 HTML;
因此不需要引入任何 JS 依赖,也顺带保证了"只发送已确认的 HTML"。
界面本身:左栏可收(顶栏按钮 / `?side=0`),右栏可收(`?panel=0`),主题 `?theme=dark|light`;
三个开关都会记进 `localStorage`,带查询参数时以参数为准。空状态只留一句提示,第一轮
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目录> --new`),
日志在 `<workspace>/.agent/webui.log`:TUI 不能被 `cst_webui_run()` 的阻塞循环占住,
而且两个进程同时写一个会话文件会把它写坏,所以子进程总是开新会话。
自动压缩的门槛是模型上下文窗口的 **75%**(`CST_CTX_BUDGET` 可覆盖);到点后下一轮
开始前先总结历史,而不是等到塞满。
## 测试
```bash
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key
# (含 e2e_webui_settings.py:假 /v1/models、换提供商/key、中文思考流)
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl ctl
```
手工验收清单(浏览器):
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样)。
5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
## 限制与已知问题
* 同一会话同时只能有一轮(`POST /api/turn` 返回 409);TUI 与 WebUI 目前是
**二选一**(`--web` 时不启动 TUI),同进程并存留待后续的 hooks 分发层。
* 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
* 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
* 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始,
这时应当重新拉一次 `/api/session`。