From 7e1dbd9afa8460fd1c74dff56fdf6fe118e77ba6 Mon Sep 17 00:00:00 2001 From: WHC Date: Sat, 10 Oct 2026 13:57:17 +0800 Subject: [PATCH] v0.8.4: WebUI sessions, diff, settings, slash commands and extensions 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. --- docs/webui.md | 84 ++- include/cst_agent.h | 6 + include/cst_skills.h | 2 + include/cst_version.h | 2 +- scripts/e2e_webui.sh | 9 + scripts/e2e_webui_ext.py | 227 +++++++ scripts/e2e_webui_sessions.py | 327 +++++++++ scripts/e2e_webui_workspace.py | 206 ++++++ src/agent_loop.c | 19 +- src/agent_store.c | 3 +- src/session.c | 60 ++ src/skills.c | 10 + src/tui.c | 62 +- src/webui.c | 828 ++++++++++++++++++++++- web/app.js | 1128 ++++++++++++++++++++++++++++++-- web/index.html | 12 +- web/style.css | 139 +++- 17 files changed, 2978 insertions(+), 146 deletions(-) create mode 100644 scripts/e2e_webui_ext.py create mode 100644 scripts/e2e_webui_sessions.py create mode 100644 scripts/e2e_webui_workspace.py diff --git a/docs/webui.md b/docs/webui.md index 53e8310..37e10f1 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -88,7 +88,7 @@ GET /api/events?since= → 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[]}`(来自 `/.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 命中率。布局类在页面脚本之前就写进 ``,避免首帧闪一下再收起。 +### 命令与快捷键 + +输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,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`), 日志在 `/.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. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。 diff --git a/include/cst_agent.h b/include/cst_agent.h index 9650a6e..c2da4be 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -130,6 +130,12 @@ const cst_message_t *cst_session_get(const cst_session_t *s, int i); const char *cst_session_path(const cst_session_t *s); int cst_session_clear(cst_session_t *s, int keep_system); int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n); +/* Tool output: role=tool, or a text-protocol user message answering a call. */ +int cst_message_is_tool_result(const cst_message_t *m); +/* The transcript as Markdown, malloc'd (NULL on OOM). Tool output and call + * arguments are cut at 4000 bytes. Takes the session lock. */ +char *cst_session_export_md(cst_session_t *s, const char *title, + const char *sid, const char *model); /* Readers on another thread must hold the lock while using pointers returned * by cst_session_get (append may reallocate). Append locks internally. */ void cst_session_lock(cst_session_t *s); diff --git a/include/cst_skills.h b/include/cst_skills.h index a49d3f8..384576b 100644 --- a/include/cst_skills.h +++ b/include/cst_skills.h @@ -45,6 +45,8 @@ char *cst_skill_read(const char *workspace, const char *name, * task. NULL when the skill does not exist. malloc'd. */ char *cst_skill_prompt(const char *workspace, const char *name, const char *task); +/* User message for `/init`: have the agent write AGENTS.md. */ +const char *cst_init_prompt(void); /* Register the `skill` tool (idempotent). */ int cst_skills_register_tool(void); diff --git a/include/cst_version.h b/include/cst_version.h index 58c1988..df1a902 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 3 +#define CST_VERSION_PATCH 4 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/scripts/e2e_webui.sh b/scripts/e2e_webui.sh index 44fb09c..e42c8a0 100644 --- a/scripts/e2e_webui.sh +++ b/scripts/e2e_webui.sh @@ -285,6 +285,15 @@ if command -v python3 > /dev/null; then echo "== settings and streaming" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_settings.py" > "$WORK/settings.txt" 2>&1 || { cat "$WORK/settings.txt"; fail "the settings checks"; } + echo "== sessions, retry and export" + CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_sessions.py" > "$WORK/sessions.txt" 2>&1 || + { cat "$WORK/sessions.txt"; fail "the session checks"; } + echo "== diff, run settings and permission rules" + CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_workspace.py" > "$WORK/workspace.txt" 2>&1 || + { cat "$WORK/workspace.txt"; fail "the workspace checks"; } + echo "== /init, /skill, extensions and the thinking switch" + CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ext.py" > "$WORK/ext.txt" 2>&1 || + { cat "$WORK/ext.txt"; fail "the extension checks"; } fi echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))" diff --git a/scripts/e2e_webui_ext.py b/scripts/e2e_webui_ext.py new file mode 100644 index 0000000..27d5c74 --- /dev/null +++ b/scripts/e2e_webui_ext.py @@ -0,0 +1,227 @@ +"""WebUI /init, /skill, extensions and the thinking switch against a fake API. + + python3 scripts/e2e_webui_ext.py (CST_AGENT= picks the binary) + +Checks: show_thinking is validated, lands in the state and in settings.json; +/api/skill inlines the skill and 404s an unknown one; /api/init sends the +AGENTS.md prompt; /api/extensions lists skills and the built-in roles; +trust and reload work and write settings.json; the changing calls are a 409 +while a turn runs, the listing is not. HOME points at a temp dir. +""" +import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent")) +SEEN = [] # the full last user message of every request + + +class Api(BaseHTTPRequestHandler): + """Answers "好的"; a prompt with "slow" takes 2 s.""" + def log_message(self, *a): + pass + + def do_GET(self): + body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + users = [m.get("content") for m in req.get("messages", []) if m.get("role") == "user"] + last = users[-1] if users and isinstance(users[-1], str) else "" + SEEN.append(last) + if "slow" in last: + time.sleep(2) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + for o in ({"delta": {"role": "assistant", "content": "好的"}}, + {"delta": {}, "finish_reason": "stop"}): + chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash", + "choices": [dict(index=0, **o)]} + self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n") + self.wfile.flush() + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +fails = 0 + + +def check(ok, msg): + global fails + print(("PASS " if ok else "FAIL ") + msg) + fails += 0 if ok else 1 + + +def wait(fn, t=15): + end = time.time() + t + while time.time() < end: + v = fn() + if v: + return v + time.sleep(0.1) + return None + + +def call(base, path, obj=None, method=None): + data = None if obj is None else json.dumps(obj).encode() + req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read() or b"{}") + except ValueError: + return e.code, {} + + +def skill(ws, name, desc, body): + d = os.path.join(ws, ".agent", "skills", name) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f: + f.write("---\nname: %s\ndescription: %s\n---\n\n%s\n" % (name, desc, body)) + + +api = ThreadingHTTPServer(("127.0.0.1", 0), Api) +threading.Thread(target=api.serve_forever, daemon=True).start() +ws = tempfile.mkdtemp(prefix="cst-ext-") +home = tempfile.mkdtemp(prefix="cst-home-") +skill(ws, "demo", "演示用的 skill", "DEMO-INSTRUCTIONS: 先说你好。") +env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config", + XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache") +for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"): + env.pop(k, None) +log = open(ws + "/server.log", "w+") +proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x", + "--provider", "deepseek", "--model", "deepseek-flash", + "--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1], + "--protocol", "native-eos", "--approval", "auto"], + stdout=log, stderr=subprocess.STDOUT, env=env) + + +def port(): + log.seek(0) + for l in log.read().splitlines(): + if l.startswith("webui: http://127.0.0.1:"): + return l.split(":")[3].split("/")[0] + + +def settings(): + try: + with open(home + "/.config/cst-agent/settings.json", encoding="utf-8") as f: + return json.load(f) + except OSError: + return {} + + +def busy(base): + return call(base, "/api/state")[1].get("status_busy") + + +def turn(base, path, body): + code, d = call(base, path, body) + if code == 202: + time.sleep(0.3) + wait(lambda: not busy(base), 20) + return code, d + + +try: + base = "http://127.0.0.1:%s" % wait(port) + + # 1. the thinking switch + st = call(base, "/api/state")[1] + check(st.get("show_thinking") is False, "show_thinking starts off") + check(call(base, "/api/config", {"show_thinking": "yes"})[0] == 400, "show_thinking must be a bool") + code, _ = call(base, "/api/config", {"show_thinking": True}) + check(code == 200 and call(base, "/api/state")[1].get("show_thinking") is True, "show_thinking on") + check(settings().get("show_thinking") is True, "show_thinking is saved in settings.json") + call(base, "/api/config", {"show_thinking": False}) + check(settings() and not settings().get("show_thinking"), "and cleared again (%s)" % settings()) + + # 2. the extensions listing + code, ex = call(base, "/api/extensions") + check(code == 200 and ex.get("enabled") is True and ex.get("trusted") is False, + "extensions: enabled, not trusted (%s)" % code) + names = [s["name"] for s in ex.get("skills", [])] + check(names == ["demo"] and ex["skills"][0].get("desc") == "演示用的 skill" + and ex["skills"][0].get("source") == "workspace", "the workspace skill is listed (%s)" % ex.get("skills")) + roles = [r["name"] for r in ex.get("roles", [])] + check("explore" in roles and all(r.get("source") for r in ex["roles"]), "built-in roles are listed (%s)" % roles) + check(ex.get("mcp") == [] and ex.get("plugins") == [] and ex.get("runs") == [], "no MCP, plugins or runs") + check(ex.get("task_tool") is True and "1 skill" in ex.get("report", ""), "task tool on, report %r" % ex.get("report")) + + # 3. /skill + code, d = turn(base, "/api/skill", {"name": "demo", "task": "做点事"}) + check(code == 202 and "DEMO-INSTRUCTIONS" in d.get("text", "") and d["text"].endswith("做点事"), + "skill turn accepted (%s)" % code) + check(SEEN and "DEMO-INSTRUCTIONS" in SEEN[-1] and "做点事" in SEEN[-1], "the model saw the skill and the task") + code, d = turn(base, "/api/skill", {"name": "demo"}) + check(code == 202 and SEEN[-1].endswith("Apply this skill now."), "a skill without a task") + check(call(base, "/api/skill", {"name": "nope"})[0] == 404, "unknown skill: 404") + check(call(base, "/api/skill", {"name": "../demo"})[0] == 404, "skill ../demo: 404") + check(call(base, "/api/skill", {})[0] == 400, "skill without a name: 400") + check(call(base, "/api/skill", {"name": "x" * 100})[0] == 400, "a long skill name: 400") + check(call(base, "/api/skill", {"name": "demo", "task": 5})[0] == 400, "a non-string task: 400") + + # 4. /init + code, d = turn(base, "/api/init", {}) + check(code == 202 and "AGENTS.md" in d.get("text", ""), "init accepted (%s)" % code) + check("AGENTS.md" in SEEN[-1], "the model saw the AGENTS.md prompt") + + # 5. trust and reload + code, d = call(base, "/api/extensions/trust", {"on": True}) + check(code == 200 and d.get("trusted") is True, "trust the workspace (%s %s)" % (code, d)) + tw = settings().get("trusted_workspaces", []) + check(any(os.path.realpath(ws) == os.path.realpath(p) for p in tw), "settings.json lists it (%s)" % tw) + check(call(base, "/api/extensions")[1].get("trusted") is True, "the listing says trusted") + code, d = call(base, "/api/extensions/trust", {"on": False}) + check(code == 200 and d.get("trusted") is False and not settings().get("trusted_workspaces"), + "untrust (%s)" % settings().get("trusted_workspaces")) + check(call(base, "/api/extensions/trust", {"on": "yes"})[0] == 400, "trust needs a bool") + skill(ws, "zeta", "第二个", "ZETA") + code, d = call(base, "/api/extensions/reload", {}) + check(code == 200 and "2 skills" in d.get("report", ""), "reload sees the new skill (%s)" % d) + check([s["name"] for s in call(base, "/api/extensions")[1]["skills"]] == ["demo", "zeta"], "both listed") + + # 6. while a turn runs + call(base, "/api/turn", {"text": "slow 慢一点"}) + time.sleep(0.4) + check(call(base, "/api/extensions/reload", {})[0] == 409, "reload during a turn: 409") + check(call(base, "/api/extensions/trust", {"on": True})[0] == 409, "trust during a turn: 409") + check(call(base, "/api/skill", {"name": "demo"})[0] == 409, "skill during a turn: 409") + check(call(base, "/api/init", {})[0] == 409, "init during a turn: 409") + check(call(base, "/api/extensions")[0] == 200, "the listing works during a turn") + check(call(base, "/api/config", {"show_thinking": True})[0] == 200, "the thinking switch works during a turn") + wait(lambda: not busy(base), 20) + check(not settings().get("trusted_workspaces"), "the refused trust changed nothing") + + # 7. another workspace brings its own skills + ws2 = tempfile.mkdtemp(prefix="cst-ext2-") + skill(ws2, "other", "另一个工作区的", "OTHER") + code, _ = call(base, "/api/workspace", {"path": ws2}) + ex = call(base, "/api/extensions")[1] + check(code == 200 and [s["name"] for s in ex.get("skills", [])] == ["other"] + and ex.get("report") == "1 skill", "a workspace switch reloads the extensions (%s)" % ex.get("report")) + code, d = turn(base, "/api/skill", {"name": "other"}) + check(code == 202 and "OTHER" in SEEN[-1], "and its skill runs") + shutil.rmtree(ws2, ignore_errors=True) +finally: + proc.terminate() + try: + proc.wait(20) + except subprocess.TimeoutExpired: + proc.kill() + check(False, "server hung on SIGTERM") + log.close() + shutil.rmtree(ws, ignore_errors=True) + shutil.rmtree(home, ignore_errors=True) + api.shutdown() +print("FAILED:", fails) +sys.exit(1 if fails else 0) diff --git a/scripts/e2e_webui_sessions.py b/scripts/e2e_webui_sessions.py new file mode 100644 index 0000000..a9ae7bd --- /dev/null +++ b/scripts/e2e_webui_sessions.py @@ -0,0 +1,327 @@ +"""WebUI session management against a fake OpenAI-compatible API. + + python3 scripts/e2e_webui_sessions.py (CST_AGENT= picks the binary) + +Checks: retry runs the last prompt again and edit-and-resend replaces it +(the model sees the new text, the session keeps one copy); retry is a 409 +while a turn runs and when there is nothing to retry; export returns the +transcript as Markdown; rename, clear and delete work on the store, and +deleting the open session moves to a new one; history messages carry the +note / tool_result flags. HOME points at a temp dir. +""" +import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent")) +SEEN = [] + + +def last_prompt(msgs): + """The prompts are one line; a note before one shares its user message.""" + for m in reversed(msgs): + c = m.get("content") + if m.get("role") == "user" and isinstance(c, str): + return c.rstrip("\n").split("\n")[-1] + return "" + + +class Api(BaseHTTPRequestHandler): + """Answers "回答:"; a prompt with "slow" takes 2 s.""" + def log_message(self, *a): + pass + + def do_GET(self): + body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + msgs = req.get("messages", []) + p = last_prompt(msgs) + tail = msgs[-1] if msgs else {} + tc = tail.get("content") if isinstance(tail.get("content"), str) else "" + answered = tail.get("role") == "tool" or tc.startswith("result ") + if not answered: + SEEN.append(p) + if "slow" in p: + time.sleep(2) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + if answered: + chunks = ({"delta": {"role": "assistant", "content": "读完了"}}, + {"delta": {}, "finish_reason": "stop"}) + elif p.startswith("native"): + args = json.dumps({"path": "hello.txt"}) + chunks = ({"delta": {"role": "assistant", "tool_calls": [ + {"index": 0, "id": "c1", "type": "function", + "function": {"name": "read_file", "arguments": args}}]}}, + {"delta": {}, "finish_reason": "tool_calls"}) + elif p.startswith("ctl"): + chunks = ({"delta": {"role": "assistant", + "content": "我来读。\ncall t1 read_file\narg path 9\nhello.txtend\n"}}, + {"delta": {}, "finish_reason": "stop"}) + else: + chunks = ({"delta": {"role": "assistant", "content": "回答:" + p}}, + {"delta": {}, "finish_reason": "stop"}) + for o in chunks: + chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash", + "choices": [dict(index=0, **o)]} + self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n") + self.wfile.flush() + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +fails = 0 + + +def check(ok, msg): + global fails + print(("PASS " if ok else "FAIL ") + msg) + fails += 0 if ok else 1 + + +def wait(fn, t=15): + end = time.time() + t + while time.time() < end: + v = fn() + if v: + return v + time.sleep(0.1) + return None + + +def call(base, path, obj=None, method=None, raw_reply=False): + data = None if obj is None else json.dumps(obj).encode() + req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + raw = r.read() + if raw_reply: + return r.status, r.headers.get("Content-Type", ""), raw + return r.status, json.loads(raw or b"{}") + except urllib.error.HTTPError as e: + raw = e.read() + if raw_reply: + return e.code, e.headers.get("Content-Type", ""), raw + try: + return e.code, json.loads(raw or b"{}") + except ValueError: + return e.code, {} + + +class Sse: + def __init__(self, base): + self.lines = [] + self.r = urllib.request.urlopen(base + "/api/events", timeout=60) + threading.Thread(target=self.pump, daemon=True).start() + + def pump(self): + try: + for raw in self.r: + self.lines.append(raw) + except Exception: + pass + + def count(self, name): + return sum(1 for l in self.lines if l.strip() == b"event: " + name.encode()) + + def close(self): + try: + self.r.close() + except Exception: + pass + + +api = ThreadingHTTPServer(("127.0.0.1", 0), Api) +threading.Thread(target=api.serve_forever, daemon=True).start() +ws = tempfile.mkdtemp(prefix="cst-sess-") +home = tempfile.mkdtemp(prefix="cst-home-") +env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config", + XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache") +for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"): + env.pop(k, None) +log = open(ws + "/server.log", "w+") +proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x", + "--provider", "deepseek", "--model", "deepseek-flash", + "--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1], + "--protocol", "native-eos", "--approval", "auto"], + stdout=log, stderr=subprocess.STDOUT, env=env) + + +def port(): + log.seek(0) + for l in log.read().splitlines(): + if l.startswith("webui: http://127.0.0.1:"): + return l.split(":")[3].split("/")[0] + + +def messages(base): + return call(base, "/api/session")[1].get("messages", []) + + +def prompts(base): + return [m["content"] for m in messages(base) + if m["role"] == "user" and not m.get("note") and not m.get("tool_result")] + + +def answers(base): + return [m["content"] for m in messages(base) if m["role"] == "assistant"] + + +def turn(base, sse, path, body, want=202): + n = sse.count("turn_end") + code, d = call(base, path, body) + if code == want == 202: + wait(lambda: sse.count("turn_end") > n, 20) + return code, d + + +try: + base = "http://127.0.0.1:%s" % wait(port) + sse = Sse(base) + time.sleep(0.3) + + # 1. retry with nothing to retry, then a first turn + code, d = call(base, "/api/retry", {}) + check(code == 409 and "nothing to retry" in d.get("error", ""), "retry on an empty session: 409 (%s)" % code) + turn(base, sse, "/api/turn", {"text": "第一句"}) + check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"], "first turn stored") + + # 2. retry: same prompt again, one copy in the session + code, d = turn(base, sse, "/api/retry", {}) + check(code == 202 and d.get("text") == "第一句", "retry accepted (%s %s)" % (code, d)) + check(SEEN[-1] == "第一句", "the model saw the same prompt again") + check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"], + "after retry: one prompt, one answer (%s)" % prompts(base)) + + # 3. edit and resend: the new text replaces the old + code, d = turn(base, sse, "/api/retry", {"text": "改过的问题"}) + check(code == 202 and d.get("text") == "改过的问题", "edit-and-resend accepted") + check(SEEN[-1] == "改过的问题", "the model saw the edited prompt") + check(prompts(base) == ["改过的问题"] and answers(base) == ["回答:改过的问题"], + "after edit: %s / %s" % (prompts(base), answers(base))) + check(call(base, "/api/retry", {"text": ""})[0] == 400, "an empty edit is a 400") + check(call(base, "/api/retry", {"text": 5})[0] == 400, "a non-string edit is a 400") + + # 4. a second turn, then retry only touches the last one + turn(base, sse, "/api/turn", {"text": "第二句"}) + turn(base, sse, "/api/retry", {}) + check(prompts(base) == ["改过的问题", "第二句"] and len(answers(base)) == 2, + "retry keeps earlier turns: %s" % prompts(base)) + + # 5. retry while a turn runs is a 409 + n = sse.count("turn_end") + call(base, "/api/turn", {"text": "slow 请慢一点"}) + code, d = call(base, "/api/retry", {}) + check(code == 409, "retry during a turn: 409 (%s)" % code) + wait(lambda: sse.count("turn_end") > n, 20) + + # 6. a note (plan mode changed mid-session) is flagged, and retry skips it + call(base, "/api/config", {"plan_mode": True}) + turn(base, sse, "/api/turn", {"text": "计划一下"}) + ms = messages(base) + notes = [m for m in ms if m.get("note")] + check(len(notes) >= 1 and all(m["role"] == "user" for m in notes), + "a mid-session note is flagged note:true (%d)" % len(notes)) + turn(base, sse, "/api/retry", {}) + check(SEEN[-1] == "计划一下" and prompts(base)[-1] == "计划一下", "retry after a note reruns the prompt") + call(base, "/api/config", {"plan_mode": False}) + + # 7. export + code, ctype, raw = call(base, "/api/export", raw_reply=True) + md = raw.decode("utf-8", "replace") + check(code == 200 and ctype.startswith("text/markdown"), "export: %s %s" % (code, ctype)) + check(md.startswith("# ") and "## User" in md and "改过的问题" in md and "## Assistant" in md + and "回答:第二句" in md and "第一句" not in md, "export has the transcript") + + # 8. rename and clear + st = call(base, "/api/state")[1] + cur = st.get("session") + code, _ = call(base, "/api/sessions/rename", {"title": "我的会话"}) + check(code == 200, "rename the open session") + ss = call(base, "/api/sessions")[1] + check(any(s["id"] == cur and s["title"] == "我的会话" for s in ss.get("sessions", [])), + "the list shows the new title") + code, _, raw = call(base, "/api/export", raw_reply=True) + check(raw.decode("utf-8", "replace").startswith("# 我的会话"), "export uses the title") + long = "长" * 100 + call(base, "/api/sessions/rename", {"id": cur, "title": long}) + t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0] + check(0 < len(t.encode()) <= 120 and t == "长" * len(t), "a long title is cut on a character (%d bytes)" + % len(t.encode())) + call(base, "/api/sessions/rename", {"title": ""}) + t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0] + check(t == "改过的问题", "an empty title falls back to the first prompt (%r)" % t) + check(call(base, "/api/sessions/rename", {"id": "nope", "title": "x"})[0] == 404, "rename unknown: 404") + check(call(base, "/api/sessions/rename", {"id": "../x", "title": "x"})[0] == 404, "rename ../x: 404") + check(call(base, "/api/sessions/rename", {})[0] == 400, "rename without a title: 400") + + # 9. delete another session, then the open one + code, d = call(base, "/api/sessions/new", {}) + second = d.get("id") + check(code == 200 and second and second != cur, "a second session") + code, d = call(base, "/api/sessions/delete", {"id": cur}) + check(code == 200 and d.get("current") == second, "delete a closed session (%s %s)" % (code, d)) + ids = [s["id"] for s in call(base, "/api/sessions")[1]["sessions"]] + check(cur not in ids and not os.path.exists(os.path.join(ws, ".agent", "sessions", cur + ".jsonl")), + "it is gone from the list and the disk") + n = sse.count("session") + code, d = call(base, "/api/sessions/delete", {"id": second}) + check(code == 200 and d.get("current") and d["current"] != second, "delete the open session (%s)" % d) + check(call(base, "/api/state")[1].get("session") == d.get("current"), "the state moved to the new one") + check(wait(lambda: sse.count("session") > n, 5), "a session event was sent") + check(call(base, "/api/sessions/delete", {"id": "nope"})[0] == 404, "delete unknown: 404") + check(call(base, "/api/sessions/delete", {})[0] == 400, "delete without an id: 400") + + # 10. history: calls and results come back apart, for native and ctl + with open(os.path.join(ws, "hello.txt"), "w", encoding="utf-8") as f: + f.write("你好世界\n") + turn(base, sse, "/api/turn", {"text": "native 读文件"}) + ms = messages(base) + a = [m for m in ms if m["role"] == "assistant" and m.get("calls")] + r = [m for m in ms if m.get("tool_result")] + check(len(a) == 1 and a[0]["calls"][0].get("name") == "read_file" + and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt" and a[0]["calls"][0].get("id") == "c1", + "native: the call is listed with its args (%s)" % (a[0].get("calls") if a else ms)) + check(len(r) == 1 and r[0]["role"] == "tool" and r[0].get("tool_call_id") == "c1" + and "你好世界" in r[0].get("out", "") and not r[0]["out"].startswith("[tool ") + and r[0].get("exit") == 0 and r[0].get("truncated") is False, + "native: the result carries out/exit without the header (%s)" % r) + check(answers(base)[-1] == "读完了", "native: the turn finished") + + check(call(base, "/api/settings", {"protocol": "ctl"})[0] == 200, "switch to the ctl protocol") + turn(base, sse, "/api/turn", {"text": "ctl 读文件"}) + ms = messages(base) + a = [m for m in ms if m["role"] == "assistant" and m.get("calls") and m["calls"][0].get("id") == "t1"] + r = [m for m in ms if m.get("tool_result") and m.get("tool_call_id") == "t1"] + check(len(a) == 1 and a[0].get("text", "").strip() == "我来读。" and "call t1" in a[0]["content"] + and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt" + and "call t1" not in a[0].get("html", ""), + "ctl: prose and calls are split (%s)" % (a[0] if a else ms[-4:])) + check(len(r) == 1 and r[0]["role"] == "user" and "你好世界" in r[0].get("out", "") + and not r[0]["out"].startswith("result ") and not r[0]["out"].endswith("end") + and r[0].get("exit") == 0, + "ctl: the result is flagged with out/exit (%s)" % r) + check(r and r[0]["content"] not in prompts(base), "ctl: the result is not a prompt") + call(base, "/api/settings", {"protocol": "native-eos"}) + sse.close() +finally: + proc.terminate() + try: + proc.wait(20) + except subprocess.TimeoutExpired: + proc.kill() + check(False, "server hung on SIGTERM") + log.close() + shutil.rmtree(ws, ignore_errors=True) + shutil.rmtree(home, ignore_errors=True) + api.shutdown() +print("FAILED:", fails) +sys.exit(1 if fails else 0) diff --git a/scripts/e2e_webui_workspace.py b/scripts/e2e_webui_workspace.py new file mode 100644 index 0000000..1acbd24 --- /dev/null +++ b/scripts/e2e_webui_workspace.py @@ -0,0 +1,206 @@ +"""WebUI workspace views: git diff, run settings, saved permission rules. + + python3 scripts/e2e_webui_workspace.py (CST_AGENT= picks the binary) + +Checks: /api/diff lists git status and the working-tree diff (per file too), +an option-looking path is only ever a path, and a directory without git +says so; /api/settings reports protocol, tools, confirmations and the +sandbox, rejects a bad body without applying any of it, applies a good one, +and is a 409 while a turn runs; /api/permissions shows the saved rules and +DELETE clears them. HOME points at a temp dir. +""" +import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent")) + + +class Api(BaseHTTPRequestHandler): + """A slow plain answer, so a turn is running for a while.""" + def log_message(self, *a): + pass + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", 0))) + time.sleep(2) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + for o in ({"delta": {"role": "assistant", "content": "好"}}, {"delta": {}, "finish_reason": "stop"}): + chunk = {"id": "x", "object": "chat.completion.chunk", "model": "m", "choices": [dict(index=0, **o)]} + self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n") + self.wfile.write(b"data: [DONE]\n\n") + + +fails = 0 + + +def check(ok, msg): + global fails + print(("PASS " if ok else "FAIL ") + msg) + fails += 0 if ok else 1 + + +def wait(fn, t=15): + end = time.time() + t + while time.time() < end: + v = fn() + if v: + return v + time.sleep(0.1) + return None + + +def call(base, path, obj=None, method=None): + data = None if obj is None else json.dumps(obj).encode() + req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + raw = e.read() + try: + return e.code, json.loads(raw or b"{}") + except ValueError: + return e.code, {} + + +def git(*args): + return subprocess.run(["git", "-C", ws] + list(args), capture_output=True, text=True) + + +api = ThreadingHTTPServer(("127.0.0.1", 0), Api) +threading.Thread(target=api.serve_forever, daemon=True).start() +ws = tempfile.mkdtemp(prefix="cst-ws-") +plain = tempfile.mkdtemp(prefix="cst-nogit-") +home = tempfile.mkdtemp(prefix="cst-home-") +env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config", + XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache", + GIT_CONFIG_NOSYSTEM="1") +for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"): + env.pop(k, None) +have_git = shutil.which("git") is not None +if have_git: + git("init", "-q") + git("config", "user.email", "e2e@example.com") + git("config", "user.name", "e2e") + open(ws + "/a.txt", "w").write("one\n") + git("add", "a.txt") + git("commit", "-q", "-m", "first") + open(ws + "/a.txt", "w").write("one\n第二行\n") + open(ws + "/new.txt", "w").write("new\n") +os.makedirs(ws + "/.agent", exist_ok=True) +json.dump({"tools": ["write_file"], "shell_prefixes": ["make"]}, open(ws + "/.agent/permissions.json", "w")) + +log = open(home + "/server.log", "w+") +proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x", + "--provider", "deepseek", "--model", "deepseek-flash", + "--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1], + "--protocol", "native-eos", "--approval", "auto"], + stdout=log, stderr=subprocess.STDOUT, env=env) + + +def port(): + log.seek(0) + for l in log.read().splitlines(): + if l.startswith("webui: http://127.0.0.1:"): + return l.split(":")[3].split("/")[0] + + +try: + base = "http://127.0.0.1:%s" % wait(port) + + # 1. diff + if have_git: + code, d = call(base, "/api/diff") + files = {f["path"]: f["status"] for f in d.get("files", [])} + check(code == 200 and d.get("git") is True, "diff: git repo seen") + check(files.get("a.txt") == " M" and files.get("new.txt") == "??", "status lists both files: %s" % files) + check("+第二行" in d.get("diff", "") and "a.txt" in d.get("diff", ""), "the diff has the change") + code, d = call(base, "/api/diff?path=a.txt") + check(code == 200 and "+第二行" in d.get("diff", "") and d.get("path") == "a.txt", "diff of one file") + code, d = call(base, "/api/diff?path=new.txt") + check(code == 200 and d.get("diff") == "", "an untracked file has no diff") + evil = ws + "/pwned" + code, d = call(base, "/api/diff?path=" + urllib.request.quote("--output=" + evil)) + check(code == 200 and not os.path.exists(evil), "an option-looking path stays a path") + else: + print("SKIP diff checks: no git") + + # 2. settings: what is there + code, s = call(base, "/api/settings") + names = [t["name"] for t in s.get("tools", [])] + check(code == 200 and s.get("protocol") == "native-eos" and len(s.get("protocols", [])) == 5, + "settings: protocol %s of %s" % (s.get("protocol"), s.get("protocols"))) + check("read_file" in names and all("perm" in t and "on" in t for t in s["tools"]), "tool list: %d tools" % len(names)) + check(set(s.get("confirm", {})) == {"shell", "write", "net"}, "confirm flags present") + check(isinstance(s.get("sandbox", {}).get("kind"), str), "sandbox kind: %s" % s.get("sandbox")) + victim = next((t["name"] for t in s["tools"] if t["perm"] == "net"), names[-1]) + + # 3. a bad body changes nothing + for body, why in (({"protocol": "nope"}, "unknown protocol"), ({"max_steps": 0}, "max_steps 0"), + ({"max_steps": "5"}, "max_steps as text"), ({"tool_profile": "huge"}, "unknown profile"), + ({"tools": {"nope": False}}, "unknown tool"), ({"tools": {victim: "off"}}, "tool value not bool"), + ({"confirm": {"disk": True}}, "unknown confirm key"), + ({"max_steps": 9, "protocol": "nope"}, "mixed good and bad")): + check(call(base, "/api/settings", body)[0] == 400, "400 for %s" % why) + check(call(base, "/api/settings")[1].get("max_steps") == s.get("max_steps"), "nothing applied from a bad body") + + # 4. a good body applies + code, d = call(base, "/api/settings", {"protocol": "ctl", "max_steps": 7, "tool_profile": "minimal", + "tools": {victim: False}, "confirm": {"shell": True, "net": False}}) + check(code == 200 and d.get("ok"), "settings accepted (%s %s)" % (code, d)) + code, s2 = call(base, "/api/settings") + t2 = {t["name"]: t for t in s2.get("tools", [])} + check(s2.get("protocol") == "ctl" and s2.get("max_steps") == 7 and s2.get("tool_profile") == "minimal", + "protocol/steps/profile applied") + check(t2.get(victim, {}).get("off") is True and t2[victim]["on"] is False, "%s switched off" % victim) + check(s2["confirm"]["shell"] is True and s2["confirm"]["net"] is False, "confirm flags applied") + st = call(base, "/api/state")[1] + check(st.get("protocol_tools") == "ctl" and st.get("max_steps") == 7 and st.get("tool_profile") == "minimal", + "the state shows them") + call(base, "/api/settings", {"protocol": "native-eos", "tool_profile": "default", "tools": {victim: True}}) + t3 = {t["name"]: t for t in call(base, "/api/settings")[1]["tools"]} + check(t3[victim]["off"] is False and t3[victim]["on"] is True, "%s back on" % victim) + + # 5. permissions + code, p = call(base, "/api/permissions") + check(code == 200 and p.get("tools") == ["write_file"] and p.get("shell_prefixes") == ["make"] + and "write_file" in p.get("rules", ""), "saved rules: %s %s" % (p.get("tools"), p.get("shell_prefixes"))) + code, _ = call(base, "/api/permissions", method="DELETE") + check(code == 200, "DELETE /api/permissions") + p = call(base, "/api/permissions")[1] + check(p.get("tools") == [] and p.get("shell_prefixes") == [], "the rules are gone") + saved = json.load(open(ws + "/.agent/permissions.json")) + check(saved.get("tools") == [] and saved.get("shell_prefixes") == [], "and gone from the file") + + # 6. while a turn runs: settings and the rule reset are 409, reads still work + code, _ = call(base, "/api/turn", {"text": "慢慢来"}) + check(code == 202, "a slow turn started") + check(call(base, "/api/settings", {"max_steps": 3})[0] == 409, "settings during a turn: 409") + check(call(base, "/api/permissions", method="DELETE")[0] == 409, "rule reset during a turn: 409") + check(call(base, "/api/settings")[0] == 200 and call(base, "/api/permissions")[0] == 200, + "reads during a turn: 200") + if have_git: + check(call(base, "/api/diff")[0] == 200, "diff during a turn: 200") + wait(lambda: not call(base, "/api/state")[1].get("status_busy"), 20) + + # 7. a directory without git + if have_git: + code, _ = call(base, "/api/workspace", {"path": plain}) + code, d = call(base, "/api/diff") + check(code == 200 and d.get("git") is False and "diff" not in d, "no git repo: git=false") +finally: + proc.terminate() + try: + proc.wait(20) + except subprocess.TimeoutExpired: + proc.kill() + check(False, "server hung on SIGTERM") + log.close() + for d in (ws, plain, home): + shutil.rmtree(d, ignore_errors=True) + api.shutdown() +print("FAILED:", fails) +sys.exit(1 if fails else 0) diff --git a/src/agent_loop.c b/src/agent_loop.c index ccc7d7e..ab36db2 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -2948,13 +2948,18 @@ int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) { return -1; if (cst_agent_store_ensure(a->cfg.workspace_root) != 0) return -1; - char id[128]; - if (cst_agent_store_new_id(id, sizeof(id)) != 0) - return -1; - char path[4096]; - if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path, - sizeof(path)) != 0) - return -1; + char id[128], path[4096]; + /* ids are per second: never reopen (and append to) one that exists */ + for (int tries = 0;; tries++) { + if (tries == 16 || cst_agent_store_new_id(id, sizeof(id)) != 0 || + cst_agent_store_path_for_id(a->cfg.workspace_root, id, path, + sizeof(path)) != 0) + return -1; + FILE *fp = fopen(path, "rb"); + if (!fp) + break; + fclose(fp); + } if (agent_reopen_session(a, path) != 0) return -1; cst_agent_store_set_current(a->cfg.workspace_root, id); diff --git a/src/agent_store.c b/src/agent_store.c index 032960e..7877109 100644 --- a/src/agent_store.c +++ b/src/agent_store.c @@ -57,7 +57,8 @@ static void make_id(char *out, size_t n) { time_t now = time(NULL); struct tm tm; os_localtime(&now, &tm); - unsigned r = (unsigned)(os_getpid() ^ (unsigned)now); + static unsigned seq; + unsigned r = (unsigned)(os_getpid() ^ (unsigned)now) + 0x9e37u * seq++; snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, r & 0xffff); diff --git a/src/session.c b/src/session.c index 515476d..b52fa77 100644 --- a/src/session.c +++ b/src/session.c @@ -8,6 +8,7 @@ #include "cJSON.h" #include "cst_agent.h" #include "cst_attach.h" +#include "cst_util.h" #include #include @@ -409,3 +410,62 @@ int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) { free(copy); return rc; } + +int cst_message_is_tool_result(const cst_message_t *m) { + return m && (m->role == CST_ROLE_TOOL || + (m->role == CST_ROLE_USER && m->tool_call_id[0])); +} + +static void md_fence(cst_buf_t *b, const char *s, size_t max) { + size_t n = strlen(s); + if (n > max) + n = cst_utf8_floor(s, max); + cst_buf_puts(b, "````\n"); + cst_buf_append(b, s, n); + cst_buf_puts(b, n < strlen(s) ? "\n…(truncated)\n````\n\n" : "\n````\n\n"); +} + +char *cst_session_export_md(cst_session_t *s, const char *title, + const char *sid, const char *model) { + if (!s) + return NULL; + cst_buf_t b = {0}; + cst_buf_printf(&b, "# %s\n\nsession `%s` · model `%s`\n\n", + title ? title : "", sid ? sid : "", model ? model : ""); + cst_session_lock(s); + for (int i = 0; i < cst_session_count(s); i++) { + const cst_message_t *m = cst_session_get(s, i); + const char *c = m->content ? m->content : ""; + if (m->role == CST_ROLE_SYSTEM) + continue; + if (cst_message_is_tool_result(m)) { + cst_buf_printf(&b, "**Tool result** `%s`\n\n", m->tool_name); + md_fence(&b, c, 4000); + } else if (cst_message_is_note(m)) { + cst_buf_puts(&b, "> "); + cst_buf_append(&b, c, strcspn(c, "\n")); + cst_buf_puts(&b, "\n\n"); + } else if (m->role == CST_ROLE_USER) { + cst_buf_puts(&b, "## User\n\n"); + cst_buf_puts(&b, c); + cst_buf_puts(&b, "\n\n"); + for (int k = 0; k < m->nattach; k++) { + char d[128]; + cst_attach_describe(&m->attach[k], d, sizeof(d)); + cst_buf_printf(&b, "- image `%s` (%s)\n", m->attach[k].name, d); + } + if (m->nattach) + cst_buf_puts(&b, "\n"); + } else { + cst_buf_puts(&b, "## Assistant\n\n"); + cst_buf_puts(&b, c); + cst_buf_puts(&b, "\n\n"); + if (m->tool_calls_json && m->tool_calls_json[0]) { + cst_buf_puts(&b, "**Tool calls**\n\n"); + md_fence(&b, m->tool_calls_json, 4000); + } + } + } + cst_session_unlock(s); + return cst_buf_steal(&b); +} diff --git a/src/skills.c b/src/skills.c index d246aff..84ab0d9 100644 --- a/src/skills.c +++ b/src/skills.c @@ -488,6 +488,16 @@ char *cst_skill_read(const char *ws, const char *name, const char *file, return cst_buf_steal(&b); } +const char *cst_init_prompt(void) { + return "Analyze this repository and write an AGENTS.md file at the " + "workspace root for future coding agents. Cover: what the project " + "is, how to build, test and run it (exact commands), the directory " + "layout, coding conventions you can infer from the code, and " + "anything surprising. Explore with list_dir/glob/grep/read_file " + "before writing. Keep it under 150 lines. If AGENTS.md already " + "exists, improve it rather than starting over."; +} + char *cst_skill_prompt(const char *ws, const char *name, const char *task) { int code = 0; char *body = cst_skill_read(ws, name, NULL, &code); diff --git a/src/tui.c b/src/tui.c index 38060c8..53c332e 100644 --- a/src/tui.c +++ b/src/tui.c @@ -1042,10 +1042,7 @@ static int find_tool_block(tui_t *t, const char *id) { return -1; } -static int msg_is_tool_result(const cst_message_t *m) { - return m && (m->role == CST_ROLE_TOOL || - (m->role == CST_ROLE_USER && m->tool_call_id[0])); -} +#define msg_is_tool_result cst_message_is_tool_result /* Strip the "[tool …]" / "result exit=N " header and "end" trailer. */ static void tool_result_view(const char *content, const char **body, @@ -4774,12 +4771,6 @@ static void cmd_diff(tui_t *t, char *arg, int *quit) { free(df.out); } -static void md_fence(FILE *fp, const char *s, size_t max) { - size_t n = strlen(s); - fprintf(fp, "````\n%.*s%s\n````\n\n", (int)(n > max ? max : n), s, - n > max ? "\n…(truncated)" : ""); -} - static void cmd_export(tui_t *t, char *arg, int *quit) { (void)quit; const cst_agent_config_t *cfg = cst_agent_config(t->agent); @@ -4799,44 +4790,20 @@ static void cmd_export(tui_t *t, char *arg, int *quit) { *sl = 0; os_mkdir_p(dir, 0755); } - FILE *fp = fopen(path, "wb"); + char *md = cst_session_export_md(cst_agent_session(t->agent), t->title, sid, + cfg->model); + FILE *fp = md ? fopen(path, "wb") : NULL; if (!fp) { + free(md); push_system_line(t, "Error: cannot write %s", path); return; } - fprintf(fp, "# %s\n\nsession `%s` · model `%s`\n\n", t->title, sid, - cfg->model); - cst_session_t *s = cst_agent_session(t->agent); - cst_session_lock(s); - for (int i = 0; i < cst_session_count(s); i++) { - const cst_message_t *m = cst_session_get(s, i); - const char *c = m->content ? m->content : ""; - if (m->role == CST_ROLE_SYSTEM) - continue; - if (msg_is_tool_result(m)) { - fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name); - md_fence(fp, c, 4000); - } else if (cst_message_is_note(m)) { - fprintf(fp, "> %.*s\n\n", (int)strcspn(c, "\n"), c); - } else if (m->role == CST_ROLE_USER) { - fprintf(fp, "## User\n\n%s\n\n", c); - for (int k = 0; k < m->nattach; k++) { - char d[128]; - cst_attach_describe(&m->attach[k], d, sizeof(d)); - fprintf(fp, "- image `%s` (%s)\n", m->attach[k].name, d); - } - if (m->nattach) - fputc('\n', fp); - } else { - fprintf(fp, "## Assistant\n\n%s\n\n", c); - if (m->tool_calls_json && m->tool_calls_json[0]) { - fprintf(fp, "**Tool calls**\n\n"); - md_fence(fp, m->tool_calls_json, 4000); - } - } + fputs(md, fp); + free(md); + if (fclose(fp) != 0) { + push_system_line(t, "Error: cannot write %s", path); + return; } - cst_session_unlock(s); - fclose(fp); push_system_line(t, "(exported to %s)", path); } @@ -5013,14 +4980,7 @@ static void cmd_init(tui_t *t, char *arg, int *quit) { (void)arg; (void)quit; free(t->pending_prompt); - t->pending_prompt = xstrdup( - "Analyze this repository and write an AGENTS.md file at the workspace " - "root for future coding agents. Cover: what the project is, how to " - "build, test and run it (exact commands), the directory layout, coding " - "conventions you can infer from the code, and anything surprising. " - "Explore with list_dir/glob/grep/read_file before writing. Keep it " - "under 150 lines. If AGENTS.md already exists, improve it rather than " - "starting over."); + t->pending_prompt = xstrdup(cst_init_prompt()); } static void cmd_compact(tui_t *t, char *arg, int *quit) { diff --git a/src/webui.c b/src/webui.c index c2d6e86..1104503 100644 --- a/src/webui.c +++ b/src/webui.c @@ -14,14 +14,22 @@ #include "cst_agent.h" #include "cst_attach.h" +#include "cst_ext.h" #include "cst_http.h" +#include "cst_mcp.h" #include "cst_models.h" +#include "cst_perms.h" +#include "cst_plugins.h" #include "cst_provider_switch.h" +#include "cst_sandbox.h" +#include "cst_skills.h" +#include "cst_subagent.h" #include "cst_tools.h" #include "cst_util.h" #include "cst_web_md.h" #include "cst_webui.h" #include "cJSON.h" +#include "mm_tool.h" #include "os_compat.h" #include @@ -62,6 +70,7 @@ typedef struct wui { int need_auth; const char *root; /* --web-dev: serve assets from disk */ int perm_timeout_s; + int show_thinking; /* settings.json: reasoning starts expanded */ pthread_mutex_t mu; pthread_cond_t cv; /* permission / ask_user answers */ @@ -257,6 +266,7 @@ static cJSON *wui_state_new(wui_t *w) { cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval)); cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol)); cJSON_AddBoolToObject(o, "plan_mode", cfg->plan_mode); + cJSON_AddBoolToObject(o, "show_thinking", w->show_thinking); cJSON_AddBoolToObject(o, "status_busy", w->busy); cJSON_AddStringToObject(o, "todo", cst_agent_todo(w->agent) ? cst_agent_todo(w->agent) @@ -307,6 +317,126 @@ static void wui_emit_state(wui_t *w) { /* --- session -------------------------------------------------------------- */ +static cJSON *call_json(const ctl_tool_call_t *call); + +/* A text-protocol reply keeps its call frames in content: the page gets the + * prose and the calls apart. */ +typedef struct { + cst_buf_t text; + cJSON *calls; + int bad; +} wui_split_t; + +static void split_ev(const ctl_event_t *ev, void *ud) { + wui_split_t *s = ud; + if (ev->type == CTL_EV_TEXT) + cst_buf_append(&s->text, ev->text, ev->text_len); + else if (ev->type == CTL_EV_CALL_READY && ev->call) + cJSON_AddItemToArray(s->calls, call_json(ev->call)); + else if (ev->type == CTL_EV_ERROR) + s->bad = 1; +} + +static int msg_protocol(const cst_message_t *m, cst_protocol_t *p) { + cJSON *o = m->meta_json ? cJSON_Parse(m->meta_json) : NULL; + const cJSON *pr = cJSON_GetObjectItemCaseSensitive(o, "protocol"); + int rc = cJSON_IsString(pr) ? cst_protocol_parse(pr->valuestring, p) : -1; + cJSON_Delete(o); + return rc; +} + +/* Visible text (malloc'd) and calls of a text-protocol reply; NULL when the + * frames do not parse, and the raw text is shown as it is. */ +static char *split_reply(const char *s, cst_protocol_t p, cJSON **calls) { + wui_split_t sp; + memset(&sp, 0, sizeof(sp)); + sp.calls = cJSON_CreateArray(); + if (!sp.calls) + return NULL; + size_t n = strlen(s); + if (p == CST_PROTO_XML) { + mm_scan_t *sc = mm_scan_create(split_ev, &sp); + sp.bad = !sc; + if (sc) { + mm_scan_feed(sc, s, n); + mm_scan_finish(sc); + mm_scan_destroy(sc); + } + } else { + ctl_parser_t *cp = ctl_parser_create(split_ev, &sp); + sp.bad = !cp; + if (cp) { + ctl_parser_feed(cp, s, n); + ctl_parser_finish(cp); + ctl_parser_destroy(cp); + } + } + if (sp.bad || !cJSON_GetArraySize(sp.calls)) { + cst_buf_free(&sp.text); + cJSON_Delete(sp.calls); + return NULL; + } + *calls = sp.calls; + char *t = cst_buf_steal(&sp.text); + return t ? t : strdup(""); +} + +/* Native tool_calls as [{id, name, args}], args parsed when they are JSON. */ +static cJSON *native_calls(const char *tool_calls_json) { + cJSON *in = cJSON_Parse(tool_calls_json); + cJSON *out = cJSON_IsArray(in) ? cJSON_CreateArray() : NULL; + const cJSON *src = out ? in : NULL; + const cJSON *it; + cJSON_ArrayForEach(it, src) { + const cJSON *fn = cJSON_GetObjectItemCaseSensitive(it, "function"); + const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id"); + const cJSON *nm = cJSON_GetObjectItemCaseSensitive(fn, "name"); + const cJSON *ar = cJSON_GetObjectItemCaseSensitive(fn, "arguments"); + cJSON *o = cJSON_CreateObject(); + if (!o) + continue; + cJSON_AddStringToObject(o, "id", cJSON_IsString(id) ? id->valuestring : ""); + cJSON_AddStringToObject(o, "name", cJSON_IsString(nm) ? nm->valuestring : ""); + cJSON *args = cJSON_IsString(ar) ? cJSON_Parse(ar->valuestring) : NULL; + if (cJSON_IsObject(args)) + cJSON_AddItemToObject(o, "args", args); + else { + cJSON_Delete(args); + cJSON_AddItemToObject(o, "args", cJSON_CreateObject()); + } + cJSON_AddItemToArray(out, o); + } + cJSON_Delete(in); + return out; +} + +/* Body and exit code of a stored tool result: "[tool NAME id=ID exit=N]\n" + * (native) or "result ID exit=N LEN\n...\nend" (text protocols). */ +static void tool_out_view(const char *s, const char **body, size_t *len, + int *exit_code) { + size_t n = strlen(s); + *exit_code = 0; + int framed = strncmp(s, "result ", 7) == 0; + if (framed || strncmp(s, "[tool ", 6) == 0) { + const char *nl = strchr(s, '\n'); + const char *ex = strstr(s, " exit="); + if (ex && (!nl || ex < nl)) + *exit_code = atoi(ex + 6); + if (nl) { + n -= (size_t)(nl + 1 - s); + s = nl + 1; + } else { + n = 0; + } + if (framed && n >= 4 && strcmp(s + n - 4, "\nend") == 0) + n -= 4; + else if (framed && n == 3 && strcmp(s, "end") == 0) + n = 0; + } + *body = s; + *len = n; +} + static cJSON *msg_json(const cst_message_t *m) { cJSON *o = cJSON_CreateObject(); if (!o) @@ -320,15 +450,50 @@ static cJSON *msg_json(const cst_message_t *m) { } cJSON_AddStringToObject(o, "role", role); cJSON_AddStringToObject(o, "content", m->content ? m->content : ""); - if (m->role == CST_ROLE_ASSISTANT && m->content && m->content[0]) { - char *html = cst_web_md_html(m->content, strlen(m->content)); + cJSON *calls = NULL; + char *prose = NULL; + cst_protocol_t proto; + if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json && + m->tool_calls_json[0]) + calls = native_calls(m->tool_calls_json); + else if (m->role == CST_ROLE_ASSISTANT && m->content && m->content[0] && + msg_protocol(m, &proto) == 0 && cst_protocol_is_text(proto)) + prose = split_reply(m->content, proto, &calls); + if (prose) + cJSON_AddStringToObject(o, "text", prose); + if (calls) + cJSON_AddItemToObject(o, "calls", calls); + const char *shown = prose ? prose : m->content; + if (m->role == CST_ROLE_ASSISTANT && shown && shown[0]) { + char *html = cst_web_md_html(shown, strlen(shown)); if (html) { cJSON_AddStringToObject(o, "html", html); free(html); } } + free(prose); + if (cst_message_is_tool_result(m)) { + const char *body; + size_t blen; + int ex; + tool_out_view(m->content ? m->content : "", &body, &blen, &ex); + size_t cap = blen > 32 * 1024 ? cst_utf8_floor(body, 32 * 1024) : blen; + char *out = malloc(cap + 1); + if (out) { + memcpy(out, body, cap); + out[cap] = 0; + cJSON_AddStringToObject(o, "out", out); + free(out); + } + cJSON_AddBoolToObject(o, "truncated", cap < blen); + cJSON_AddNumberToObject(o, "exit", ex); + } if (m->reasoning && m->reasoning[0]) cJSON_AddStringToObject(o, "reasoning", m->reasoning); + if (cst_message_is_note(m)) + cJSON_AddBoolToObject(o, "note", 1); + if (cst_message_is_tool_result(m)) + cJSON_AddBoolToObject(o, "tool_result", 1); if (m->tool_call_id[0]) cJSON_AddStringToObject(o, "tool_call_id", m->tool_call_id); if (m->tool_name[0]) @@ -870,9 +1035,14 @@ static void *wui_turn_main(void *arg) { return NULL; } -static int wui_start_turn(wui_t *w, const char *text, char *err, size_t err_sz) { +/* claimed: the caller holds op_busy (wui_claim); it passes to the turn, and is + * released either way. */ +static int wui_start_turn(wui_t *w, const char *text, int claimed, char *err, + size_t err_sz) { pthread_mutex_lock(&w->mu); - if (w->busy || w->op_busy) { + if (claimed) + w->op_busy = 0; + else if (w->busy || w->op_busy) { pthread_mutex_unlock(&w->mu); snprintf(err, err_sz, w->busy ? "a turn is already running" : "another change is running"); @@ -931,7 +1101,7 @@ static int h_turn(cst_http_conn_t *c, void *ud) { return cst_http_send_err(c, 400, "expected {\"text\": \"...\"}"); } char err[256] = ""; - int rc = wui_start_turn(w, text->valuestring, err, sizeof(err)); + int rc = wui_start_turn(w, text->valuestring, 0, err, sizeof(err)); cJSON_Delete(body); if (rc != 0) return cst_http_send_err(c, 409, err); @@ -1146,6 +1316,9 @@ static int h_workspace_x(cst_http_conn_t *c, void *ud) { cJSON_Delete(j); if (rc != 0) return cst_http_send_err(c, 400, err[0] ? err : "cannot switch workspace"); + /* MCP servers, plugins and the task tool are bound to a workspace */ + cst_extensions_unload(); + cst_extensions_load(cst_agent_config(w->agent), NULL, 0); char id[128] = ""; if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0) return cst_http_send_err(c, 500, "cannot start a session there"); @@ -1178,6 +1351,151 @@ static int h_session_switch_x(cst_http_conn_t *c, void *ud) { "{\"ok\":true}"); } +/* Like wui_claim but a running turn is fine: for work that reads the session + * object (a turn only appends to it, under its lock) or the store. */ +static int wui_claim_read(wui_t *w) { + pthread_mutex_lock(&w->mu); + int ok = !w->op_busy; + if (ok) + w->op_busy = 1; + pthread_mutex_unlock(&w->mu); + return ok ? 0 : -1; +} + +/* POST /api/sessions/rename {"id"?: "...", "title": "..."}; "" clears it. */ +static int h_session_rename(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cJSON *j = wui_body(c); + const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL; + const cJSON *title = j ? cJSON_GetObjectItem(j, "title") : NULL; + if (!cJSON_IsString(title) || (id && !cJSON_IsString(id))) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, "需要 {\"title\": \"…\", \"id\": 可选}"); + } + if (wui_claim_read(w) != 0) { + cJSON_Delete(j); + return cst_http_send_err(c, 409, "another change is running"); + } + const cst_agent_config_t *cfg = cst_agent_config(w->agent); + char sid[128], t[121]; + if (id && id->valuestring[0]) + snprintf(sid, sizeof(sid), "%s", id->valuestring); + else + cst_agent_session_id(w->agent, sid, sizeof(sid)); + size_t tn = strlen(title->valuestring); + if (tn > sizeof(t) - 1) + tn = cst_utf8_floor(title->valuestring, sizeof(t) - 1); + memcpy(t, title->valuestring, tn); + t[tn] = 0; + cJSON_Delete(j); + cst_session_info_t in; + int rc = cst_agent_store_info(cfg->workspace_root, sid, &in) != 0 ? 404 + : cst_agent_store_title_set(cfg->workspace_root, sid, t) != 0 ? 500 + : 200; + wui_release(w); + if (rc == 404) + return cst_http_send_err(c, 404, "no such session"); + if (rc == 500) + return cst_http_send_err(c, 500, "cannot set the title"); + return cst_http_send_str(c, 200, "application/json; charset=utf-8", + "{\"ok\":true}"); +} + +/* POST /api/sessions/delete {"id": "..."}. Deleting the open session first + * moves to a new one. */ +static int h_session_delete_x(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cJSON *j = wui_body(c); + const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL; + if (!cJSON_IsString(id) || !id->valuestring[0]) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, "需要 {\"id\": \"…\"}"); + } + char victim[128]; + snprintf(victim, sizeof(victim), "%s", id->valuestring); + cJSON_Delete(j); + const cst_agent_config_t *cfg = cst_agent_config(w->agent); + cst_session_info_t in; + if (cst_agent_store_info(cfg->workspace_root, victim, &in) != 0) + return cst_http_send_err(c, 404, "no such session"); + char cur[128]; + cst_agent_session_id(w->agent, cur, sizeof(cur)); + int was_current = strcmp(cur, victim) == 0; + if (was_current) { + if (cst_agent_new_session(w->agent, cur, sizeof(cur)) != 0) + return cst_http_send_err(c, 500, "cannot start a session"); + wui_emit_str(w, "session", "id", cur); + wui_emit_state(w); + } + if (cst_agent_store_delete(cfg->workspace_root, victim) != 0) + return cst_http_send_err(c, 500, "cannot delete the session"); + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddBoolToObject(o, "ok", 1); + cJSON_AddStringToObject(o, "current", cur); + } + return wui_send_obj(c, 200, o); +} + +/* POST /api/retry {"text"?: "..."}: drop the last prompt and everything after + * it, then run it again, or text in its place. File changes are kept. */ +static int h_retry(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cJSON *j = wui_body(c); + const cJSON *text = j ? cJSON_GetObjectItem(j, "text") : NULL; + if (text && (!cJSON_IsString(text) || !text->valuestring[0])) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, "text 不能为空"); + } + if (wui_claim(w) != 0) { + cJSON_Delete(j); + return cst_http_send_err(c, 409, "busy: wait for the running turn"); + } + char *prompt = cst_agent_rewind_last_turn(w->agent); + if (!prompt) { + wui_release(w); + cJSON_Delete(j); + return cst_http_send_err(c, 409, "nothing to retry"); + } + const char *use = text ? text->valuestring : prompt; + char err[256] = ""; + int rc = wui_start_turn(w, use, 1, err, sizeof(err)); + cJSON *o = rc == 0 ? cJSON_CreateObject() : NULL; + if (o) { + cJSON_AddBoolToObject(o, "ok", 1); + cJSON_AddStringToObject(o, "text", use); + } + free(prompt); + cJSON_Delete(j); + if (rc != 0) { + cJSON_Delete(o); + return cst_http_send_err(c, 500, err); + } + return wui_send_obj(c, 202, o); +} + +/* GET /api/export: the open session as Markdown. */ +static int h_export(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + if (wui_claim_read(w) != 0) + return cst_http_send_err(c, 409, "another change is running"); + const cst_agent_config_t *cfg = cst_agent_config(w->agent); + char sid[128]; + cst_agent_session_id(w->agent, sid, sizeof(sid)); + cst_session_info_t in; + const char *title = sid; + if (cst_agent_store_info(cfg->workspace_root, sid, &in) == 0) + title = in.title[0] ? in.title : in.first_user[0] ? in.first_user : sid; + char *md = cst_session_export_md(cst_agent_session(w->agent), title, sid, + cfg->model); + wui_release(w); + if (!md) + return cst_http_send_err(c, 500, "out of memory"); + int rc = cst_http_send_str(c, 200, "text/markdown; charset=utf-8", md); + free(md); + return rc; +} + static int h_config(cst_http_conn_t *c, void *ud) { wui_t *w = ud; cst_agent_config_t *cfg = cst_agent_config_mut(w->agent); @@ -1192,6 +1510,7 @@ static int h_config(cst_http_conn_t *c, void *ud) { const cJSON *effort = cJSON_GetObjectItem(j, "effort"); const cJSON *approval = cJSON_GetObjectItem(j, "approval"); const cJSON *plan = cJSON_GetObjectItem(j, "plan_mode"); + const cJSON *think = cJSON_GetObjectItem(j, "show_thinking"); const char *bad = NULL; if (cJSON_GetObjectItem(j, "provider")) bad = "切换提供商请用 POST /api/provider"; @@ -1206,6 +1525,8 @@ static int h_config(cst_http_conn_t *c, void *ud) { bad = "审批模式只能是 ask / auto / yolo"; if (!bad && plan && !cJSON_IsBool(plan)) bad = "plan_mode 必须是 true 或 false"; + if (!bad && think && !cJSON_IsBool(think)) + bad = "show_thinking 必须是 true 或 false"; if (bad) { cJSON_Delete(j); return cst_http_send_err(c, 400, bad); @@ -1231,6 +1552,12 @@ static int h_config(cst_http_conn_t *c, void *ud) { } if (plan) cfg->plan_mode = cJSON_IsTrue(plan) ? 1 : 0; + if (think) { + pthread_mutex_lock(&w->mu); + w->show_thinking = cJSON_IsTrue(think) ? 1 : 0; + pthread_mutex_unlock(&w->mu); + cst_settings_remember_thinking(cJSON_IsTrue(think)); + } cJSON *o = cJSON_CreateObject(); if (o) { cJSON_AddBoolToObject(o, "ok", 1); @@ -1245,6 +1572,262 @@ static int h_config(cst_http_conn_t *c, void *ud) { return wui_send_obj(c, 200, o); } +/* GET /api/diff[?path=rel]: git status and the working-tree diff. git runs + * without a shell, external diff drivers or textconv, for at most 10 s. */ +#define WUI_DIFF_CAP (512u * 1024u) +static int h_diff(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + char path[1024] = ""; + cst_http_query(cst_http_request(c), "path", path, sizeof(path)); + char ws[sizeof(((cst_agent_config_t *)0)->workspace_root)]; + pthread_mutex_lock(&w->mu); + int ok = !w->op_busy; + if (ok) + snprintf(ws, sizeof(ws), "%s", cst_agent_config(w->agent)->workspace_root); + pthread_mutex_unlock(&w->mu); + if (!ok) + return cst_http_send_err(c, 409, "another change is running"); + os_set_run_timeout(10); + os_run_result_t st, df; + memset(&st, 0, sizeof(st)); + memset(&df, 0, sizeof(df)); + const char *sv[] = {"git", "status", "--short", "--untracked-files=all", NULL}; + int git = os_run_capture(ws, sv, 64 * 1024, &st) == 0 && st.exit_code == 0; + const char *dv[] = {"git", "--no-pager", "diff", "--no-color", + "--no-ext-diff", "--no-textconv", "--", + path[0] ? path : NULL, NULL}; + if (git) + os_run_capture(ws, dv, WUI_DIFF_CAP, &df); + os_set_run_timeout(0); + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddBoolToObject(o, "git", git); + cJSON_AddStringToObject(o, "path", path); + if (git) { + cJSON *files = cJSON_AddArrayToObject(o, "files"); + const char *s = st.out ? st.out : ""; + while (files && *s) { + size_t n = strcspn(s, "\n"); + if (n > 3) { + char code[3] = {s[0], s[1], 0}, name[1024]; + snprintf(name, sizeof(name), "%.*s", (int)(n - 3), s + 3); + cJSON *f = cJSON_CreateObject(); + if (f) { + cJSON_AddStringToObject(f, "status", code); + cJSON_AddStringToObject(f, "path", name); + cJSON_AddItemToArray(files, f); + } + } + s += n + (s[n] == '\n'); + } + size_t dl = df.out ? cst_utf8_floor(df.out, df.out_len) : 0; + char *d = malloc(dl + 1); + if (d) { + memcpy(d, df.out ? df.out : "", dl); + d[dl] = 0; + cJSON_AddStringToObject(o, "diff", d); + free(d); + } + cJSON_AddBoolToObject(o, "truncated", df.out_len >= WUI_DIFF_CAP); + if (df.exit_code != 0 && df.exit_code != 1) + cJSON_AddNumberToObject(o, "exit", df.exit_code); + } + } + os_run_result_free(&st); + os_run_result_free(&df); + return wui_send_obj(c, 200, o); +} + +static const char *const wui_perm_names[] = {"read", "write", "shell", + "net", "ask", "meta"}; + +/* GET /api/settings: what POST /api/settings can change, plus the sandbox. */ +static int h_settings_get(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cst_agent_config_t *cfg = malloc(sizeof(*cfg)); + if (!cfg) + return cst_http_send_err(c, 500, "out of memory"); + /* held to the end: an extensions reload frees the tools listed below */ + if (wui_claim_read(w) != 0) { + free(cfg); + return cst_http_send_err(c, 409, "正在切换设置,稍后再试"); + } + *cfg = *cst_agent_config(w->agent); + memset(cfg->api_key, 0, sizeof(cfg->api_key)); + cJSON *o = cJSON_CreateObject(); + if (!o) { + wui_release(w); + free(cfg); + return cst_http_send_err(c, 500, "out of memory"); + } + cJSON_AddStringToObject(o, "protocol", cst_protocol_name(cfg->protocol)); + cJSON *ps = cJSON_AddArrayToObject(o, "protocols"); + for (int p = CST_PROTO_NATIVE_EOS; ps && p <= CST_PROTO_XML; p++) + cJSON_AddItemToArray(ps, cJSON_CreateString(cst_protocol_name(p))); + cJSON_AddNumberToObject(o, "max_steps", cfg->max_steps); + cJSON_AddStringToObject(o, "tool_profile", + cst_tool_profile_name(cfg->tool_profile)); + cJSON *pr = cJSON_AddArrayToObject(o, "profiles"); + for (int p = CST_TOOLS_DEFAULT; pr && p <= CST_TOOLS_MINIMAL; p++) + cJSON_AddItemToArray(pr, cJSON_CreateString(cst_tool_profile_name(p))); + cJSON *cf = cJSON_AddObjectToObject(o, "confirm"); + if (cf) { + cJSON_AddBoolToObject(cf, "shell", cfg->confirm_shell); + cJSON_AddBoolToObject(cf, "write", cfg->confirm_write); + cJSON_AddBoolToObject(cf, "net", cfg->confirm_net); + } + cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval)); + cJSON *tools = cJSON_AddArrayToObject(o, "tools"); + for (int i = 0; tools && i < cst_tool_count(); i++) { + const cst_tool_def_t *d = cst_tool_at(i); + cJSON *t = d ? cJSON_CreateObject() : NULL; + if (!t) + continue; + const char *desc = d->desc ? d->desc : ""; + char first[160]; + size_t dl = strcspn(desc, ".\n"); + dl = cst_utf8_floor(desc, dl < sizeof(first) - 1 ? dl : sizeof(first) - 1); + memcpy(first, desc, dl); + first[dl] = 0; + cJSON_AddStringToObject(t, "name", d->name); + cJSON_AddStringToObject(t, "perm", (int)d->perm < 6 ? wui_perm_names[d->perm] + : "?"); + cJSON_AddBoolToObject(t, "on", cst_tool_active(cfg, d)); + cJSON_AddBoolToObject(t, "off", cst_tool_disabled(cfg, d->name)); + cJSON_AddStringToObject(t, "desc", first); + if (d->owner) + cJSON_AddStringToObject(t, "owner", d->owner); + cJSON_AddItemToArray(tools, t); + } + wui_release(w); + int sk = cst_sandbox_probe(); + cJSON *sb = cJSON_AddObjectToObject(o, "sandbox"); + if (sb) { + cJSON_AddStringToObject(sb, "kind", cst_sandbox_name(sk)); + cJSON_AddBoolToObject(sb, "active", sk != CST_SANDBOX_NONE); + char *l = cst_sandbox_labels(); + if (l) + cJSON_AddStringToObject(sb, "labels", l); + free(l); + } + free(cfg); + return wui_send_obj(c, 200, o); +} + +/* POST /api/settings {protocol?, max_steps?, tool_profile?, + * tools?: {name: on}, confirm?: {shell?, write?, net?}}. All checked before + * any of it applies; this run only, like the TUI commands. */ +static int h_settings_x(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cst_agent_config_t *cfg = cst_agent_config_mut(w->agent); + cJSON *j = wui_body(c); + if (!cJSON_IsObject(j)) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, "expected a JSON object"); + } + const cJSON *proto = cJSON_GetObjectItem(j, "protocol"); + const cJSON *steps = cJSON_GetObjectItem(j, "max_steps"); + const cJSON *prof = cJSON_GetObjectItem(j, "tool_profile"); + const cJSON *tools = cJSON_GetObjectItem(j, "tools"); + const cJSON *conf = cJSON_GetObjectItem(j, "confirm"); + cst_protocol_t p = cfg->protocol; + int profile = cfg->tool_profile; + char bad[200] = ""; + if (proto && (!cJSON_IsString(proto) || + cst_protocol_parse(proto->valuestring, &p) != 0)) + snprintf(bad, sizeof(bad), "未知的工具协议"); + else if (steps && (!cJSON_IsNumber(steps) || steps->valuedouble < 1 || + steps->valuedouble > 1000)) + snprintf(bad, sizeof(bad), "max_steps 要在 1 到 1000 之间"); + else if (prof && (!cJSON_IsString(prof) || + cst_tool_profile_parse(prof->valuestring, &profile) != 0)) + snprintf(bad, sizeof(bad), "未知的工具集(default 或 minimal)"); + else if (tools && !cJSON_IsObject(tools)) + snprintf(bad, sizeof(bad), "tools 要是 {\"名字\": true/false}"); + else if (conf && !cJSON_IsObject(conf)) + snprintf(bad, sizeof(bad), "confirm 要是 {shell, write, net}"); + for (const cJSON *t = tools ? tools->child : NULL; !bad[0] && t; t = t->next) + if (!cJSON_IsBool(t) || cst_tool_index(t->string) < 0) + snprintf(bad, sizeof(bad), "未知的工具或取值:%.80s", t->string); + for (const cJSON *t = conf ? conf->child : NULL; !bad[0] && t; t = t->next) + if (!cJSON_IsBool(t) || (strcmp(t->string, "shell") != 0 && + strcmp(t->string, "write") != 0 && + strcmp(t->string, "net") != 0)) + snprintf(bad, sizeof(bad), "未知的确认项:%.80s", t->string); + if (bad[0]) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, bad); + } + cfg->protocol = p; + cfg->tool_profile = profile; + if (steps) + cfg->max_steps = (int)steps->valuedouble; + for (const cJSON *t = tools ? tools->child : NULL; t; t = t->next) + cst_tool_set_disabled(cfg, t->string, cJSON_IsFalse(t)); + for (const cJSON *t = conf ? conf->child : NULL; t; t = t->next) { + int on = cJSON_IsTrue(t); + if (strcmp(t->string, "shell") == 0) + cfg->confirm_shell = on; + else if (strcmp(t->string, "write") == 0) + cfg->confirm_write = on; + else + cfg->confirm_net = on; + } + cJSON_Delete(j); + wui_emit_state(w); + return cst_http_send_str(c, 200, "application/json; charset=utf-8", + "{\"ok\":true}"); +} + +/* GET /api/permissions: the saved always-allow rules, read from disk (the + * agent's copy is the turn's to change). */ +static int h_perms_get(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + char ws[sizeof(((cst_agent_config_t *)0)->workspace_root)]; + pthread_mutex_lock(&w->mu); + int ok = !w->op_busy; + if (ok) + snprintf(ws, sizeof(ws), "%s", cst_agent_config(w->agent)->workspace_root); + pthread_mutex_unlock(&w->mu); + if (!ok) + return cst_http_send_err(c, 409, "another change is running"); + cst_perms_t *p = cst_perms_open(ws); + char *d = p ? cst_perms_describe(p) : NULL; + cst_perms_close(p); + char path[sizeof(ws) + 32], *raw = NULL; + size_t rn = 0; + snprintf(path, sizeof(path), "%s/.agent/permissions.json", ws); + cJSON *saved = read_file_all(path, &raw, &rn) == 0 + ? cJSON_ParseWithLength(raw, rn) + : NULL; + free(raw); + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddStringToObject(o, "rules", d ? d : ""); + cJSON_AddStringToObject(o, "file", ".agent/permissions.json"); + static const char *const keys[] = {"tools", "shell_prefixes"}; + for (int k = 0; k < 2; k++) { + cJSON *arr = cJSON_AddArrayToObject(o, keys[k]); + const cJSON *it; + cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(saved, keys[k])) + if (arr && cJSON_IsString(it)) + cJSON_AddItemToArray(arr, cJSON_CreateString(it->valuestring)); + } + } + cJSON_Delete(saved); + free(d); + return wui_send_obj(c, 200, o); +} + +/* DELETE /api/permissions: forget every saved rule. */ +static int h_perms_reset_x(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + if (cst_perms_reset(cst_agent_perms(w->agent)) != 0) + return cst_http_send_err(c, 500, "cannot reset the rules"); + return cst_http_send_str(c, 200, "application/json; charset=utf-8", + "{\"ok\":true}"); +} + /* GET /api/providers: the presets, whether each has a key and from where. * Never the key itself. */ static int h_providers(cst_http_conn_t *c, void *ud) { @@ -1483,9 +2066,223 @@ static int h_attach_clear_x(cst_http_conn_t *c, void *ud) { "{\"ok\":true}"); } +/* --- /init, /skill and extensions ------------------------------------------ */ + +static int wui_turn_reply(cst_http_conn_t *c, const char *text) { + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddBoolToObject(o, "ok", 1); + cJSON_AddStringToObject(o, "text", text); + } + return wui_send_obj(c, 202, o); +} + +/* POST /api/init: a turn that has the agent write AGENTS.md. */ +static int h_init(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + char err[256] = ""; + if (wui_start_turn(w, cst_init_prompt(), 0, err, sizeof(err)) != 0) + return cst_http_send_err(c, 409, err); + return wui_turn_reply(c, cst_init_prompt()); +} + +/* POST /api/skill {"name": "...", "task"?: "..."}: a turn with the skill's + * instructions inlined before the task. */ +static int h_skill(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cJSON *j = wui_body(c); + const cJSON *name = j ? cJSON_GetObjectItem(j, "name") : NULL; + const cJSON *task = j ? cJSON_GetObjectItem(j, "task") : NULL; + if (!cJSON_IsString(name) || !name->valuestring[0] || + strlen(name->valuestring) > 63 || (task && !cJSON_IsString(task))) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, "需要 {\"name\": \"skill 名\", \"task\": 可选}"); + } + if (wui_claim(w) != 0) { + cJSON_Delete(j); + return cst_http_send_err(c, 409, "busy: wait for the running turn"); + } + char *p = cst_skill_prompt(cst_agent_config(w->agent)->workspace_root, + name->valuestring, task ? task->valuestring : ""); + cJSON_Delete(j); + if (!p) { + wui_release(w); + return cst_http_send_err(c, 404, "no such skill"); + } + char err[256] = ""; + int rc = wui_start_turn(w, p, 1, err, sizeof(err)); + rc = rc == 0 ? wui_turn_reply(c, p) : cst_http_send_err(c, 500, err); + free(p); + return rc; +} + +static const char *wui_mcp_state(int st) { + switch (st) { + case CST_MCP_STARTING: return "starting"; + case CST_MCP_READY: return "ready"; + case CST_MCP_FAILED: return "failed"; + case CST_MCP_EXITED: return "exited"; + default: return "off"; + } +} + +#define WUI_RUNS_MAX 64 + +/* GET /api/extensions: MCP servers, plugins, skills, sub-agent roles and the + * open session's sub-agent runs. */ +static int h_extensions(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + if (wui_claim_read(w) != 0) + return cst_http_send_err(c, 409, "another change is running"); + const cst_agent_config_t *cfg = cst_agent_config(w->agent); + const char *ws = cfg->workspace_root; + char sid[128]; + cst_agent_session_id(w->agent, sid, sizeof(sid)); + cJSON *o = cJSON_CreateObject(); + cst_mcp_info_t *mi = calloc(CST_MCP_MAX, sizeof(*mi)); + cst_plugin_info_t *pi = calloc(CST_PLUGINS_MAX, sizeof(*pi)); + cst_skill_t *sk = calloc(CST_SKILLS_MAX, sizeof(*sk)); + cst_agent_role_t *ro = calloc(CST_ROLES_MAX, sizeof(*ro)); + cst_subagent_rec_t *ru = calloc(WUI_RUNS_MAX, sizeof(*ru)); + if (!o || !mi || !pi || !sk || !ro || !ru) { + wui_release(w); + cJSON_Delete(o); + free(mi); + free(pi); + free(sk); + free(ro); + free(ru); + return cst_http_send_err(c, 500, "out of memory"); + } + cJSON_AddBoolToObject(o, "enabled", cfg->tool_profile != CST_TOOLS_MINIMAL); + cJSON_AddBoolToObject(o, "trusted", cst_settings_trusted(ws)); + cJSON_AddStringToObject(o, "report", cst_extensions_report()); + cJSON_AddBoolToObject(o, "task_tool", cst_tool_find("task") != NULL); + + cJSON *a = cJSON_AddArrayToObject(o, "mcp"); + int n = cst_mcp_info(mi, CST_MCP_MAX); + for (int i = 0; a && i < n; i++) { + cJSON *e = cJSON_CreateObject(); + if (!e) + continue; + cJSON_AddStringToObject(e, "name", mi[i].name); + cJSON_AddStringToObject(e, "source", mi[i].source); + cJSON_AddStringToObject(e, "command", mi[i].command); + cJSON_AddStringToObject(e, "state", wui_mcp_state(mi[i].state)); + cJSON_AddNumberToObject(e, "tools", mi[i].ntools); + cJSON_AddStringToObject(e, "error", mi[i].error); + cJSON_AddItemToArray(a, e); + } + a = cJSON_AddArrayToObject(o, "plugins"); + n = cst_plugins_info(pi, CST_PLUGINS_MAX); + for (int i = 0; a && i < n; i++) { + cJSON *e = cJSON_CreateObject(); + if (!e) + continue; + cJSON_AddStringToObject(e, "name", pi[i].name); + cJSON_AddStringToObject(e, "source", pi[i].source); + cJSON_AddStringToObject(e, "desc", pi[i].desc); + cJSON_AddNumberToObject(e, "tools", pi[i].ntools); + cJSON_AddNumberToObject(e, "mcp", pi[i].nmcp); + cJSON_AddBoolToObject(e, "skills", pi[i].nskills_root); + cJSON_AddStringToObject(e, "error", pi[i].error); + cJSON_AddItemToArray(a, e); + } + a = cJSON_AddArrayToObject(o, "skills"); + n = cst_skills_list(ws, sk, CST_SKILLS_MAX); + for (int i = 0; a && i < n; i++) { + cJSON *e = cJSON_CreateObject(); + if (!e) + continue; + cJSON_AddStringToObject(e, "name", sk[i].name); + cJSON_AddStringToObject(e, "source", sk[i].source); + cJSON_AddStringToObject(e, "desc", sk[i].desc); + cJSON_AddItemToArray(a, e); + } + a = cJSON_AddArrayToObject(o, "roles"); + n = cst_roles_list(ws, ro, CST_ROLES_MAX); + for (int i = 0; a && i < n; i++) { + cJSON *e = cJSON_CreateObject(); + if (!e) + continue; + cJSON_AddStringToObject(e, "name", ro[i].name); + cJSON_AddStringToObject(e, "source", ro[i].source); + cJSON_AddStringToObject(e, "desc", ro[i].desc); + cJSON_AddStringToObject(e, "model", ro[i].model); + cJSON_AddBoolToObject(e, "read_only", ro[i].read_only); + cJSON_AddItemToArray(a, e); + } + cst_roles_free(ro, n); + a = cJSON_AddArrayToObject(o, "runs"); + n = cst_subagent_list(ws, sid, ru, WUI_RUNS_MAX); + for (int i = 0; a && i < n; i++) { + cJSON *e = cJSON_CreateObject(); + if (!e) + continue; + cJSON_AddStringToObject(e, "file", ru[i].file); + cJSON_AddStringToObject(e, "role", ru[i].role); + cJSON_AddStringToObject(e, "desc", ru[i].desc); + cJSON_AddStringToObject(e, "status", ru[i].status); + cJSON_AddNumberToObject(e, "steps", ru[i].steps); + cJSON_AddNumberToObject(e, "tools", ru[i].tools); + cJSON_AddNumberToObject(e, "ms", ru[i].ms); + cJSON_AddItemToArray(a, e); + } + wui_release(w); + free(mi); + free(pi); + free(sk); + free(ro); + free(ru); + return wui_send_obj(c, 200, o); +} + +static int wui_ext_reply(wui_t *w, cst_http_conn_t *c) { + char rep[256]; + cst_extensions_unload(); + cst_extensions_load(cst_agent_config(w->agent), rep, sizeof(rep)); + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddBoolToObject(o, "ok", 1); + cJSON_AddBoolToObject( + o, "trusted", + cst_settings_trusted(cst_agent_config(w->agent)->workspace_root)); + cJSON_AddStringToObject(o, "report", rep); + } + return wui_send_obj(c, 200, o); +} + +/* POST /api/extensions/reload: stop MCP servers and plugins, read them and + * the skills again. */ +static int h_ext_reload_x(cst_http_conn_t *c, void *ud) { + return wui_ext_reply(ud, c); +} + +/* POST /api/extensions/trust {"on": bool}: whether .agent/plugins and + * .agent/mcp.json of this workspace load, then reload. */ +static int h_ext_trust_x(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cJSON *j = wui_body(c); + const cJSON *on = j ? cJSON_GetObjectItem(j, "on") : NULL; + if (!cJSON_IsBool(on)) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, "需要 {\"on\": true 或 false}"); + } + int want = cJSON_IsTrue(on); + cJSON_Delete(j); + if (cst_settings_trust(cst_agent_config(w->agent)->workspace_root, want) != 0) + return cst_http_send_err(c, 500, "cannot save the settings"); + return wui_ext_reply(w, c); +} + WUI_EXCLUSIVE(h_session_new) WUI_EXCLUSIVE(h_workspace) WUI_EXCLUSIVE(h_session_switch) +WUI_EXCLUSIVE(h_session_delete) +WUI_EXCLUSIVE(h_settings) +WUI_EXCLUSIVE(h_perms_reset) +WUI_EXCLUSIVE(h_ext_reload) +WUI_EXCLUSIVE(h_ext_trust) WUI_EXCLUSIVE(h_undo) WUI_EXCLUSIVE(h_compact) WUI_EXCLUSIVE(h_attach_upload) @@ -1678,6 +2475,11 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { w->agent = agent; w->perm_timeout_s = opts->perm_timeout_s; w->root = opts->root; + { + cst_settings_t st; + cst_settings_load(&st); + w->show_thinking = st.show_thinking; + } pthread_cond_init(&w->cv, NULL); snprintf(w->host, sizeof(w->host), "%s", opts->host && opts->host[0] ? opts->host : "127.0.0.1"); @@ -1706,6 +2508,20 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { {"GET", "/api/sessions", h_sessions, NULL, 0, 0}, {"POST", "/api/sessions/new", h_session_new, NULL, 0, 0}, {"POST", "/api/sessions/switch", h_session_switch, NULL, 0, 0}, + {"POST", "/api/sessions/rename", h_session_rename, NULL, 0, 0}, + {"POST", "/api/sessions/delete", h_session_delete, NULL, 0, 0}, + {"POST", "/api/retry", h_retry, NULL, 0, 0}, + {"GET", "/api/export", h_export, NULL, 0, 0}, + {"GET", "/api/diff", h_diff, NULL, 0, 0}, + {"GET", "/api/settings", h_settings_get, NULL, 0, 0}, + {"POST", "/api/settings", h_settings, NULL, 0, 0}, + {"GET", "/api/permissions", h_perms_get, NULL, 0, 0}, + {"DELETE", "/api/permissions", h_perms_reset, NULL, 0, 0}, + {"POST", "/api/init", h_init, NULL, 0, 0}, + {"POST", "/api/skill", h_skill, NULL, 0, 0}, + {"GET", "/api/extensions", h_extensions, NULL, 0, 0}, + {"POST", "/api/extensions/reload", h_ext_reload, NULL, 0, 0}, + {"POST", "/api/extensions/trust", h_ext_trust, NULL, 0, 0}, {"POST", "/api/workspace", h_workspace, NULL, 0, 0}, {"POST", "/api/turn", h_turn, NULL, 0, 0}, {"POST", "/api/cancel", h_cancel, NULL, 0, 0}, @@ -1773,7 +2589,7 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { if (opts->prompt && opts->prompt[0]) { char err2[256] = ""; - if (wui_start_turn(w, opts->prompt, err2, sizeof(err2)) != 0) + if (wui_start_turn(w, opts->prompt, 0, err2, sizeof(err2)) != 0) fprintf(stderr, "webui: %s\n", err2); } diff --git a/web/app.js b/web/app.js index 4ff5d6f..ea58837 100644 --- a/web/app.js +++ b/web/app.js @@ -53,6 +53,12 @@ const ERR_ZH = [ [/^a turn is already running/, '已经有一轮在运行'], [/^another change is running/, '另一项修改正在进行,稍后再试'], [/^nothing to undo/, '没有可以撤销的写入'], + [/^nothing to retry/, '没有可以重试的消息'], + [/^cannot reset the rules/, '清除许可规则失败'], + [/^expected a JSON object/, '请求格式不对'], + [/^cannot set the title/, '标题保存失败'], + [/^cannot delete the session/, '删除会话失败'], + [/^cannot start a session/, '无法新建会话'], [/^no such session/, '找不到这个会话'], [/^no such permission request/, '这个许可请求已经结束'], [/^no such question/, '这个问题已经结束'], @@ -60,13 +66,72 @@ const ERR_ZH = [ [/^out of memory/, '服务器内存不足'], [/^path is required/, '需要填写路径'], [/^empty body/, '内容为空'], + [/^no such skill/, '没有这个 skill(/skills 查看全部)'], + [/^cannot save the settings/, '设置保存失败'], + [/^nothing to compact/, '没有可以压缩的内容'], + [/^compact llm: (.*)/, (m) => '压缩时模型请求失败:' + m[1]], + [/^compact produced empty summary/, '压缩失败:模型给了空摘要'], + [/^no such directory: (.*)/, (m) => '目录不存在:' + m[1]], + [/^cannot use as a workspace: (.*)/, (m) => '不能用作工作目录:' + m[1]], + [/^at most (\d+) images per message/, (m) => '每条消息最多 ' + m[1] + ' 张图'], + [/^the images (?:already picked fill|together exceed) (\d+) MB/, (m) => '图片加起来超过 ' + m[1] + ' MB'], + [/^HTTP (\d+): the API key was rejected/, (m) => 'API key 被拒绝(HTTP ' + m[1] + ')'], + [/^not found$/, '服务端没有这个接口:页面和程序版本不一致,刷新页面试试'], ]; +/* the first match of a [regexp, text | (match) => text] table, or null */ +function zhOf(table, msg) { + for (const [re, zh] of table) { + const m = re.exec(msg); + if (m) return typeof zh === 'function' ? zh(m) : zh; + } + return null; +} function errorText(status, msg) { msg = String(msg || ''); - for (const [re, zh] of ERR_ZH) if (re.test(msg)) return zh; + const zh = zhOf(ERR_ZH, msg); + if (zh) return zh; if (status === 401 && !msg) return '没有权限:请带上 token 打开页面'; return msg || ('HTTP ' + status); } +/* the engine's one-line status messages */ +const STATUS_ZH = [ + [/^calling model \(step (\d+)\)/, (m) => '调用模型 · 第 ' + m[1] + ' 步…'], + [/^final answer pass/, '最后一次作答…'], + [/^tool round done/, '工具执行完,继续…'], + [/^contract error/, '模型的工具调用格式不对,让它重发…'], + [/^empty response/, '模型回了空内容,重试…'], + [/^output cut off/, '输出被截断,接着往下写…'], + [/^the model refused images/, '模型不收图片,改发文字说明…'], + [/^prompt (\d+)K tokens \(budget (\d+)K\)/, (m) => '上下文 ' + m[1] + 'K 超过预算 ' + m[2] + 'K,压缩中…'], + [/^context compacted/, '上下文已压缩'], + [/^done$/, '完成'], + [/^blocked by the sandbox/, '被沙箱拦下'], + [/^rerunning outside the sandbox/, '在沙箱外重跑…'], + [/^unknown tool/, '模型调用了不存在的工具'], + [/^skip repeated edit/, '跳过重复的修改'], + [/^skip duplicate read/, '跳过重复的读取'], + [/^running reads in parallel/, '并行读取中…'], +]; +const statusText = (msg) => zhOf(STATUS_ZH, String(msg || '')) || msg; +/* why a turn stopped, in words: turn_end carries the engine's error text */ +const HTTP_ZH = { + 400: '请求被拒绝', 401: 'API key 无效或已过期', 402: '余额不足', 403: '没有权限用这个模型', + 404: '模型或端点不存在', 408: '请求超时', 413: '请求太大', 429: '请求太频繁或额度用完', +}; +const TURN_ERR_ZH = [ + [/^(?:llm: )?cancelled$/, '已停止'], + [/^llm: http (\d+)(?::\s*([\s\S]*))?/i, (m) => '模型请求失败:' + + (HTTP_ZH[m[1]] || (+m[1] >= 500 ? '服务端出错' : '出错')) + '(HTTP ' + m[1] + ')' + (m[2] ? '\n' + m[2] : '')], + [/^llm: curl: (.*)/, (m) => '连不上模型服务:' + m[1]], + [/^llm: api: (.*)/, (m) => '模型服务报错:' + m[1]], + [/^llm: (.*)/, (m) => '模型请求失败:' + m[1]], + [/^(\S+): repeated contract errors/, (m) => '模型连续几次没按 ' + m[1] + + ' 协议的格式调用工具,这一轮停了。可以在运行设置里换个工具协议,或换个模型再试。'], + [/^empty model response/, '模型返回了空内容。'], + [/^step limit \((\d+)\) reached/, (m) => '这一轮用满了 ' + m[1] + ' 步上限。说"继续"接着做,或在运行设置里调大步数。'], +]; +const turnError = (d) => zhOf(TURN_ERR_ZH, String(d.error || '')) || d.error || + ('这一轮没有完成(错误码 ' + d.rc + ')'); const errMsg = (e) => (e && e.message) || String(e); let toastTimer = null; function toast(msg, kind) { @@ -107,6 +172,9 @@ let permReq = null; let askReq = null; let busy = false; let turnStart = 0; +let turnErr = null; /* why the last turn stopped; kept across reloads */ +let detailsAll = localStorage.getItem('cst.details') === '1'; /* tool lines start open */ +let shownThinking; /* state.show_thinking last applied to the page */ /* --- anchored popovers --------------------------------------------------- * The pattern the reference apps use: a small sheet under the control that @@ -268,15 +336,77 @@ function actionsFor(root, text) { root.appendChild(row); return row; } +/* "[cst] Plan mode is now ON: ..." notes the engine adds to the history; the + * model reads the English, the page shows one line per change */ +const APPROVAL_ZH = { ask: '每次询问', auto: '自动', yolo: '完全放行' }; +const NOTE_ZH = [ + [/^Plan mode is now ON/, '切到计划模式(只读)'], + [/^Plan mode is now OFF/, '切回构建模式'], + [/^Approval mode is now (\w+)/, (m) => '审批改为' + (APPROVAL_ZH[m[1]] || m[1])], + [/^The git branch is now (.+?)\.?$/, (m) => 'git 分支变为 ' + m[1]], + [/^Today's date is now (.+?)\.?$/, (m) => '日期变为 ' + m[1]], + [/^The workspace's AGENTS\.md changed/, 'AGENTS.md 有改动,已重新读入'], + [/^The workspace's AGENTS\.md was removed/, 'AGENTS.md 已删除'], +]; +function noteLine(text) { + const lines = String(text || '').split('\n').filter((l) => l.startsWith('[cst]')) + .map((l) => l.replace(/^\[cst\]\s*/, '')); + const shown = (lines.length ? lines : [String(text || '').split('\n')[0]]) + .map((l) => zhOf(NOTE_ZH, l) || l); + const n = el('div', 'note-line', shown.join(' · ')); + n.title = String(text || ''); + log.appendChild(n); +} +/* history: the stored calls become the same step lines a live turn draws, + * and each result fills its line in */ +function historyCall(c) { + const st = stepFor({ id: c.id, name: c.name }); + const a = argsLine(c.args); + if (a) st.args.textContent = a; +} +function historyResult(m) { + const st = stepFor({ id: m.tool_call_id, name: m.tool_name }); + st.root.classList.remove('run'); + st.root.classList.add('done'); + st.bad = !!m.exit; + st.root.classList.toggle('bad', st.bad); + st.phase.textContent = st.bad ? '失败 exit=' + m.exit : '完成'; + st.body.textContent = ''; + if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent)); + const out = m.out !== undefined ? m.out : (m.content || ''); + if (out) st.body.appendChild(el('pre', 'step-out', out + (m.truncated ? '\n… (截断)' : ''))); +} function renderMessage(m) { - if (m.role === 'system' || m.role === 'tool') return; + if (m.role === 'system') return; + if (m.note) return noteLine(m.content); + if (m.role === 'tool' || m.tool_result) return historyResult(m); const isUser = m.role === 'user'; - const b = bubble(isUser ? 'user' : 'assistant'); - if (!isUser && m.html) b.body.innerHTML = m.html; - else b.body.textContent = m.content || ''; - if (m.reasoning) thinkInto(b.body, m.reasoning, false); - if (m.attach && m.attach.length) b.body.insertBefore(attachStrip(m.attach), b.body.firstChild); - if (!isUser) actionsFor(b.root, m.content || ''); + if (isUser) steps = null; /* each prompt starts its own step list */ + const text = m.text !== undefined ? m.text : (m.content || ''); + if (isUser || text.trim() || m.reasoning || (m.attach && m.attach.length)) { + const b = bubble(isUser ? 'user' : 'assistant'); + if (!isUser && m.html) b.body.innerHTML = m.html; + else b.body.textContent = text; + if (m.reasoning) thinkInto(b.body, m.reasoning, !!state.show_thinking); + if (m.attach && m.attach.length) b.body.insertBefore(attachStrip(m.attach), b.body.firstChild); + if (isUser) b.root.dataset.prompt = '1'; + actionsFor(b.root, text); + } + for (const c of m.calls || []) historyCall(c); +} +/* only the newest exchange can be redone: the server rewinds one turn */ +function lastPromptActions() { + const prompts = log.querySelectorAll('.msg[data-prompt]'); + const p = prompts[prompts.length - 1]; + if (!p) return; + const row = p.querySelector('.msg-actions'); + if (row) row.appendChild(Object.assign(el('button', null, '编辑重发'), { onclick: () => editLast(p) })); + const answers = log.querySelectorAll('.msg.assistant'); + const a = answers[answers.length - 1]; + if (a && (p.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING)) { + const r = a.querySelector('.msg-actions'); + if (r) r.appendChild(Object.assign(el('button', null, '重新生成'), { onclick: () => retry() })); + } } async function loadSession() { @@ -288,8 +418,16 @@ async function loadSession() { steps = null; live = null; for (const m of data.messages) renderMessage(m); + for (const d of log.querySelectorAll('details.turn-steps')) d.open = detailsAll; + steps = null; + stepIndex = new Map(); + lastPromptActions(); + if (turnErr && turnErr.session === state.session) { + if (turnErr.stopped) noteLine('已停止'); + else bubble('error').body.textContent = turnErr.text; + } showEmpty(log.children.length === 0); - const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user'); + const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user' && !m.note && !m.tool_result); document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent'; scrollDown(true); updateJump(); @@ -321,7 +459,7 @@ function ensureSteps() { return steps; } function phaseText(p) { - return { open: '准备中', ready: '待执行', run: '执行中', done: '完成' }[p] || p || ''; + return { open: '准备中', ready: '待执行', run: '执行中', progress: '进行中', done: '完成' }[p] || p || ''; } function argsLine(args) { if (!args) return ''; @@ -335,6 +473,7 @@ function stepFor(ev) { if (st) return st; const c = ensureSteps(); const node = el('details', 'step-line run'); + node.open = detailsAll; const sum = el('summary'); const name = el('span', 'name', ev.name || 'tool'); const args = el('span', 'args'); @@ -405,21 +544,85 @@ function renderFilesPanel() { return; } for (const [p, v] of files) { - const row = el('div', 'fstat'); + const row = el('button', 'fstat'); row.appendChild(icon('file', 'fimg')); const nm = el('span', 'p', p); - nm.title = p; + row.title = '查看 ' + p + ' 的 diff'; + row.onclick = () => showDiff(p === '补丁' ? undefined : p); row.appendChild(nm); row.appendChild(el('span', 'a', '+' + v.add)); row.appendChild(el('span', 'd', '−' + v.del)); box.appendChild(row); } } +/* --- the real diff: git status + git diff of the workspace --------------- */ +function diffPre(text) { + const pre = el('pre', 'diff'); + for (const ln of String(text).split('\n')) { + let cls = ''; + if (/^(diff |index |\+\+\+ |--- |new file|deleted file|similarity|rename )/.test(ln)) cls = 'h'; + else if (ln.startsWith('@@')) cls = 'hunk'; + else if (ln.charAt(0) === '+') cls = 'add'; + else if (ln.charAt(0) === '-') cls = 'del'; + pre.appendChild(el('span', cls, ln + '\n')); + } + return pre; +} +const GIT_ST = { M: '修改', A: '新增', D: '删除', R: '重命名', C: '复制', U: '冲突', T: '类型', '?': '未跟踪', '!': '忽略' }; +function gitLabel(code) { + const c = (code || '').replace(/ /g, '').charAt(0); + return GIT_ST[c] || code || ''; +} +async function showDiff(path) { + let d; + try { d = await getJSON('api/diff' + (path ? '?path=' + encodeURIComponent(path) : '')); } + catch (e) { return toast('读取 diff 失败:' + errMsg(e)); } + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, path ? 'diff · ' + path : '工作区改动')); + const row = el('div', 'buttons'); + if (!d.git) { + box.appendChild(el('p', 'muted', '这个工作区不是 git 仓库(或者没有装 git),看不到真实 diff;右栏的行数是按工具参数估算的。')); + } else { + const list = el('div', 'diff-files'); + const fileOf = (f) => f.path.includes(' -> ') ? f.path.split(' -> ').pop() : f.path; + for (const f of d.files || []) { + const b = el('button', 'diff-file' + (fileOf(f) === path ? ' on' : '')); + b.appendChild(el('span', 'st', gitLabel(f.status))); + b.appendChild(el('span', 'p', f.path)); + b.onclick = () => showDiff(fileOf(f)); + list.appendChild(b); + } + if (!(d.files || []).length) list.appendChild(el('div', 'muted tiny', '工作区是干净的,没有改动。')); + box.appendChild(list); + if (d.diff) box.appendChild(diffPre(d.diff)); + else if ((d.files || []).length) { + const f = (d.files || []).find((x) => fileOf(x) === path); + box.appendChild(el('p', 'muted tiny', f && f.status === '??' + ? '未跟踪的新文件:git 还没有记录它,所以没有 diff。' + : '没有未暂存的改动(已 git add 的改动不在这里显示)。')); + } + if (d.truncated) box.appendChild(el('p', 'warn-text tiny', 'diff 太长,只显示了前 512 KB。')); + if (path) row.appendChild(Object.assign(el('button', null, '全部改动'), { onclick: () => showDiff() })); + row.appendChild(Object.assign(el('button', null, '刷新'), { onclick: () => showDiff(path) })); + } + row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); + box.appendChild(row); + openModal(box, true); +} + function onTool(ev) { const st = stepFor(ev); const a = argsLine(ev.args); if (a) st.args.textContent = a; - if (ev.detail && !st.args.textContent) st.args.textContent = ev.detail; + if (ev.phase === 'progress' && ev.detail) { + /* a sub-agent's latest steps: "explore · grep foo · read_file x" */ + if (!st.detail) { + st.detail = el('span', 'step-detail'); + st.sum.insertBefore(st.detail, st.phase); + } + st.detail.textContent = ev.detail; + $('status').textContent = ev.detail; + } else if (ev.detail && !st.args.textContent) st.args.textContent = ev.detail; st.phase.textContent = phaseText(ev.phase); st.root.classList.toggle('run', ev.phase !== 'done'); if (ev.phase === 'done') { @@ -557,6 +760,10 @@ function applyState(s) { p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发')); } renderTodo(s.todo); + if ('show_thinking' in s && s.show_thinking !== shownThinking) { + shownThinking = s.show_thinking; + for (const d of log.querySelectorAll('details.think')) d.open = !!s.show_thinking; + } } /* --- SSE ----------------------------------------------------------------- */ @@ -570,7 +777,7 @@ function connect() { try { fn(JSON.parse(e.data)); } catch (err) { console.error(name, err); } }); on('state', applyState); - on('status', (d) => { markBusy(); if (d.message) $('status').textContent = d.message; }); + on('status', (d) => { markBusy(); if (d.message) $('status').textContent = statusText(d.message); }); on('assistant_html', (d) => { markBusy(); const l = ensureLive(); @@ -589,6 +796,7 @@ function connect() { on('session', (d) => { closePop(); live = null; steps = null; stepIndex = new Map(); + turnErr = null; loadSession().then(loadSessions).catch(toastErr); closeDrawers(); if (d && d.id) $('status').textContent = '已切换会话'; @@ -600,7 +808,12 @@ function connect() { if (turnStart) { turnMeta(Date.now() - turnStart); turnStart = 0; } if (live) live.body.classList.remove('live'); live = null; steps = null; stepIndex = new Map(); - if (d && d.rc) bubble('error').body.textContent = d.error || ('rc=' + d.rc); + /* the reload below rebuilds the log, so the reason is drawn after it */ + turnErr = d && d.rc ? { + text: turnError(d), + stopped: /cancelled$/.test(d.error || ''), + session: state.session, + } : null; loadSession().then(loadSessions).catch(toastErr); }); /* the browser resumes from Last-Event-ID; a restarted server has none of @@ -686,18 +899,30 @@ window.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (!$('modal').hidden) return closeModal(); if (!$('pop').hidden) return closePop(); + if (editing) return cancelEdit(); if (busy) return void post('api/cancel', {}).catch(toastErr); } if (e.ctrlKey && e.key.toLowerCase() === 'b') { e.preventDefault(); toggleSide(); } if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); } if (e.ctrlKey && e.key.toLowerCase() === 'k') { e.preventDefault(); showSessions(); } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'p') { + e.preventDefault(); + showPalette(); + } + if (e.key === 'F1' || (e.key === '?' && !typing)) { e.preventDefault(); showHelp(); } + /* the composer handles its own Shift+Tab; a popover keeps it for focus */ + if (e.key === 'Tab' && e.shiftKey && !typing && $('modal').hidden && $('pop').hidden) { + e.preventDefault(); + cycleApproval(); + } }); /* --- modal sheet (session list) ------------------------------------------ */ -function openModal(node) { +function openModal(node, wide) { const box = $('modal-box'); box.textContent = ''; + box.classList.toggle('wide', !!wide); box.appendChild(node); $('modal').hidden = false; } @@ -735,32 +960,97 @@ function timeLabel(id) { const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/.exec(id || ''); return m ? `${+m[2]}/${+m[3]} ${m[4]}:${m[5]}` : ''; } -async function loadSessions() { - let data; - try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; } - sessionsCache = data.sessions || []; +let sessionsCurrent = ''; +const sessMatch = (s, q) => !q || (s.title || '').toLowerCase().includes(q) || s.id.includes(q); +async function openSession(id) { + if (id === (state.session || '')) return; + await post('api/sessions/switch', { id }); + await loadSession(); + await loadSessions(); +} +function renameSession(s) { + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '重命名会话')); + box.appendChild(el('p', 'muted tiny', s.id)); + const input = el('input', 'key-input'); + input.value = s.title || ''; + input.placeholder = '留空则恢复为第一条消息'; + box.appendChild(input); + const err = el('div', 'key-err'); + box.appendChild(err); + const row = el('div', 'buttons'); + const go = async () => { + try { + await post('api/sessions/rename', { id: s.id, title: input.value.trim() }); + closeModal(); + loadSessions(); + } catch (e) { err.textContent = errMsg(e); } + }; + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); }); + row.appendChild(Object.assign(el('button', 'ok', '保存'), { onclick: go })); + row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); + box.appendChild(row); + openModal(box); + setTimeout(() => { input.focus(); input.select(); }, 0); +} +function deleteSession(s) { + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '删除会话?')); + box.appendChild(el('p', null, '「' + (s.title || s.id) + '」会被永久删除' + + (s.id === state.session ? ';这是当前会话,删除后会开一个新的。' : '。'))); + const row = el('div', 'buttons'); + row.appendChild(Object.assign(el('button', 'danger', '删除'), { + onclick: async () => { + try { + await post('api/sessions/delete', { id: s.id }); + closeModal(); + toast('已删除', 'ok'); + loadSessions(); + } catch (e) { closeModal(); toastErr(e); } + }, + })); + row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); + box.appendChild(row); + openModal(box); +} +function sessionMenu(anchor, s) { + openPop(anchor, (pop) => { + pop.appendChild(popRow({ icon: 'file', title: '重命名', onClick: () => renameSession(s) })); + if (s.id === state.session) + pop.appendChild(popRow({ icon: 'copy', title: '导出 Markdown', onClick: exportSession })); + pop.appendChild(popRow({ icon: 'close', title: '删除', danger: true, onClick: () => deleteSession(s) })); + }, { align: 'right' }); +} +function renderSessionList() { const box = $('sessions-list'); + const q = ($('sess-search').value || '').trim().toLowerCase(); box.textContent = ''; - for (const s of sessionsCache.slice(0, 60)) { - const b = el('button', 'sess-item' + (s.id === data.current ? ' on' : '')); + const shown = sessionsCache.filter((s) => sessMatch(s, q)).slice(0, q ? 200 : 60); + for (const s of shown) { + const b = el('div', 'sess-item' + (s.id === sessionsCurrent ? ' on' : '')); + b.tabIndex = 0; + b.setAttribute('role', 'button'); const t = el('span', 't'); const ts = el('span', 'ts', s.title || '(空会话)'); t.appendChild(ts); b.appendChild(t); b.appendChild(el('span', 'n', s.messages + ' 条 · ' + (timeLabel(s.id) || ''))); + const more = el('button', 'more'); + more.title = '更多'; + more.appendChild(icon('more')); + more.onclick = (e) => { e.stopPropagation(); sessionMenu(more, s); }; + b.appendChild(more); b.title = s.id + ' · ' + s.messages + ' 条消息'; - b.onclick = async () => { - if (s.id === (state.session || '')) { closeDrawers(); return; } - try { - await post('api/sessions/switch', { id: s.id }); - await loadSession(); - await loadSessions(); - closeDrawers(); - } catch (e) { toastErr(e); } + const open = async () => { + try { await openSession(s.id); closeDrawers(); } catch (e) { toastErr(e); } }; + b.onclick = open; + b.onkeydown = (e) => { if (e.key === 'Enter' && e.target === b) open(); }; + b.oncontextmenu = (e) => { e.preventDefault(); sessionMenu(more, s); }; box.appendChild(b); } - if (!sessionsCache.length) box.appendChild(el('div', 'muted tiny', '还没有历史会话')); + if (!shown.length) + box.appendChild(el('div', 'muted tiny', q ? '没有匹配的会话' : '还没有历史会话')); /* measure after layout: only rows whose title really overflows get the scroll */ requestAnimationFrame(() => { for (const t of box.querySelectorAll('.t')) { @@ -772,27 +1062,44 @@ async function loadSessions() { } }); } +async function loadSessions() { + let data; + try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; } + sessionsCache = data.sessions || []; + sessionsCurrent = data.current || ''; + renderSessionList(); +} async function showSessions() { await loadSessions(); const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '会话')); - for (const s of sessionsCache) { - const row = el('div', 'sess'); - row.appendChild(el('span', 'id', (s.id === state.session ? '● ' : ' ') + s.id.slice(0, 12))); - row.appendChild(el('span', 't', s.title || '(空会话)')); - row.appendChild(el('span', 'id', s.messages + ' 条')); - row.appendChild(Object.assign(el('button', null, '打开'), { - onclick: async () => { - try { - await post('api/sessions/switch', { id: s.id }); - closeModal(); - await loadSession(); - await loadSessions(); - } catch (e) { toastErr(e); } - }, - })); - box.appendChild(row); - } + const search = el('input', 'key-input'); + search.placeholder = '搜索标题或 ID'; + box.appendChild(search); + const list = el('div', 'sess-list'); + box.appendChild(list); + const draw = () => { + list.textContent = ''; + const q = search.value.trim().toLowerCase(); + const shown = sessionsCache.filter((s) => sessMatch(s, q)); + for (const s of shown) { + const row = el('div', 'sess'); + row.appendChild(el('span', 'id', (s.id === state.session ? '● ' : ' ') + s.id.slice(0, 12))); + row.appendChild(el('span', 't', s.title || '(空会话)')); + row.appendChild(el('span', 'id', s.messages + ' 条')); + row.appendChild(Object.assign(el('button', null, '打开'), { + onclick: async () => { + try { closeModal(); await openSession(s.id); } catch (e) { toastErr(e); } + }, + })); + row.appendChild(Object.assign(el('button', null, '重命名'), { onclick: () => renameSession(s) })); + row.appendChild(Object.assign(el('button', 'danger', '删除'), { onclick: () => deleteSession(s) })); + list.appendChild(row); + } + if (!shown.length) list.appendChild(el('div', 'muted tiny', q ? '没有匹配的会话' : '还没有历史会话')); + }; + search.addEventListener('input', draw); + draw(); const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', 'ok', '新建会话'), { onclick: async () => { @@ -807,6 +1114,7 @@ async function showSessions() { row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row); openModal(box); + setTimeout(() => search.focus(), 0); } /* --- pickers as popovers ------------------------------------------------- */ @@ -1150,14 +1458,27 @@ function usagePopover(anchor) { function menuPopover(anchor) { openPop(anchor, (pop) => { pop.appendChild(popRow({ icon: 'list', title: '会话列表', desc: 'Ctrl+K', onClick: showSessions })); + pop.appendChild(popRow({ icon: 'bolt', title: '命令面板', desc: 'Ctrl+P · 输入框里打 / 也行', onClick: showPalette })); + pop.appendChild(popRow({ icon: 'cpu', title: '扩展', desc: 'MCP、插件、skills、子代理', onClick: () => showExtensions() })); pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') })); + pop.appendChild(popRow({ icon: 'refresh', title: '重新生成上一轮', desc: '删掉上一轮回答,用同一句话再问', onClick: () => retry() })); + pop.appendChild(popRow({ icon: 'file', title: '导出 Markdown', desc: '下载当前会话', onClick: exportSession })); pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', onClick: compact })); pop.appendChild(popRow({ icon: 'undo', title: '撤销上一次写入', desc: '把文件恢复到工具改写之前', onClick: undo, })); pop.appendChild(el('div', 'pop-sep')); + pop.appendChild(popRow({ + icon: 'list', title: '思考过程默认展开', desc: '会记住,TUI 也用这个设置', on: !!state.show_thinking, + onClick: () => setThinking(), + })); + pop.appendChild(popRow({ + icon: 'list', title: '展开全部工具输出', desc: '工具调用默认展开到参数和输出', on: detailsAll, + onClick: () => setDetails(), + })); pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); + pop.appendChild(popRow({ icon: 'alert', title: '快捷键与命令', desc: 'F1', onClick: showHelp })); pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).then(() => toast('服务已退出', 'ok')).catch(toastErr) })); }, { align: 'right' }); } @@ -1184,9 +1505,125 @@ function showSettings() { pop.appendChild(popRow({ icon: 'refresh', title: '切换提供商', desc: '换端点,需要时输入 key', onClick: showProviders })); pop.appendChild(popRow({ icon: 'shield', title: '更换 API key', desc: '验证后保存,只对当前提供商', onClick: changeKey })); pop.appendChild(popRow({ icon: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) })); + pop.appendChild(popRow({ icon: 'settings', title: '运行设置…', desc: '工具协议、步数、工具开关、确认项、沙箱、已保存的许可', onClick: showRunSettings })); + pop.appendChild(popRow({ icon: 'file', title: '工作区改动', desc: 'git status + diff', onClick: () => showDiff() })); + pop.appendChild(popRow({ icon: 'cpu', title: '扩展…', desc: 'MCP、插件、skills、子代理;信任工作区', onClick: () => showExtensions() })); pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); }); } +const PERM_ZH = { read: '只读', write: '写文件', shell: '命令', net: '联网', ask: '提问', meta: '内部' }; +async function showRunSettings() { + let s, perms; + try { [s, perms] = await Promise.all([getJSON('api/settings'), getJSON('api/permissions')]); } + catch (e) { return toast('读取设置失败:' + errMsg(e)); } + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '运行设置')); + box.appendChild(el('p', 'muted tiny', '只对这次运行生效,和 TUI 里的 /protocol、/tools、/max-steps 一样;有轮次在跑时不能改。')); + const field = (label, input) => { + const f = el('label', 'field'); + f.appendChild(el('span', null, label)); + f.appendChild(input); + box.appendChild(f); + return input; + }; + const select = (opts, cur) => { + const sel = el('select'); + for (const o of opts) sel.appendChild(Object.assign(el('option', null, o), { value: o, selected: o === cur })); + return sel; + }; + const proto = field('工具协议', select(s.protocols || [], s.protocol)); + const steps = field('每轮步数', Object.assign(el('input'), { type: 'number', min: 1, max: 1000, value: s.max_steps })); + const prof = field('工具集', select(s.profiles || [], s.tool_profile)); + + box.appendChild(el('h4', 'sub', '需要确认(审批模式 ' + (s.approval || '-') + ')')); + const conf = {}; + const confRow = el('div', 'checks'); + for (const [k, label] of [['shell', '运行命令'], ['write', '改文件'], ['net', '联网']]) { + const l = el('label', 'check'); + const cb = Object.assign(el('input'), { type: 'checkbox', checked: !!(s.confirm || {})[k] }); + conf[k] = cb; + l.appendChild(cb); + l.appendChild(el('span', null, label)); + confRow.appendChild(l); + } + box.appendChild(confRow); + + box.appendChild(el('h4', 'sub', '工具开关')); + const toolBoxes = new Map(); + const tl = el('div', 'tool-list'); + for (const t of s.tools || []) { + const l = el('label', 'tool-row' + (t.on ? '' : ' dim')); + const cb = Object.assign(el('input'), { type: 'checkbox', checked: !t.off }); + toolBoxes.set(t.name, cb); + l.appendChild(cb); + l.appendChild(el('code', null, t.name)); + l.appendChild(el('span', 'perm', PERM_ZH[t.perm] || t.perm)); + const desc = el('span', 'desc', (t.owner ? '[' + t.owner + '] ' : '') + (t.desc || '')); + desc.title = desc.textContent; + l.appendChild(desc); + tl.appendChild(l); + } + box.appendChild(tl); + + const sb = s.sandbox || {}; + box.appendChild(el('h4', 'sub', '命令沙箱')); + box.appendChild(el('p', sb.active ? 'ok-text tiny' : 'warn-text tiny', sb.active + ? '已启用:' + sb.kind + '(命令只能写工作区和临时目录)' + : '不可用:按规则审批命令(' + (sb.kind || 'none') + ')')); + if (sb.labels) box.appendChild(el('pre', 'step-out', sb.labels)); + + box.appendChild(el('h4', 'sub', '已保存的"总是允许"')); + const rules = el('div', 'tiny'); + const drawRules = (p) => { + rules.textContent = ''; + const tools = p.tools || [], pre = p.shell_prefixes || []; + rules.appendChild(el('div', null, '工具:' + (tools.length ? tools.join('、') : '(无)'))); + rules.appendChild(el('div', null, '命令前缀:' + (pre.length ? pre.map((x) => '`' + x + '`').join('、') : '(无)'))); + rules.appendChild(el('div', 'muted', '保存在 ' + (p.file || '.agent/permissions.json'))); + }; + drawRules(perms); + box.appendChild(rules); + const clear = Object.assign(el('button', 'link danger-text', '全部清除'), { + onclick: async () => { + try { + await post('api/permissions', undefined, 'DELETE'); + drawRules(await getJSON('api/permissions')); + toast('已清除保存的许可规则', 'ok'); + } catch (e) { toastErr(e); } + }, + }); + box.appendChild(clear); + + const err = el('div', 'key-err'); + box.appendChild(err); + const row = el('div', 'buttons'); + row.appendChild(Object.assign(el('button', 'ok', '保存'), { + onclick: async () => { + const body = {}; + if (proto.value !== s.protocol) body.protocol = proto.value; + if (+steps.value !== s.max_steps) body.max_steps = +steps.value; + if (prof.value !== s.tool_profile) body.tool_profile = prof.value; + const c = {}; + for (const k of Object.keys(conf)) if (conf[k].checked !== !!(s.confirm || {})[k]) c[k] = conf[k].checked; + if (Object.keys(c).length) body.confirm = c; + const tools = {}; + for (const t of s.tools || []) { + const on = toolBoxes.get(t.name).checked; + if (on === !!t.off) tools[t.name] = on; + } + if (Object.keys(tools).length) body.tools = tools; + if (!Object.keys(body).length) return closeModal(); + try { + await post('api/settings', body); + closeModal(); + toast('设置已更新', 'ok'); + } catch (e) { err.textContent = errMsg(e); } + }, + })); + row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); + box.appendChild(row); + openModal(box, true); +} async function copyText(t) { try { await navigator.clipboard.writeText(t); @@ -1197,7 +1634,9 @@ async function copyText(t) { /* --- composer ------------------------------------------------------------ */ function syncSend() { - $('btn-send').disabled = busy || !$('input').value.trim(); + const v = $('input').value; + /* commands also work while a turn runs (/stop, /help, …) */ + $('btn-send').disabled = (busy && !v.startsWith('/')) || !v.trim(); } async function compact() { $('status').textContent = '压缩中…'; @@ -1208,27 +1647,567 @@ async function undo() { try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; } catch (e) { toast('撤销失败:' + errMsg(e)); } } -async function send(text) { - const input = $('input'); - const body = (text !== undefined ? text : input.value).trim(); - if (!body || busy) return; - if (text === undefined) input.value = ''; +let editing = false; /* the composer holds the last prompt, to replace it */ +function setEditing(on) { + editing = on; + $('edit-bar').hidden = !on; +} +function cancelEdit() { + setEditing(false); + $('input').value = ''; autoGrow(); syncSend(); - bubble('user').body.textContent = body; +} +function editLast(node) { + if (busy) return toast('等这一轮结束再编辑'); + const body = node.querySelector('.body'); + const input = $('input'); + input.value = body ? body.textContent : ''; + setEditing(true); + autoGrow(); + syncSend(); + input.focus(); +} +function freshTurn(text) { + const b = bubble('user'); + b.body.textContent = text; + b.root.dataset.prompt = '1'; steps = null; stepIndex = new Map(); files = new Map(); live = null; + turnErr = null; renderFilesPanel(); turnStart = Date.now(); +} +/* drop the newest prompt and everything after it from the page, as the + * server does with the session */ +function cutLastTurn() { + const prompts = log.querySelectorAll('.msg[data-prompt]'); + const p = prompts[prompts.length - 1]; + if (!p) return ''; + const body = p.querySelector('.body'); + const text = body ? body.textContent : ''; + while (p.nextSibling) p.nextSibling.remove(); + p.remove(); + return text; +} +/* the page is cut before the request: the new turn's events can arrive + * before its reply does */ +async function retry(text) { + if (busy) return toast('等这一轮结束再重试'); + if (!log.querySelector('.msg[data-prompt]')) return toast('没有可以重试的消息'); + const old = cutLastTurn(); + freshTurn(text !== undefined ? text : old); + try { + await post('api/retry', text === undefined ? {} : { text }); + if (busy) $('status').textContent = text === undefined ? '重新生成中…' : '已重发…'; + } catch (e) { + toast('重试失败:' + errMsg(e)); + turnStart = 0; + loadSession().catch(toastErr); + } +} +async function send(text) { + const input = $('input'); + const raw = text !== undefined ? text : input.value; + const body = raw.trim(); + if (!body) return; + if (raw.startsWith('/') && !editing) { + const r = runSlash(body); + if (r === 'keep') return; + if (r) { + if (text === undefined) input.value = ''; + autoGrow(); + syncSend(); + return; + } + } + if (busy) return; + if (text === undefined) input.value = ''; + autoGrow(); + syncSend(); + if (editing) { + setEditing(false); + return retry(body); + } + freshTurn(body); try { await post('api/turn', { text: body }); - $('status').textContent = '已开始…'; + if (busy) $('status').textContent = '已开始…'; } catch (e) { bubble('error').body.textContent = errMsg(e); } } +async function exportSession() { + try { + const r = await api('api/export'); + const md = await readReply(r); + const a = el('a'); + a.href = URL.createObjectURL(new Blob([md], { type: 'text/markdown' })); + a.download = (state.session || 'session') + '.md'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + } catch (e) { toast('导出失败:' + errMsg(e)); } +} +/* --- commands: "/name args" in the composer, Ctrl+P, F1 ------------------- */ + +async function newSession() { + try { await post('api/sessions/new', {}); } catch (e) { return toastErr(e); } + await loadSession(); + await loadSessions(); + closeDrawers(); +} +function setComposer(text) { + const input = $('input'); + input.value = text; + autoGrow(); + syncSend(); + input.focus(); + input.setSelectionRange(text.length, text.length); + slashSuggest(); +} +function cycleApproval() { + const keys = APPROVALS.map((a) => a.key); + const next = keys[(keys.indexOf(state.approval || 'auto') + 1) % keys.length]; + setApproval(next); +} +function setApproval(a) { + if (!a) return approvalPopover($('chip-approval')); + const ap = APPROVALS.find((x) => x.key === a); + if (!ap) return toast('审批模式只能是 ask / auto / yolo'); + post('api/config', { approval: a }) + .then(() => toast('审批:' + ap.title + ' · ' + ap.desc, a === 'yolo' ? 'warn' : 'ok')) + .catch(toastErr); +} +function setEffort(a) { + if (!a) return modelPopover($('chip-model')); + if (!EFFORTS.some((x) => x.key === a)) return toast('推理强度只能是 low / medium / high / max'); + post('api/config', { effort: a }) + .then((r) => toast(r && r.warning ? r.warning : '推理强度 → ' + a, r && r.warning ? 'warn' : 'ok')) + .catch(toastErr); +} +function setPlan(on) { + post('api/config', { plan_mode: on }) + .then(() => toast(on ? '计划模式:只读,先出方案' : '构建模式:可以改文件', 'ok')) + .catch(toastErr); +} +/* "on" / "off" / anything else flips */ +const onOffArg = (a, cur) => (a === 'on' ? true : a === 'off' ? false : !cur); +function setThinking(a) { + const on = onOffArg(a, !!state.show_thinking); + post('api/config', { show_thinking: on }) + .then(() => toast(on ? '思考过程默认展开' : '思考过程默认收起', 'ok')) + .catch(toastErr); +} +function setDetails(a) { + detailsAll = onOffArg(a, detailsAll); + localStorage.setItem('cst.details', detailsAll ? '1' : '0'); + for (const d of log.querySelectorAll('details.turn-steps, details.step-line')) d.open = detailsAll; + toast(detailsAll ? '工具输出全部展开' : '工具输出全部收起', 'ok'); +} +async function providerCmd(a) { + if (!a) return showProviders(); + let d; + try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); } + const p = (d.providers || []).find((x) => x.name.toLowerCase() === a.toLowerCase()); + if (!p) return toast('没有叫 ' + a + ' 的提供商(/provider 打开列表)'); + switchProvider(p); +} +function renameCurrent(a) { + if (a) { + return post('api/sessions/rename', { title: a }) + .then(() => { toast('已重命名', 'ok'); loadSessions(); }) + .catch(toastErr); + } + renameSession(sessionsCache.find((s) => s.id === state.session) || { id: state.session || '', title: '' }); +} +function editLastPrompt() { + const ps = log.querySelectorAll('.msg[data-prompt]'); + if (!ps.length) return toast('还没有可以编辑的消息'); + editLast(ps[ps.length - 1]); +} +async function copyLastAnswer() { + const as = log.querySelectorAll('.msg.assistant .body'); + if (!as.length) return toast('还没有回答'); + try { + await navigator.clipboard.writeText(as[as.length - 1].innerText); + toast('已复制最后一条回答', 'ok'); + } catch { toast('浏览器不允许写剪贴板'); } +} +/* /init and /skill: the server builds the prompt and starts the turn */ +async function startCmdTurn(label, path, body) { + if (busy) return toast('等这一轮结束再试'); + freshTurn(label); + try { + await post(path, body || {}); + if (busy) $('status').textContent = '已开始…'; + } catch (e) { + toast(errMsg(e)); + turnStart = 0; + loadSession().catch(toastErr); + } +} +function runSkill(a) { + if (!a) return showExtensions('skills'); + const name = a.split(/\s+/)[0]; + startCmdTurn('/skill ' + a, 'api/skill', { name, task: a.slice(name.length).trim() }); +} + +/* completions for the argument; null = none (or still loading) */ +let extCache = null, providersCache = null, completing = false; +const fetched = new Set(); /* one try per source: a failed fetch is not retried per keystroke */ +function fetchOnce(key, load) { + if (completing || fetched.has(key)) return; + fetched.add(key); + completing = true; + load().catch(() => {}).finally(() => { completing = false; slashSuggest(); }); +} +const ON_OFF = () => [{ value: 'on', desc: '开' }, { value: 'off', desc: '关' }]; +function completeSkill(arg) { + if (/\s/.test(arg)) return null; + if (!extCache) { + fetchOnce('ext', async () => { try { extCache = await getJSON('api/extensions'); } catch { extCache = { skills: [] }; } }); + return null; + } + return (extCache.skills || []).map((s) => ({ value: s.name, desc: s.desc })); +} +function completeModel() { + if (!modelsCache || modelsCache.provider !== state.provider) { + fetchOnce('models:' + state.provider, () => fetchModels(false)); + return null; + } + return ((modelsCache.data || {}).models || []).map((m) => ({ value: m.id, desc: m.label || '' })); +} +function completeProvider() { + if (!providersCache) { + fetchOnce('providers', async () => { + try { providersCache = (await getJSON('api/providers')).providers || []; } catch { providersCache = []; } + }); + return null; + } + return providersCache.map((p) => ({ value: p.name, desc: p.api_base })); +} + +const COMMANDS = [ + { name: 'help', aliases: ['keys'], desc: '快捷键和命令列表', key: 'F1', run: () => showHelp() }, + { name: 'new', aliases: ['clear'], desc: '新建会话', run: newSession }, + { name: 'sessions', desc: '会话列表:搜索、切换、重命名、删除', key: 'Ctrl+K', run: () => showSessions() }, + { name: 'rename', args: '[标题]', desc: '重命名当前会话(不带标题打开对话框)', run: renameCurrent }, + { name: 'retry', desc: '重新生成上一轮', run: () => retry() }, + { name: 'edit', desc: '编辑最后一句再发', run: editLastPrompt }, + { name: 'stop', desc: '停止当前一轮', key: 'Esc', run: () => post('api/cancel', {}).catch(toastErr) }, + { name: 'undo', desc: '撤销上一次写入', run: undo }, + { name: 'compact', desc: '把旧消息压成摘要,腾出上下文', run: compact }, + { name: 'export', desc: '下载当前会话的 Markdown', run: exportSession }, + { name: 'copy', desc: '复制最后一条回答', run: copyLastAnswer }, + { name: 'diff', args: '[文件]', desc: '工作区改动(git status + diff)', run: (a) => showDiff(a || undefined) }, + { name: 'model', args: '[模型]', desc: '换模型(不带参数打开列表)', complete: completeModel, + run: (a) => (a ? setModel(a) : modelPopover($('chip-model'))) }, + { name: 'effort', args: '[low|medium|high|max]', desc: '推理强度', + complete: () => EFFORTS.map((e) => ({ value: e.key, desc: e.title + ' · ' + e.desc })), run: setEffort }, + { name: 'provider', args: '[名字]', desc: '切换提供商(不带参数打开列表)', complete: completeProvider, run: providerCmd }, + { name: 'key', desc: '更换当前提供商的 API key', run: changeKey }, + { name: 'approval', args: '[ask|auto|yolo]', desc: '审批权限', key: 'Shift+Tab', + complete: () => APPROVALS.map((a) => ({ value: a.key, desc: a.title + ' · ' + a.desc })), run: setApproval }, + { name: 'plan', desc: '计划模式:只读,先出方案', run: () => setPlan(true) }, + { name: 'build', desc: '构建模式:可以改文件', run: () => setPlan(false) }, + { name: 'settings', aliases: ['protocol', 'tools', 'max-steps', 'permissions'], + desc: '运行设置:工具协议、步数、工具开关、确认项、沙箱、许可规则', run: () => showRunSettings() }, + { name: 'init', desc: '让 agent 分析仓库,写一份 AGENTS.md', run: () => startCmdTurn('/init', 'api/init') }, + { name: 'skill', args: '<名字> [任务]', desc: '带着某个 skill 开始一轮', complete: completeSkill, more: true, run: runSkill }, + { name: 'skills', desc: '查看可用的 skills', run: () => showExtensions('skills') }, + { name: 'extensions', aliases: ['mcp', 'plugins', 'agents'], desc: 'MCP、插件、skills、子代理;信任工作区', + run: (a, name) => showExtensions(name === 'extensions' ? undefined : name) }, + { name: 'thinking', args: '[on|off]', desc: '思考过程默认展开/收起(会记住)', complete: ON_OFF, run: setThinking }, + { name: 'details', args: '[on|off]', desc: '工具输出全部展开/收起(会记住)', complete: ON_OFF, run: setDetails }, + { name: 'theme', desc: '切换深色/浅色', run: toggleTheme }, + { name: 'palette', desc: '命令面板', key: 'Ctrl+P', run: () => showPalette() }, +]; +const findCmd = (name) => COMMANDS.find((c) => c.name === name || (c.aliases || []).includes(name)); +function invokeCmd(c, arg, name) { + hideSlash(); + try { + const r = c.run(arg || '', name || c.name); + if (r && r.catch) r.catch(toastErr); + } catch (e) { toastErr(e); } +} +/* true = ran it, 'keep' = an unknown command (left in the box), false = not + * a command at all ("/usr/bin 是什么" goes to the model) */ +function runSlash(line) { + const m = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(line); + if (!m) return false; + const name = m[1].toLowerCase(); + const c = findCmd(name); + if (!c) { + toast('没有 /' + m[1] + ' 这个命令。F1 看全部命令;想当普通消息发,开头加个空格。', 'warn'); + return 'keep'; + } + invokeCmd(c, (m[2] || '').trim(), name); + return true; +} + +/* the list above the composer: command names, then argument values */ +let slashItems = [], slashSel = 0; +function hideSlash() { + $('slash').hidden = true; + slashItems = []; +} +function slashSuggest() { + const v = $('input').value; + const m = /^\/([^\s/]*)(?: ([^\n]*))?$/.exec(v); + if (!m) return hideSlash(); + const name = m[1].toLowerCase(); + let items; + if (m[2] === undefined) { + items = COMMANDS + .filter((c) => c.name.startsWith(name) || (c.aliases || []).some((a) => a.startsWith(name))) + .map((c) => ({ cmd: c, label: '/' + c.name, hint: c.args || '', desc: c.desc })); + } else { + const c = findCmd(name); + const opts = c && c.complete ? c.complete(m[2]) : null; + if (!opts) return hideSlash(); + const q = m[2].toLowerCase(); + items = opts + .filter((o) => !q || o.value.toLowerCase().includes(q)) + .slice(0, 30) + .map((o) => ({ cmd: c, value: o.value, label: o.value, hint: '', desc: o.desc || '' })); + } + const prev = slashItems[slashSel]; + slashItems = items; + /* what was typed in full wins, then the row the arrows were on */ + const exact = m[2] === undefined + ? items.findIndex((i) => i.cmd.name === name || (i.cmd.aliases || []).includes(name)) + : items.findIndex((i) => i.value === m[2]); + const keep = prev ? items.findIndex((i) => i.label === prev.label) : -1; + slashSel = exact >= 0 ? exact : keep >= 0 ? keep : 0; + if (!items.length) return hideSlash(); + renderSlash(); +} +function renderSlash() { + const box = $('slash'); + box.textContent = ''; + slashItems.forEach((it, i) => { + const b = el('button', 'slash-item' + (i === slashSel ? ' on' : '')); + b.type = 'button'; + b.appendChild(el('code', null, it.label)); + if (it.hint) b.appendChild(el('span', 'hint', it.hint)); + b.appendChild(el('span', 'desc', it.desc)); + /* mousedown keeps the focus in the composer */ + b.onmousedown = (e) => { e.preventDefault(); slashSel = i; acceptSlash(it, true); }; + box.appendChild(b); + }); + box.hidden = false; + const on = box.children[slashSel]; + if (on) on.scrollIntoView({ block: 'nearest' }); +} +/* Enter runs the pick, Tab only completes the text */ +function acceptSlash(it, run) { + if (!it) return; + const c = it.cmd; + if (it.value === undefined) { + if (run && !(c.more && c.complete)) { + $('input').value = ''; + autoGrow(); + syncSend(); + return invokeCmd(c, ''); + } + return setComposer('/' + c.name + (c.args ? ' ' : '')); + } + const line = '/' + c.name + ' ' + it.value; + if (run && !c.more) { + $('input').value = ''; + autoGrow(); + syncSend(); + return invokeCmd(c, it.value); + } + setComposer(line + (c.more ? ' ' : '')); + if (c.more) hideSlash(); +} + +function cmdLabel(c) { + return '/' + c.name + (c.args ? ' ' + c.args : ''); +} +function showPalette() { + hideSlash(); + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '命令面板')); + const input = el('input', 'key-input'); + input.placeholder = '输入命令或说明过滤,↑↓ 选择,Enter 执行'; + input.spellcheck = false; + box.appendChild(input); + const list = el('div', 'palette-list'); + box.appendChild(list); + let items = [], sel = 0; + const pick = (c) => { + closeModal(); + /* a required argument goes back to the composer to be filled in */ + if (c.args && c.args.startsWith('<')) return setComposer('/' + c.name + ' '); + invokeCmd(c, ''); + }; + const render = () => { + const q = input.value.trim().toLowerCase().replace(/^\//, ''); + items = COMMANDS.filter((c) => !q || c.name.includes(q) || c.desc.toLowerCase().includes(q) || + (c.aliases || []).some((a) => a.includes(q))); + sel = Math.min(sel, Math.max(0, items.length - 1)); + list.textContent = ''; + items.forEach((c, i) => { + const b = el('button', 'palette-item' + (i === sel ? ' on' : '')); + b.appendChild(el('code', null, cmdLabel(c))); + b.appendChild(el('span', 'desc', c.desc)); + if (c.key) b.appendChild(el('kbd', null, c.key)); + b.onclick = () => pick(c); + list.appendChild(b); + }); + if (!items.length) list.appendChild(el('div', 'muted tiny', '没有匹配的命令')); + const on = list.children[sel]; + if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' }); + }; + input.addEventListener('input', () => { sel = 0; render(); }); + input.addEventListener('keydown', (e) => { + const n = Math.max(1, items.length); + if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % n; render(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel + n - 1) % n; render(); } + else if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); if (items[sel]) pick(items[sel]); } + }); + openModal(box); + render(); + setTimeout(() => input.focus(), 0); +} + +const KEYS_HELP = [ + ['Enter', '发送'], + ['Shift+Enter', '换行'], + ['/', '输入命令:Tab 补全,↑↓ 选择,Enter 执行'], + ['Ctrl+P', '命令面板'], + ['Ctrl+K', '会话列表'], + ['Shift+Tab', '切换审批:每次询问 → 自动 → 完全放行'], + ['Esc', '关闭弹窗 / 取消编辑 / 停止当前一轮'], + ['Ctrl+B · Ctrl+J', '收起或展开左栏 · 右栏'], + ['y · n · 1-4', '回答许可请求(焦点不在输入框时)'], + ['F1 · ?', '这个帮助'], +]; +function showHelp() { + hideSlash(); + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '快捷键')); + const keys = el('div', 'keys-grid'); + for (const [k, d] of KEYS_HELP) { + keys.appendChild(el('kbd', null, k)); + keys.appendChild(el('span', null, d)); + } + box.appendChild(keys); + box.appendChild(el('h4', 'sub', '命令(在输入框里打,或者 Ctrl+P)')); + const cmds = el('div', 'keys-grid cmds'); + for (const c of COMMANDS) { + cmds.appendChild(el('code', null, cmdLabel(c))); + cmds.appendChild(el('span', null, c.desc + ((c.aliases || []).length ? '(也可以 /' + c.aliases.join(' /') + ')' : ''))); + } + box.appendChild(cmds); + const row = el('div', 'buttons'); + row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); + box.appendChild(row); + openModal(box, true); +} + +/* --- extensions: MCP servers, plugins, skills, sub-agents ----------------- */ + +const MCP_ZH = { starting: '启动中', ready: '就绪', failed: '失败', exited: '已退出', off: '未启动' }; +const RUN_ZH = { done: '完成', error: '出错', cancelled: '已取消', running: '运行中' }; +function reportZh(r) { + return String(r || '') + .replace(/(\d+) skills?/, '$1 个 skill') + .replace(/(\d+) plugins? \((\d+) tools?\)/, '$1 个插件($2 个工具)') + .replace(/(\d+) MCP servers?/, '$1 个 MCP 服务器') + .replace(/untrusted workspace: \/plugins trust/, '工作区未信任,它自带的插件和 MCP 没有加载'); +} +async function showExtensions(focus) { + let d; + try { d = extCache = await getJSON('api/extensions'); } + catch (e) { return toast('读取扩展失败:' + errMsg(e)); } + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '扩展')); + if (!d.enabled) + box.appendChild(el('p', 'warn-text tiny', '当前工具集是 minimal:MCP、插件、skills 和子代理都不加载。在运行设置里换回 default,再点"重新加载"。')); + box.appendChild(el('p', 'muted tiny', (d.report ? '已加载:' + reportZh(d.report) + '。' : '没有加载任何扩展。') + + (d.trusted + ? '这个工作区已信任:它的 .agent/plugins 和 .agent/mcp.json 会加载。' + : '这个工作区未信任:它的 .agent/plugins 和 .agent/mcp.json 不会加载(配置目录里的照常加载)。'))); + const acts = el('div', 'buttons left'); + const act = async (path, body, done) => { + try { + const r = await post(path, body); + toast(done + (r.report ? ':' + reportZh(r.report) : ''), 'ok'); + showExtensions(focus); + } catch (e) { toastErr(e); } + }; + acts.appendChild(Object.assign(el('button', null, '重新加载'), { + title: '停掉 MCP 服务器和插件,重新读取(有轮次在跑时不行)', + onclick: () => act('api/extensions/reload', {}, '已重新加载'), + })); + acts.appendChild(Object.assign(el('button', d.trusted ? 'danger' : 'ok', d.trusted ? '取消信任这个工作区' : '信任这个工作区'), { + title: '信任后会运行工作区里 .agent/plugins 和 .agent/mcp.json 指定的程序', + onclick: () => act('api/extensions/trust', { on: !d.trusted }, d.trusted ? '已取消信任' : '已信任'), + })); + box.appendChild(acts); + + const secs = {}; + const section = (key, title, rows, empty, draw) => { + const h = el('h4', 'sub', title + ' · ' + rows.length); + secs[key] = h; + box.appendChild(h); + const list = el('div', 'ext-list'); + if (!rows.length) list.appendChild(el('div', 'muted tiny', empty)); + for (const r of rows) list.appendChild(draw(r)); + box.appendChild(list); + }; + const row = (name, badge, badgeCls, meta, sub, button) => { + const r = el('div', 'ext-row'); + const m = el('div', 'pm'); + const t = el('div', 'ext-title'); + t.appendChild(el('b', null, name)); + if (badge) t.appendChild(el('span', 'ext-badge ' + (badgeCls || ''), badge)); + if (meta) t.appendChild(el('span', 'muted tiny', meta)); + m.appendChild(t); + if (sub) m.appendChild(el('div', 'tiny ' + (badgeCls === 'bad' ? 'danger-text' : 'muted'), sub)); + r.appendChild(m); + if (button) r.appendChild(button); + return r; + }; + const useBtn = (label, text) => Object.assign(el('button', null, label), { + onclick: () => { closeModal(); setComposer(text); }, + }); + section('mcp', 'MCP 服务器', d.mcp || [], + '没有 MCP 服务器。在配置目录的 mcp.json(或已信任工作区的 .agent/mcp.json)里写 {"mcpServers": {"名字": {"command": …, "args": […]}}},再点重新加载。', + (s) => row(s.name, MCP_ZH[s.state] || s.state, + s.state === 'ready' ? 'ok' : s.state === 'failed' || s.state === 'exited' ? 'bad' : '', + (s.state === 'ready' ? s.tools + ' 个工具(mcp__' + s.name + '__*)· ' : '') + s.source, + s.error || s.command)); + section('plugins', '插件', d.plugins || [], + '没有插件。在配置目录的 plugins/(或已信任工作区的 .agent/plugins/)下放 <目录>/plugin.json,再点重新加载。', + (p) => row(p.name, p.error ? '出错' : p.tools + ' 个工具', p.error ? 'bad' : '', + p.source + (p.mcp ? ' · MCP' : '') + (p.skills ? ' · skills' : ''), p.error || p.desc)); + section('skills', 'Skills', d.skills || [], + '没有 skill。在工作区的 .agent/skills/ 或配置目录的 skills/ 下放 <目录>/SKILL.md(frontmatter 写 name 和 description),再点重新加载。', + (s) => row(s.name, null, null, s.source, s.desc, useBtn('使用', '/skill ' + s.name + ' '))); + section('agents', '子代理角色', d.roles || [], + d.task_tool ? '没有角色。' : 'task 工具没开(工具集是 minimal)。', + (r) => row(r.name, r.read_only ? '只读' : null, null, r.source + (r.model ? ' · ' + r.model : ''), r.desc, + d.task_tool ? useBtn('交给它', '用 ' + r.name + ' 子代理(task 工具)来 ') : null)); + section('runs', '本会话的子代理运行', d.runs || [], '这个会话还没有用过子代理。', + (r) => row(r.role + ' · ' + r.file, RUN_ZH[r.status] || r.status, + r.status === 'done' ? 'ok' : r.status === 'error' ? 'bad' : '', + r.steps + ' 步 · ' + r.tools + ' 次工具 · ' + fmtSecs(r.ms || 0), r.desc)); + const close = el('div', 'buttons'); + close.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); + box.appendChild(close); + openModal(box, true); + const target = secs[focus === 'skill' ? 'skills' : focus]; + if (target) setTimeout(() => target.scrollIntoView({ block: 'start' }), 0); +} + function autoGrow() { const t = $('input'); t.style.height = 'auto'; @@ -1279,13 +2258,11 @@ function toggleTheme() { $('btn-send').onclick = () => send(); $('btn-stop').onclick = () => post('api/cancel', {}).catch(toastErr); -$('btn-new').onclick = async () => { - try { await post('api/sessions/new', {}); } catch (e) { toastErr(e); } - await loadSession(); - await loadSessions(); - closeDrawers(); -}; +$('btn-new').onclick = newSession; $('btn-refresh-sessions').onclick = () => loadSessions(); +$('sess-search').addEventListener('input', renderSessionList); +$('btn-diff').onclick = () => showDiff(); +$('btn-edit-cancel').onclick = cancelEdit; $('chip-model').onclick = () => modelPopover($('chip-model')); $('chip-approval').onclick = () => approvalPopover($('chip-approval')); $('btn-plus').onclick = () => plusPopover($('btn-plus')); @@ -1310,9 +2287,32 @@ for (const b of $('seg-mode').children) { }; } $('input').addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } + if (e.isComposing) return; + if (!$('slash').hidden && slashItems.length) { + const n = slashItems.length; + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + slashSel = (slashSel + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; + renderSlash(); + return; + } + if ((e.key === 'Tab' && !e.shiftKey) || (e.key === 'Enter' && !e.shiftKey)) { + e.preventDefault(); + acceptSlash(slashItems[slashSel], e.key === 'Enter'); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + hideSlash(); + return; + } + } + if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); cycleApproval(); return; } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); -$('input').addEventListener('input', () => { autoGrow(); syncSend(); }); +$('input').addEventListener('input', () => { autoGrow(); syncSend(); slashSuggest(); }); +$('input').addEventListener('blur', () => setTimeout(hideSlash, 150)); $('scroll').addEventListener('scroll', updateJump); $('scroll').addEventListener('wheel', closePop, { passive: true }); $('file').addEventListener('change', async (e) => { diff --git a/web/index.html b/web/index.html index a1c9676..0a86b01 100644 --- a/web/index.html +++ b/web/index.html @@ -69,6 +69,7 @@ +
@@ -133,6 +134,11 @@