v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests
A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on a worker thread, the hooks publish events into a ring buffer that SSE clients read, so the engine itself needs no changes. - src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect - src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming, body caps (413), Expect: 100-continue, connection cap, exact routing - src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML (tag/attribute allowlist, javascript: URLs and off-site images dropped) - src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes, token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no client is attached - src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI - web/: no-build front end (transcript, approvals, todo, sessions, image upload, status bar) - docs/webui.md: engine API v1 - events, requests, security model - tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run driven by a recorded SSE stream (no API key needed) - src/main.c: --web --web-port --web-host --web-token --web-dev --web-open and CST_WEB_PORT/HOST/TOKEN/ROOT An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI.
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
# 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) |
|
||||
| `--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 条,超出后从可用的最老一条开始。
|
||||
* 心跳是 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` | 需要回答(见下) |
|
||||
| `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,cost,priced,currency}`。
|
||||
|
||||
### 请求
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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/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?,provider?,effort?,approval?,plan_mode?}` |
|
||||
| POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
|
||||
| POST | `/api/compact` | 手动压缩上下文 |
|
||||
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤16 MiB),进入"待发送"列表 |
|
||||
| DELETE | `/api/attach` | 清空待发送图片 |
|
||||
| GET | `/api/attach/<rel>` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) |
|
||||
| POST | `/api/quit` | 关闭进程 |
|
||||
|
||||
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
|
||||
|
||||
### 客户端接入示例(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"。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
|
||||
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API 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`。
|
||||
Reference in New Issue
Block a user