Build: <fcntl.h> in tui.c, format-truncation in http_srv.c and /web. WebUI: - refuse cross-site requests (Origin must be the page itself) in the default no-token mode: any page could POST approval=yolo and a prompt - session/workspace/undo/compact/attach/model changes are 409 while a turn runs (use-after-free, messages landing in the wrong session) - ask_user returned "dismissed" at once instead of waiting - cancel and shutdown wake a turn parked on a permission or question; SIGTERM mid-turn no longer hangs or crashes - a page opened while a question is pending still gets it; Last-Event-ID resumes the stream; event seq matches the SSE id - token mode: page assets load without the token, images via cookie - --web-open works, --web-port/--web-token are validated - frontend: gate keys ignored while typing, clear-images button, reasoning no longer wiped while streaming HTTP server: auth and size cap before reading the body, strict Content-Length, NUL in url_decode, 413 on keep-alive, idle timeout, absolute-form only without a leading '/', case-insensitive Connection, header deadline, 400 for long request lines, unlink before close, poll() instead of select(), SO_EXCLUSIVEADDRUSE on Windows. Markdown sanitizer escapes quotes in attribute values. /web: passes this session's provider/model/base/effort/approval (key via the environment), keeps output off the TUI, shows the real URL and keeps CURRENT on the TUI's session. Tests: scripts/e2e_webui_ask.py, scripts/e2e_webui_http.py (run from e2e_webui.sh), sanitizer quote test.
11 KiB
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);带 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 |
需要回答(见下) |
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}。
cached_tokens / cache_miss_tokens 来自上游的 prompt cache 统计,前端用它算命中率。
请求
| 方法 | 路径 | 说明 |
|---|---|---|
| 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?,provider?,effort?,approval?,plan_mode?} |
| 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(上传/清空)以及改 model/provider/effort 的 config
一律 409;approval/plan_mode 可以中途改。
客户端接入示例(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"。
界面本身:左栏可收(顶栏按钮 / ?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 可覆盖);到点后下一轮
开始前先总结历史,而不是等到塞满。
测试
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
手工验收清单(浏览器):
- 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
- 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带
exit=标记。 - 切到
ask审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。 会话按钮:新建/切换后历史正确重建(刷新页面也一样)。- 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。
- 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
限制与已知问题
- 同一会话同时只能有一轮(
POST /api/turn返回 409);TUI 与 WebUI 目前是 二选一(--web时不启动 TUI),同进程并存留待后续的 hooks 分发层。 - 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
- 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
- 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始,
这时应当重新拉一次
/api/session。