Files
CST-agent/docs/webui.md
T
WHC 6e81513e36 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.
2026-10-09 10:20:09 +08:00

8.8 KiB
Raw Blame History

WebUI(内置 HTTP + SSE 前端)

cst-agent --web 让同一个进程同时提供命令行以外的前端:浏览器(或任何 HTTP 客户端) 连上后能看到流式回答、工具卡片、审批弹窗和任务清单。它是 TUI 之外的第二个前端, 引擎没有为它改一行——两边用的都是 cst_agent_hooks_t 这组回调。

这份文档同时是引擎 API v1 的规范:以后专门客户端(Tauri / Qt / 移动端)直接照它实现, 不需要读 C 代码。

快速开始

# 默认只监听 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 行)

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"。

测试

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。