Files
Hermit/docs/webui.md
T
WHC 9bf7707912
ci / linux (clang) (push) Successful in 6m15s
ci / linux (gcc) (push) Successful in 7m14s
ci / musl (push) Successful in 7m14s
ci / windows-cross (push) Successful in 11m4s
v0.8.5: WebUI process timeline, Gemini-style images, aligned layout
Process timeline: a run of consecutive thinking and tool calls is one
block that folds to a single line ("思考 4 次 · 工具 6 次"; while running
it shows the current action) and opens into a compact timeline, one row
per thought or call. Visible text, a note or the next prompt closes the
block; history and the live stream follow the same rule. show_thinking
now also applies while streaming instead of forcing thoughts open.

Layout: the avatar gutter and the flex-wrap that dropped it under long
answers are gone, so answer, process blocks, notes and composer share one
left edge. The text column and composer are both 720px (--content); the
rail and run panel grow with clamp() on wide screens. A rebuild after
each turn no longer replays every entrance animation, and the turn time
survives it. Message actions are icon buttons and only appear under text.

Images: a prompt's images sit above the bubble, right-aligned (one keeps
its shape, several are 152px rounded tiles) and open in a lightbox (Esc,
arrow keys). Images uploaded from the page show as thumbnails over the
composer and appear on the prompt as soon as it is sent.

Also: ?theme= is no longer overridden by app.js, the user bubble is
visible in the light theme, and the narrow top bar no longer wraps.
2026-10-10 14:54:36 +08:00

21 KiB
Raw Permalink 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);带 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/progress/done;progress 是子代理的最近几步(detail 形如 explore · grep foo · read_file x),可以出现多次
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 show_thinking(思考过程默认展开,存在 settings.json,和 TUI 的 /thinking 是同一个开关)。 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 一致,另加的字段见下)
GET /api/sessions {current, sessions:[{id,title,messages,mtime}]}
POST /api/sessions/new {} → {id}
POST /api/sessions/switch {"id":"…"}
POST /api/sessions/rename {"id":"…"?,"title":"…"}(不带 id = 当前会话);标题按字符截到 120 字节,空串 = 恢复默认(第一句提问);没这个会话 → 404
POST /api/sessions/delete {"id":"…"} → {ok,current};删的是当前会话时先开一个新会话(并发 session 事件);没这个会话 → 404
POST /api/retry {} = 重新生成最后一轮;{"text":"…"} = 把最后一句提问换成新文字再跑(编辑重发)。先把最后一轮从会话里退掉,再开新一轮 → 202 {ok,text};没有可重来的轮次 / 轮次在跑 → 409;空串或非字符串 → 400
GET /api/export 当前会话的 Markdown(text/markdown,与 TUI /export 同一格式)
GET /api/diff[?path=p] 工作区的真实 diff:{git,path,files:[{status,path}],diff,truncated}。files 来自 git status --short(含未跟踪),diff 是 git diff(未暂存部分,限 512 KiB);不是 git 仓库 / 没装 git → git:false;10 s 超时
GET /api/settings 运行设置:protocol,protocols[],max_steps,tool_profile,profiles[],confirm{shell,write,net},approval,tools[{name,perm,on,desc,…}],sandbox{kind,active,labels?}
POST /api/settings {protocol?,max_steps?,tool_profile?,tools?:{名字:bool},confirm?:{shell?,write?,net?}};整体校验后才生效(任何一项不对 → 400,什么都不改),成功后发 state
GET /api/permissions 已保存的"总是允许"规则:{text,tools[],shell_prefixes[]}(来自 <workspace>/.agent/permissions.json)
DELETE /api/permissions 清空这些规则
POST /api/init {} → 202 {ok,text}:开一轮,让 agent 分析仓库写 AGENTS.md(与 TUI /init 同一段提示词)
POST /api/skill {"name":"…","task":"…"?} → 202 {ok,text}:把 skill 正文内联进这条消息再开一轮(同 TUI /skill);没有这个 skill → 404;缺 name / 超过 63 字节 / task 不是字符串 → 400
GET /api/extensions {enabled,trusted,report,task_tool,mcp:[{name,source,command,state,tools,error}],plugins:[{name,source,desc,tools,mcp,skills,error}],skills:[{name,source,desc}],roles:[{name,source,desc,model,read_only}],runs:[{file,role,desc,status,steps,tools,ms}]};state = starting/ready/failed/exited/off,runs 是当前会话的子代理运行
POST /api/extensions/reload 停掉 MCP 服务器和插件,重新读取它们和 skills → {ok,trusted,report}
POST /api/extensions/trust {"on":true|false}:信任(或取消)当前工作区——决定它的 .agent/plugins、.agent/mcp.json 是否加载;写进 settings.json 后重新加载,回复同上
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?,show_thinking?};先整体校验再生效,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、sessions/delete、 workspace、undo、compact、attach(上传/清空)、provider、key、retry、diff、 POST /api/settings、DELETE /api/permissions、init、skill、extensions/reload、 extensions/trust 以及改 model/effort 的 config 一律 409; approval/plan_mode/show_thinking 可以中途改,sessions/rename、export 与 GET /api/extensions 在轮次进行中也能用。

/api/session 的每条消息在会话文件的字段之外还带:

