A session's transcript lived beside its attachments, both named after an id
that was a timestamp. Two things fell out of that: on a workspace two
machines share, a timestamp is not an id (the same second on both is enough
to hand out the same name), and "delete this conversation" had to chase the
jsonl, the title and a directory in a shared attachments/ tree, guessing what
belonged to whom. A phone's app data has nobody to tidy it by hand.
Now one session is one directory:
<workspace>/.agent/sessions/
<uuid>/session.jsonl the transcript
<uuid>/title the window title, one line
<uuid>/attachments/ images and delivered files (img-…, art-…)
<uuid>/sub-1.jsonl an agent role's own transcript
CURRENT the id the next run opens
- src/agent_store.c: new_id() makes a uuid v4 (16 bytes from /dev/urandom,
clock+pid only as a fallback), dir_for_id/flat_for_id/nested_for_id/
shape_of_id decide which shape an id has, so every reader takes either.
An id is a directory name, so it is checked as one (no "/", no "..",
nothing starting with ".", not CURRENT or attachments). path_for_id() keeps
the shape a session already has and makes the directory for a new id, so
nothing had to change in the callers. store_list() lists the directories
that hold a transcript plus the flat files, newest first. delete() is one
rm_tree of the session's directory, plus the old shared attachments/<id>
and CURRENT when it pointed there.
- cst_agent_store_migrate() (--migrate-store, or plain cst-agent for a
session): moves a flat store in — <id>.jsonl to <id>/session.jsonl,
<id>.title in, the shared attachments/<id>/ to <id>/attachments/. rename
first, copy when the filesystem says no, and counts what it moved, so a
second run is a no-op that says "nothing to move". On the real workspace:
6 sessions moved, the shared directory gone, --list-sessions reads them
back unchanged, a second run moved 0.
- cst_agent_store_id_of_path(): the id of a session path. This is the bug the
whole change had been hiding: cst_agent_session_id() took the id from the
file name, and the new file name is session.jsonl -- every session would
have called itself "session" (the sidebar, CURRENT, undo, an agent role's
directory, and the metrics file, which now also goes through the helper).
- src/attach.c: a session that owns its directory keeps its payloads inside
it (<id>/attachments/); only the flat layout needs the shared directory and
the id stem. Attachments behave exactly as before otherwise.
- tests/test_store.c: uuid shape and uniqueness, a small buffer refused, a
new session's path and directory, write/list/info/title read back, the
payload landing in <id>/attachments/, delete taking the directory with it;
then an old-layout store: readable before the move, moved byte for byte,
its payload readable through the new path, listed once, a second migration
a no-op, deletion covering the migrated payload. Also in the Makefile and
CMakeLists test lists.
- app/src/ui/UiTest.cpp: the image directory check wanted "/attachments/",
which the new layout has no trailing slash for.
Desktop: build clean, make check 35/35, --uitest all green, --selftest and
cst-app-selftest OK, and --selftest opens a session of the migrated workspace.
Version 0.9.5.
293 lines
21 KiB
Markdown
293 lines
21 KiB
Markdown
# 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`/`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/<id>/session.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 行)
|
||
|
||
```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>`,避免首帧闪一下再收起。
|
||
|
||
对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 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` 可覆盖);到点后下一轮
|
||
开始前先总结历史,而不是等到塞满。
|
||
|
||
## 测试
|
||
|
||
```bash
|
||
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`。
|