v0.8.4: WebUI sessions, diff, settings, slash commands and extensions
ci / linux (clang) (push) Successful in 6m13s
ci / musl (push) Canceled after 0s
ci / windows-cross (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 6m33s

Sessions: rename, delete and search; /api/retry regenerates the last
answer, editing a prompt resends from there; /api/export downloads the
transcript as Markdown (cst_session_export_md). Session messages carry
note/tool_result markers, so a reload rebuilds the tool steps with
their arguments, output and exit status; subagent progress is shown.

Diff and settings: /api/diff shows the real diff; /api/settings covers
protocol, max_steps, tool switches and confirm; /api/permissions edits
the rules; sandbox status is reported. Session rename no longer reads
past a short title.

Commands: slash commands with completion in the composer (/model,
/provider, /new, /retry, /undo, /compact, /export, /diff, /init,
/skill and more), a Ctrl+P palette, Shift+Tab cycles the approval
mode, F1 or ? shows the keys. "Thinking expanded" is saved in the
settings (show_thinking), "expand all tool output" in the browser.

Extensions: GET /api/extensions lists MCP servers, plugins, skills,
agent roles and subagent runs; POST /api/extensions/reload and
/api/extensions/trust; a workspace switch reloads them. /api/init and
/api/skill run on the server (cst_init_prompt shared with the TUI).

Errors, statuses and [cst] notes are shown in Chinese with a readable
reason instead of rc=; a failed turn's error survives the reload.

Tests: scripts/e2e_webui_sessions.py, e2e_webui_workspace.py and
e2e_webui_ext.py, run from e2e_webui.sh.
This commit is contained in:
WHC
2026-10-10 13:57:17 +08:00
parent 2d1052fd5f
commit 7e1dbd9afa
17 changed files with 2978 additions and 146 deletions
+73 -11
View File
@@ -88,7 +88,7 @@ GET /api/events?since=<seq> → text/event-stream (chunked)
| `assistant_html` | `text` | **已渲染且已消毒的 HTML 片段**,按顺序追加 |
| `assistant_delta` | `text` | 原始文本增量(逐字节真相,做复制/导出时用) |
| `think_delta` | `text` | 推理过程增量(Thought) |
| `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`done` |
| `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` 秒后按拒绝处理 |
@@ -104,7 +104,8 @@ 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`。
`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:上游在多字节字符中间
@@ -115,16 +116,30 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/state` | 上面那份快照 |
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致;assistant 消息额外带 `html`) |
| 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/workspace` | `{"path":"/abs/dir"}` —— 换工作目录(权限规则/埋点/AGENTS.md 跟着换),并**在新目录里开一个新会话**(会话是按目录存的);返回 `{id}`,坏路径 → 400 |
| 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?}`;先整体校验再生效,model/effort/approval(`yolo` 除外)写进 `settings.json`;当前模型不吃 effort 时回 `warning`;带 `provider` → 400(改用 `/api/provider`) |
| 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) |
@@ -137,9 +152,25 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
| POST | `/api/quit` | 关闭进程 |
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`workspace`、
`undo`、`compact`、`attach`(上传/清空)、`provider`、`key` 以及改 `model/effort` 的
`config` 一律 **409**;`approval`/`plan_mode` 可以中途改。
轮次运行中(或另一个修改正在进行)时,`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 保护。
@@ -178,6 +209,31 @@ Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
### 命令与快捷键
输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,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()` 的阻塞循环占住,
而且两个进程同时写一个会话文件会把它写坏,所以子进程总是开新会话。
@@ -190,8 +246,12 @@ TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目
```bash
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key
# (含 e2e_webui_settings.py:假 /v1/models、换提供商/key、中文思考流)
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl ctl
# (含 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 录的是一次契约错误连发的失败运行,不能拿来跑这个)
```
手工验收清单(浏览器):
@@ -199,7 +259,9 @@ sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl ctl
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样)。
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):工具调用折叠成"本轮工具调用",
点开能看到参数和输出;搜索、重命名、删除可用。
4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。
5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。