字段 出现在 说明
html assistant 已消毒的 Markdown 渲染结果(文本协议时只渲染说明文字,不含调用帧)
text 文本协议的 assistant 去掉调用帧后的说明文字(content 仍是原文)
calls 带工具调用的 assistant [{id,name,args}]:原生协议来自 tool_calls,文本协议从 content 解析
tool_result 工具结果 true;原生协议 role 是 tool,文本协议 role 是 user(凭 tool_call_id 区分)
out,exit,truncated 工具结果 去掉 [tool …] / result … end 外框后的输出(截到 32 KiB,按字符边界)与退出码
note 引擎插入的提示 true;如中途切换计划模式时的 [cst] … 说明,不是用户说的话

客户端据此在刷新后重建"本轮工具调用"列表:calls 建行,tool_call_id 相同的结果填进去。

key 只出现在请求体里:回复、事件、日志都不会带它。/api/provider 与 /api/key 跟其它 /api/* 一样受 loopback 同源检查或 token 保护。

客户端接入示例(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>,避免首帧闪一下再收起。

对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 720px(--content); 左右两栏随屏宽在 clamp() 范围内变宽,超宽屏下中间列不会无限拉开。

过程时间线:一段连续的思考和工具调用合成一个过程块,折叠时只有一行 ("思考 4 次 · 工具 6 次";运行中显示当前动作,比如"执行 run_shell…"),展开后是一条 细竖线串起的时间线,每次思考、每个工具调用各占一行,思考只露第一行,点开看全文。 出现可见的正文(或引擎提示、下一句提问)时过程块就此结束,后面的思考/工具另起一块—— 历史重建(/api/session)和实时事件流用的是同一条规则,所以刷新前后看到的一样。 /thinking on|off(show_thinking)决定过程块和其中的思考默认展开还是收起,流式输出时也照此执行; /details 只管工具行是否展开。

图片:提问附带的图片显示在气泡上方、靠右,一张保持原比例(最长 260px),多张是 152px 的圆角方块;点开是大图预览(Esc 关闭,←/→ 切换)。从本页上传/粘贴/拖入的图片在 输入框上方显示缩略图,发送后立刻出现在新提问上(本地预览,本轮结束后换成 /api/attach/<rel>); 从别的客户端加的待发图片仍然显示为文字条。

命令与快捷键

输入框以 / 开头时是命令,在页面里执行,不发给模型:打 / 弹出列表(↑↓ 选,Tab 补全, Enter 执行),/model、/effort、/approval、/provider、/skill、/thinking、/details 还会补全参数。/usr/bin 是什么 这种第一个词里带 / 的照常发送;未知命令留在输入框里并提示, 开头加个空格即可当普通消息发出。

命令 作用
/new /sessions /rename [标题] 新建 / 会话列表 / 重命名
/retry /edit /stop /undo /compact 重新生成 / 编辑重发 / 停止 / 撤销写入 / 压缩上下文
/export /copy /diff [文件] 导出 Markdown / 复制最后一条回答 / 工作区 diff
/model [id] /effort [级别] /provider [名字] /key 模型、推理强度、提供商、API key
/approval [ask|auto|yolo] /plan /build 审批权限、计划/构建模式
/settings(/protocol /tools /max-steps /permissions) 运行设置
/init /skill 名字 [任务] /skills /extensions(/mcp /plugins /agents) 写 AGENTS.md、用 skill、扩展状态
/thinking [on|off] /details [on|off] 思考过程默认展开(存 settings.json)、工具输出全部展开(存浏览器)
/theme /palette /help 主题、命令面板、帮助

快捷键:Ctrl+P 命令面板,Ctrl+K 会话列表,Shift+Tab 循环审批(每次询问 → 自动 → 完全放行), F1 或 ? 帮助,Esc 关闭弹窗 / 取消编辑 / 停止当前一轮,Ctrl+B / Ctrl+J 收起左栏 / 右栏。

引擎的英文状态、错误和会话里的 [cst] 提示在页面上换成中文;一轮失败时(turn_end.rc≠0) 显示可读的原因(比如"API key 无效或已过期(HTTP 401)"、"用满了 30 步上限"),不再是 rc=-1。

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
                                  # (含 e2e_webui_settings.py:假 /v1/models、换提供商/key、中文思考流;
                                  #  e2e_webui_sessions.py:重新生成/编辑重发/导出/重命名/删除/历史字段;
                                  #  e2e_webui_workspace.py:diff、运行设置、权限规则;
                                  #  e2e_webui_ext.py:/init、/skill、扩展、信任、思考开关)
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl-heredoc ctl-heredoc
                                  # (deepseek-ctl 录的是一次契约错误连发的失败运行,不能拿来跑这个)

手工验收清单(浏览器):

  1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
  2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 exit= 标记。
  3. 切到 ask 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
  4. 会话 按钮:新建/切换后历史正确重建(刷新页面也一样):连续的思考和工具调用折叠成一行 过程块,展开是时间线,工具行点开能看到参数和输出;搜索、重命名、删除可用。 /thinking off 时流式输出的思考也保持收起。 4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。
  5. 拖入一张图片 → 输入框上方出现缩略图,发送后图片立刻出现在提问气泡上方,点开是大图预览。
  6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。

限制与已知问题

  • 同一会话同时只能有一轮(POST /api/turn 返回 409);TUI 与 WebUI 目前是 二选一(--web 时不启动 TUI),同进程并存留待后续的 hooks 分发层。
  • 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
  • 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
  • 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始, 这时应当重新拉一次 /api/session。