diff --git a/README.md b/README.md index 542d2cc..d48265a 100644 --- a/README.md +++ b/README.md @@ -212,10 +212,10 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T | `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) | | `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) | | `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) | -| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 80% 时自动 `/compact`) | +| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 75% 时自动 `/compact`) | | `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt | | `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) | -| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 | +| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限),设了才在超时后整组杀进程,exit=124 | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/.jsonl`) | | `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) | | `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 | @@ -319,7 +319,7 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 `sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。 - **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。 - **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。 -- **自动压缩**:上一轮 prompt 用到模型上下文 80% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。 +- **自动压缩**:上一轮 prompt 用到模型上下文 75% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。 ### 扩展:Skills、插件、MCP、子 agent @@ -408,6 +408,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中 | `/diff [path]` * | `git status` + `git diff` | | `/compact` | 手动压缩上下文 | | `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) | +| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) | | `/init` | 让 agent 写 `AGENTS.md` | | `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 | | `/skills [reload\|list]`、`/skill [任务]` | 浏览 skills;带着某个 skill 开始一轮 | diff --git a/docs/env_openai.md b/docs/env_openai.md index f4a8d3f..0b23b8c 100644 --- a/docs/env_openai.md +++ b/docs/env_openai.md @@ -73,7 +73,7 @@ export OPENAI_MODEL='your-model' | `CST_MAX_STEPS` | 每个用户回合最多调用模型几次(默认 30) | | `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(默认 16384) | | `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(默认 80) | -| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认 120) | +| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限,Ctrl+C / 停止按钮仍可随时中断) | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL | | `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 | | `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 | diff --git a/docs/webui.md b/docs/webui.md index a9c2186..9b708e9 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -95,7 +95,8 @@ GET /api/events?since= → text/event-stream (chunked) `state` 字段:`workspace` `session` `provider` `model` `effort` `approval` (`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo` `context_tokens` `undo_available` `pending_images[]` `usage{requests,prompt_tokens, -completion_tokens,cost,priced,currency}`。 +completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`。 +`cached_tokens` / `cache_miss_tokens` 来自上游的 prompt cache 统计,前端用它算命中率。 ### 请求 @@ -106,6 +107,7 @@ completion_tokens,cost,priced,currency}`。 | GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` | | POST | `/api/sessions/new` | `{}` → `{id}` | | POST | `/api/sessions/switch` | `{"id":"…"}` | +| POST | `/api/workspace` | `{"path":"/abs/dir"}` —— 换工作目录(权限规则/埋点/AGENTS.md 跟着换),并**在新目录里开一个新会话**(会话是按目录存的);返回 `{id}`,坏路径 → 400 | | POST | `/api/turn` | `{"text":"…"}` → 202 `{ok:true}`;已有轮次在跑时 **409** | | POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程) | | POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix | @@ -149,6 +151,18 @@ api("/api/turn", {"text": "hello"}) # 之后读 /api/events 收流 Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),客户端只贴 HTML; 因此不需要引入任何 JS 依赖,也顺带保证了"只发送已确认的 HTML"。 +界面本身:左栏可收(顶栏按钮 / `?side=0`),右栏可收(`?panel=0`),主题 `?theme=dark|light`; +三个开关都会记进 `localStorage`,带查询参数时以参数为准。空状态只留一句提示,第一轮 +对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文 +占用与 prompt cache 命中率。布局类在页面脚本之前就写进 ``,避免首帧闪一下再收起。 + +TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目录> --new`), +日志在 `/.agent/webui.log`:TUI 不能被 `cst_webui_run()` 的阻塞循环占住, +而且两个进程同时写一个会话文件会把它写坏,所以子进程总是开新会话。 + +自动压缩的门槛是模型上下文窗口的 **75%**(`CST_CTX_BUDGET` 可覆盖);到点后下一轮 +开始前先总结历史,而不是等到塞满。 + ## 测试 ```bash diff --git a/include/cst_agent.h b/include/cst_agent.h index ebc8d9b..9650a6e 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -321,6 +321,12 @@ cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a); void cst_agent_request_cancel(cst_agent_t *a); int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz); int cst_agent_switch_session(cst_agent_t *a, const char *id); +/* Re-root the agent at another directory. Per-workspace state (permission + * rules, metrics, project notes) follows, the system prompt is rebuilt and the + * token budget is re-measured. The session is not touched: the caller decides + * whether to keep it or start a new one. */ +int cst_agent_set_workspace(cst_agent_t *a, const char *path, char *err, + size_t err_sz); int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz); /* Bare id of the current session (file name without .jsonl). */ void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz); diff --git a/include/cst_version.h b/include/cst_version.h index 5d8112b..239ea94 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -2,8 +2,8 @@ #define CST_VERSION_H #define CST_VERSION_MAJOR 0 -#define CST_VERSION_MINOR 7 -#define CST_VERSION_PATCH 8 +#define CST_VERSION_MINOR 8 +#define CST_VERSION_PATCH 0 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/include/cst_webui.h b/include/cst_webui.h index 4c9a0f0..b99b73a 100644 --- a/include/cst_webui.h +++ b/include/cst_webui.h @@ -17,6 +17,10 @@ extern "C" { #endif +/* What --web listens on when --web-port/CST_WEB_PORT says nothing (the + * README and --help quote this number, so keep them in sync). */ +#define CST_WEBUI_DEFAULT_PORT 8787 + typedef struct { const char *host; /* NULL = 127.0.0.1; "0.0.0.0" = every interface */ int port; /* 0 = pick a free port */ diff --git a/include/os_compat.h b/include/os_compat.h index e0add6d..3c87d01 100644 --- a/include/os_compat.h +++ b/include/os_compat.h @@ -114,9 +114,10 @@ int os_win_get_label(const char *path, char *out, size_t n); int os_clipboard_set(const char *utf8, size_t n); #endif -/* Child processes get stdin=/dev/null, their own process group, and are - * killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when - * *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */ +/* Child processes get stdin=/dev/null and their own process group. They run + * with no time limit unless one is asked for (CST_SHELL_TIMEOUT seconds, or + * os_set_run_timeout()); either way a non-zero *flag cancels them. + * Exit code 124 = killed by that timeout, 130 = cancelled. */ void os_set_cancel_flag(volatile int *flag); volatile int *os_get_cancel_flag(void); void os_set_run_timeout(int seconds); /* this thread; 0 = default */ diff --git a/src/agent_loop.c b/src/agent_loop.c index 498cca5..84023dc 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -2558,9 +2558,9 @@ static int can_continue(const cst_agent_t *a, const turn_t *t, return st->http_status > 0 && st->http_status < 400 && !st->client_stopped; } -/* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 60% of - * the context window capped at 256K tokens. Even a cached prompt is billed - * on every step, so huge windows are not worth filling. */ +/* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 75% of + * the context window, capped at 256K tokens. Even a cached prompt is billed on + * every step, so a 1M-token window is not worth filling. */ static long ctx_budget(const cst_agent_t *a) { const char *e = getenv("CST_CTX_BUDGET"); if (e && atol(e) > 0) @@ -2568,7 +2568,7 @@ static long ctx_budget(const cst_agent_t *a) { long win = cst_model_context_tokens(a->cfg.model); if (win <= 0) return 0; - long b = win / 10 * 6; + long b = win / 4 * 3; return b > 262144 ? 262144 : b; } @@ -2948,6 +2948,42 @@ int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) { return 0; } +int cst_agent_set_workspace(cst_agent_t *a, const char *path, char *err, + size_t err_sz) { + char dummy[8]; + if (!err || !err_sz) { + err = dummy; + err_sz = sizeof(dummy); + } + err[0] = 0; + if (!a || !path || !path[0]) { + snprintf(err, err_sz, "empty path"); + return -1; + } + char abs[sizeof(a->cfg.workspace_root)]; + if (os_realpath(path, abs, sizeof(abs)) != 0) { + snprintf(err, err_sz, "no such directory: %s", path); + return -1; + } + /* a file, or a tree we may not write to, fails here instead of halfway + * through the next turn */ + if (cst_agent_store_ensure(abs) != 0) { + snprintf(err, err_sz, "cannot use as a workspace: %s", abs); + return -1; + } + snprintf(a->cfg.workspace_root, sizeof(a->cfg.workspace_root), "%s", abs); + /* everything that is kept per workspace follows the new root */ + cst_perms_close(a->perms); + a->perms = cst_perms_open(a->cfg.workspace_root); + cst_metrics_close(a->metrics); + a->metrics = cst_metrics_open(a->cfg.workspace_root, + cst_session_path(a->session)); + cst_agent_reload_notes(a); + ensure_system(a); /* the prompt names the directory */ + a->last_prompt_tokens = 0; /* the old budget was measured elsewhere */ + return 0; +} + int cst_agent_switch_session(cst_agent_t *a, const char *id) { if (!a || !id || !id[0]) return -1; diff --git a/src/main.c b/src/main.c index e8ae18e..1e749f2 100644 --- a/src/main.c +++ b/src/main.c @@ -43,7 +43,7 @@ static void usage(const char *argv0) { " -n, --max-steps N model calls per turn (30)\n" " -H, --headless -p TEXT run one prompt without the TUI\n" " --web serve the UI over HTTP on 127.0.0.1:8787\n" - " --web-port N port (0 = pick a free one)\n" + " --web-port N port (default 8787, 0 = pick a free one)\n" " --web-host ADDR bind address (default 127.0.0.1; a\n" " non-loopback address requires a token)\n" " --web-token T require ?token=T / Authorization: Bearer T\n" @@ -332,6 +332,7 @@ int main(int argc, char **argv) { int web_mode = 0; cst_webui_opts_t web; memset(&web, 0, sizeof(web)); + web.port = CST_WEBUI_DEFAULT_PORT; /* 0 would mean "pick a free one" */ const char *env = getenv("CST_WEB_PORT"); if (env && env[0]) web.port = atoi(env); diff --git a/src/os_compat.c b/src/os_compat.c index e2a0720..66814a7 100644 --- a/src/os_compat.c +++ b/src/os_compat.c @@ -519,7 +519,7 @@ int os_run_timeout_effective(void) { const char *e = getenv("CST_SHELL_TIMEOUT"); if (e && atoi(e) > 0) return atoi(e); - return 120; + return 0; /* no limit: a build, a test suite or a long clone must finish */ } double os_now_ms(void) { @@ -662,7 +662,8 @@ int os_run_capture_ex(const char *cwd, const char *const argv[], int truncated = 0, killed = 0; int status = 0, reaped = 0, bg_killed = 0; double reaped_at = 0, killed_at = 0; - double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0; + int timeout_s = os_run_timeout_effective(); + double deadline = timeout_s > 0 ? os_now_ms() + timeout_s * 1000.0 : 0; for (;;) { struct pollfd pfd = {pipefd[0], POLLIN, 0}; int pr = poll(&pfd, 1, 100); @@ -692,7 +693,7 @@ int os_run_capture_ex(const char *cwd, const char *const argv[], } /* Checked on every pass, so a chatty command still stops promptly. */ if (!reaped && !killed && - ((g_run_cancel && *g_run_cancel) || now > deadline)) { + ((g_run_cancel && *g_run_cancel) || (deadline > 0 && now > deadline))) { killed = (g_run_cancel && *g_run_cancel) ? 2 : 1; killed_at = now; kill(-pid, SIGTERM); diff --git a/src/os_win.c b/src/os_win.c index 67f35aa..a29c2f8 100644 --- a/src/os_win.c +++ b/src/os_win.c @@ -601,7 +601,10 @@ int os_run_capture_token(const char *cwd, const char *const argv[], buf[0] = 0; int truncated = 0, killed = 0, reaped = 0, bg_killed = 0; double reaped_at = 0, killed_at = 0; - double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0; + /* no timeout unless one was asked for: a deadline far in the future keeps + * every `now > deadline` check working without a second code path */ + int timeout_s = os_run_timeout_effective(); + double deadline = timeout_s > 0 ? os_now_ms() + timeout_s * 1000.0 : 1e18; int idle = 0; /* empty polls in a row: wait longer once output stalls */ for (;;) { DWORD avail = 0; diff --git a/src/tui.c b/src/tui.c index 0cb9b61..bea04ab 100644 --- a/src/tui.c +++ b/src/tui.c @@ -5364,6 +5364,66 @@ static void cmd_quit(tui_t *t, char *arg, int *quit) { *quit = 1; } +/* /web: the WebUI is a second front end for the same engine and is designed to + * run as its own process, so this starts one on the same workspace and leaves + * the TUI holding the terminal. The child gets a fresh session: two writers on + * one session file is the one thing that would corrupt it. */ +#ifndef _WIN32 +#include /* waitpid: collect the WebUI started by the last /web */ +#endif +#ifndef CST_WEBUI_DEFAULT_PORT +#define CST_WEBUI_DEFAULT_PORT 8787 /* keep in sync with include/cst_webui.h */ +#endif +static void cmd_web(tui_t *t, char *arg, int *quit) { + (void)arg; + (void)quit; +#ifdef _WIN32 + push_system_line(t, "open another terminal and run: cst-agent --web"); +#else + static pid_t spawned = 0; + if (spawned > 0) { /* collect the one we started last time */ + waitpid(spawned, NULL, WNOHANG); + spawned = 0; + } + char dir[4096], exe[4096 + 16]; + if (os_exe_dir(dir, sizeof(dir)) != 0) { + push_system_line(t, "cannot locate this binary: run cst-agent --web yourself"); + return; + } + snprintf(exe, sizeof(exe), "%s/cst-agent", dir); + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + const char *ws = (cfg && cfg->workspace_root[0]) ? cfg->workspace_root : "."; + char logf[4096]; + snprintf(logf, sizeof(logf), "%s/.agent/webui.log", ws); + pid_t pid = fork(); + if (pid < 0) { + push_system_line(t, "cannot start a second process"); + return; + } + if (pid == 0) { + setsid(); + int fd = open(logf, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + dup2(fd, STDOUT_FILENO); + dup2(fd, STDERR_FILENO); + if (fd > 2) + close(fd); + } + execl(exe, "cst-agent", "--web", "--workspace", ws, "--new", (char *)NULL); + _exit(127); + } + spawned = pid; + char msg[4200]; + snprintf(msg, sizeof(msg), + "WebUI started in the background: http://127.0.0.1:%d/ (new session)", + CST_WEBUI_DEFAULT_PORT); + push_system_line(t, msg); + snprintf(msg, sizeof(msg), "its log: %s (port busy? see the log, or pass " + "--web-port)", logf); + push_system_line(t, msg); +#endif +} + static const slash_cmd_t SLASH[] = { {"help", "?", "", "list commands and keys", 1, cmd_help}, {"new", "clear", "", "start a new session", 0, cmd_new}, @@ -5422,6 +5482,8 @@ static const slash_cmd_t SLASH[] = { cmd_agents}, {"skill", "", " [task]", "run a task with a skill loaded", 0, cmd_skill}, + {"web", "", "", "start the WebUI in the background (127.0.0.1:8787)", 1, + cmd_web}, {"quit", "q exit", "", "quit", 1, cmd_quit}, }; diff --git a/src/webui.c b/src/webui.c index 593c934..f03528a 100644 --- a/src/webui.c +++ b/src/webui.c @@ -181,6 +181,8 @@ static cJSON *wui_state_new(wui_t *w) { cJSON_AddNumberToObject(u, "requests", sess.requests); cJSON_AddNumberToObject(u, "prompt_tokens", (double)sess.prompt); cJSON_AddNumberToObject(u, "completion_tokens", (double)sess.out); + cJSON_AddNumberToObject(u, "cached_tokens", (double)sess.cached); + cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)sess.miss); cJSON_AddNumberToObject(u, "cost", sess.cost); cJSON_AddBoolToObject(u, "priced", sess.priced != 0); cJSON_AddStringToObject(u, "currency", sess.currency); @@ -916,6 +918,38 @@ static int h_session_new(cst_http_conn_t *c, void *ud) { return rc; } +/* POST /api/workspace {"path":"/abs/dir"} - re-root the agent. The session + * store is per directory, so this always lands in a fresh session there. */ +static int h_workspace(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + const cst_http_req_t *req = cst_http_request(c); + cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL; + const cJSON *p = j ? cJSON_GetObjectItem(j, "path") : NULL; + if (!cJSON_IsString(p) || !p->valuestring[0]) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, "path is required"); + } + char err[256] = ""; + int rc = cst_agent_set_workspace(w->agent, p->valuestring, err, sizeof(err)); + cJSON_Delete(j); + if (rc != 0) + return cst_http_send_err(c, 400, err[0] ? err : "cannot switch workspace"); + 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"); + wui_emit_str(w, "session", "id", id); + wui_emit_state(w); + cJSON *o = cJSON_CreateObject(); + if (!o) + return cst_http_send_err(c, 500, "out of memory"); + cJSON_AddStringToObject(o, "id", id); + char *json = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + rc = cst_http_send_json(c, 200, json); + free(json); + return rc; +} + static int h_session_switch(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); @@ -1134,7 +1168,10 @@ static void open_browser(const char *url) { os_run_shell_c(".", cmd, 0, NULL); #else char cmd[600]; - snprintf(cmd, sizeof(cmd), "xdg-open '%s' >/dev/null 2>&1 &", url); + /* Termux has no xdg-open; termux-open-url hands the URL to Android. */ + const char *opener = + getenv("TERMUX_VERSION") ? "termux-open-url" : "xdg-open"; + snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url); os_run_shell_c(".", cmd, 0, NULL); #endif (void)url; @@ -1173,6 +1210,7 @@ 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/workspace", h_workspace, NULL, 0, 0}, {"POST", "/api/turn", h_turn, NULL, 0, 0}, {"POST", "/api/cancel", h_cancel, NULL, 0, 0}, {"POST", "/api/permission", h_permission, NULL, 0, 0}, diff --git a/web/app.js b/web/app.js index 0c50f9c..9cc6a84 100644 --- a/web/app.js +++ b/web/app.js @@ -1,6 +1,7 @@ /* CST-Agent WebUI client: HTTP + SSE against the engine in the same binary. - * No build step, no dependencies: the server renders Markdown, this file - * only moves messages around and draws them. */ + * No build step, no dependencies. The server renders Markdown to sanitized + * HTML; this file lays out the conversation, the tool lines, the approval + * gate and the anchored popovers. Protocol: docs/webui.md. */ 'use strict'; /* --- token: from ?token= / #token= once, then kept in a cookie by the @@ -9,11 +10,14 @@ const TOKEN = (() => { const q = new URLSearchParams(location.search); if (q.get('token')) return q.get('token'); - const h = location.hash.replace(/^#/, ''); - const hp = new URLSearchParams(h); - return hp.get('token') || ''; + return new URLSearchParams(location.hash.replace(/^#/, '')).get('token') || ''; })(); - +const qs = (extra) => { + const p = new URLSearchParams(extra || {}); + if (TOKEN) p.set('token', TOKEN); + const s = p.toString(); + return s ? '?' + s : ''; +}; function api(path, opt) { if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN); return fetch(path, opt); @@ -35,202 +39,596 @@ async function post(path, body) { } const $ = (id) => document.getElementById(id); -const log = $('log'); -let state = {}; -let live = null; /* {body, html, think} for the streaming assistant block */ -let tools = new Map();/* tool call id -> element */ -let permReq = null; /* pending permission event */ -let askReq = null; /* pending question */ -let firstPaint = true; - -/* --- transcript ---------------------------------------------------------- */ - -function atBottom() { - return log.scrollTop + log.clientHeight >= log.scrollHeight - 40; -} -function scrollDown(force) { - if (force || atBottom()) log.scrollTop = log.scrollHeight; -} -function el(tag, cls, text) { +const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; +}; +const attachURL = (rel) => 'api/attach/' + rel + qs(); +const fmtSecs = (ms) => { + const s = ms / 1000; + if (s < 60) return s.toFixed(1) + ' 秒'; + const m = Math.floor(s / 60); + return m + ' 分 ' + Math.round(s - m * 60) + ' 秒'; +}; +const num = (n) => (n || 0).toLocaleString(); + +const log = $('log'); +const scroll = $('scroll'); +let state = {}; +let live = null; /* streaming assistant block */ +let steps = null; /* this turn's tool list */ +let stepIndex = new Map(); /* tool call id -> step row */ +let files = new Map(); /* this turn's file changes: path -> {add, del} */ +let permReq = null; +let askReq = null; +let busy = false; +let turnStart = 0; + +/* --- anchored popovers --------------------------------------------------- + * The pattern the reference apps use: a small sheet under the control that + * owns the setting, each row = icon + title + one line, a check on the + * current value. Shift+Tab and the arrow keys work, Escape closes. */ + +let popAnchor = null; +let popOpenedAt = 0; +/* icons all come from the sprite in index.html: one 24px grid, one stroke + * weight, round caps. A mixed bag of unicode glyphs never reads as a family. */ +const SVGNS = 'http://www.w3.org/2000/svg'; +function icon(name, cls) { + /* an unknown name is just an old text glyph: keep it, do not blank it out */ + if (!document.getElementById('i-' + name)) return el('span', cls || '', name); + const s = document.createElementNS(SVGNS, 'svg'); + s.setAttribute('class', 'i' + (cls ? ' ' + cls : '')); + const u = document.createElementNS(SVGNS, 'use'); + u.setAttribute('href', '#i-' + name); + s.appendChild(u); + return s; } -function bubble(who, cls) { - const m = el('div', 'msg ' + (cls || '')); - m.appendChild(el('div', 'who', who)); +function setIcon(btn, name) { + const old = btn.querySelector('svg.i'); + if (old) old.replaceWith(icon(name)); +} +function closePop() { + const p = $('pop'); + p.hidden = true; + p.textContent = ''; + popAnchor = null; +} +function popRow(o) { + const b = el('button', 'pop-item' + (o.on ? ' on' : '') + (o.danger ? ' danger' : '')); + b.appendChild(o.icon ? icon(o.icon, 'pi') : el('span', 'pi')); + const m = el('span', 'pm'); + m.appendChild(el('span', 'pt', o.title)); + if (o.desc) m.appendChild(el('span', 'pd', o.desc)); + b.appendChild(m); + if (o.on) b.appendChild(el('span', 'pk', '✓')); + b.onclick = () => { const fn = o.onClick; closePop(); if (fn) fn(); }; + return b; +} +function positionPop(pop, anchor, align) { + const a = anchor.getBoundingClientRect(); + pop.style.visibility = 'hidden'; + pop.style.left = '0px'; + pop.style.top = '0px'; + const p = pop.getBoundingClientRect(); + let left = align === 'right' ? a.right - p.width : a.left; + left = Math.max(8, Math.min(left, window.innerWidth - p.width - 8)); + let top = a.bottom + 8; + let origin = 'top'; + if (top + p.height > window.innerHeight - 8) { + top = Math.max(8, a.top - p.height - 8); + origin = 'bottom'; + } + pop.style.left = left + 'px'; + pop.style.top = top + 'px'; + pop.style.transformOrigin = (align === 'right' ? '100%' : '0') + ' ' + (origin === 'bottom' ? '100%' : '0'); + pop.style.visibility = 'visible'; +} +function openPop(anchor, build, opt) { + const pop = $('pop'); + if (popAnchor === anchor) return closePop(); /* second tap closes */ + closePop(); + pop.textContent = ''; + build(pop, closePop); + pop.hidden = false; + positionPop(pop, anchor, (opt && opt.align) || 'left'); + popAnchor = anchor; + popOpenedAt = Date.now(); +} +document.addEventListener('click', (e) => { + if ($('pop').hidden) return; + /* the click lands on a child of the anchor button, so compare containers */ + if ($('pop').contains(e.target) || (popAnchor && popAnchor.contains(e.target))) return; + closePop(); +}); +/* a phone browser fires resize while the address bar settles right after the + * popover opened; that is not the user dismissing it */ +window.addEventListener('resize', () => { + if (Date.now() - popOpenedAt > 700) closePop(); +}); +window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !$('pop').hidden) closePop(); +}); + +/* --- transcript ---------------------------------------------------------- */ + +function atBottom() { + return scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 60; +} +function scrollDown(force) { + if (force || atBottom()) scroll.scrollTop = scroll.scrollHeight; + updateJump(); +} +function updateJump() { + $('to-bottom').hidden = atBottom(); +} +function showEmpty(show) { + $('empty').classList.toggle('gone', !show); +} +function bubble(role, cls) { + const m = el('div', 'msg ' + (cls || role)); + /* no avatar per message: the assistant reads as plain text in the column, + * the way the reference transcript does; only errors get a mark */ + const g = el('div', 'gutter', role === 'error' ? '!' : ''); const b = el('div', 'body'); + m.appendChild(g); m.appendChild(b); log.appendChild(m); + showEmpty(false); scrollDown(true); return { root: m, body: b }; } - +function copyButton(text) { + const b = el('button', null, '复制'); + b.onclick = async () => { + try { + await navigator.clipboard.writeText(text); + b.textContent = '已复制'; + setTimeout(() => { b.textContent = '复制'; }, 1200); + } catch { b.textContent = '复制失败'; } + }; + return b; +} +function attachStrip(atts) { + const strip = el('div', 'attach-strip'); + for (const a of atts) { + const img = el('img'); + img.alt = a.name || 'image'; + img.loading = 'lazy'; + img.src = attachURL(a.rel); + strip.appendChild(img); + } + return strip; +} +/* reasoning belongs inside .body: as a sibling it becomes a second flex item + * and squeezes the answer into a narrow column */ +function thinkInto(body, text, open) { + let d = body.querySelector(':scope > details.think'); + let t; + if (!d) { + d = el('details', 'think'); + d.appendChild(el('summary', null, '思考过程')); + t = el('div'); + d.appendChild(t); + body.insertBefore(d, body.firstChild); + } else { + t = d.querySelector('div'); + } + if (open) d.open = true; + t.textContent += text; + return d; +} +function actionsFor(root, text) { + const row = el('div', 'msg-actions'); + row.appendChild(copyButton(text)); + root.appendChild(row); + return row; +} function renderMessage(m) { - if (m.role === 'system') return; - if (m.role === 'tool') return; /* drawn with its call */ - const cls = m.role === 'user' ? 'user' : 'assistant'; - const who = m.role === 'user' ? 'You' : 'Assistant'; - const b = bubble(who, cls); - if (m.role === 'assistant' && m.html) b.body.innerHTML = m.html; + if (m.role === 'system' || m.role === 'tool') return; + 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) { - const d = el('details', 'think'); - d.appendChild(el('summary', null, 'Thought')); - d.appendChild(el('div', null, m.reasoning)); - b.root.insertBefore(d, b.body); - } - if (m.attach) { - for (const a of m.attach) { - const img = el('img'); - img.alt = a.name || 'image'; - img.src = 'api/attach/' + a.rel + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : ''); - log.insertBefore(img, null); - } - } + 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 || ''); } async function loadSession() { const data = await getJSON('api/session'); log.textContent = ''; - firstPaint = true; + stepIndex = new Map(); + files = new Map(); + renderFilesPanel(); + steps = null; + live = null; for (const m of data.messages) renderMessage(m); - firstPaint = false; + showEmpty(log.children.length === 0); + const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user'); + document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent'; scrollDown(true); + updateJump(); } /* --- live blocks --------------------------------------------------------- */ function ensureLive() { if (live) return live; - const b = bubble('Assistant', 'assistant'); - live = { root: b.root, body: b.body, html: '', think: null }; + const b = bubble('assistant'); + b.body.classList.add('live'); + live = { root: b.root, body: b.body, html: '' }; return live; } - -function thinkBlock(text) { - const l = ensureLive(); - if (!l.think) { - const d = el('details', 'think'); - d.appendChild(el('summary', null, 'Thought')); - const t = el('div'); - d.appendChild(t); - l.root.insertBefore(d, l.body); - l.think = t; - } - l.think.textContent += text; - return l; +function ensureSteps() { + if (steps) return steps; + const d = el('details', 'turn-steps'); + d.open = true; + const s = el('summary', 'steps-sum', '本轮工具调用'); + d.appendChild(s); + const box = el('div', 'steps-box'); + d.appendChild(box); + log.appendChild(d); + steps = { root: d, sum: s, box, count: 0 }; + return steps; +} +function phaseText(p) { + return { open: '准备中', ready: '待执行', run: '执行中', done: '完成' }[p] || p || ''; +} +function argsLine(args) { + if (!args) return ''; + return Object.entries(args) + .map(([k, v]) => (typeof v === 'string' ? v : JSON.stringify(v))) + .join(' '); +} +/* one line per call, like "已执行 python · <": open it for the details */ +function stepFor(ev) { + let st = stepIndex.get(ev.id); + if (st) return st; + const c = ensureSteps(); + const node = el('details', 'step-line run'); + const sum = el('summary'); + const name = el('span', 'name', ev.name || 'tool'); + const args = el('span', 'args'); + const phase = el('span', 'phase', '准备中'); + sum.appendChild(name); + sum.appendChild(args); + sum.appendChild(phase); + const body = el('div', 'step-body'); + node.appendChild(sum); + node.appendChild(body); + c.box.appendChild(node); + st = { root: node, sum, name, args, phase, body, t0: Date.now(), bad: false }; + stepIndex.set(ev.id, st); + c.count++; + c.sum.textContent = '本轮工具调用 · ' + c.count + ' 次'; + return st; } -function toolCard(ev) { - let c = tools.get(ev.id); - if (!c) { - const d = el('details', 'tool'); - const s = el('summary', null, ev.name || 'tool'); - d.appendChild(s); - const args = el('div', 'args', JSON.stringify(ev.args || {}, null, 0)); - d.appendChild(args); - const out = el('pre'); - d.appendChild(out); - log.appendChild(d); - c = { root: d, sum: s, out, open: false }; - tools.set(ev.id, c); - } - return c; +/* --- the run panel answers one question: which files moved, and by how many + * lines. Everything else about a call stays in the transcript where it + * happened, so the panel stays readable no matter how long a turn runs. */ +function linesOf(s) { + return (typeof s === 'string' && s !== '') ? s.split('\n').length : 0; +} +function patchStat(text) { + let add = 0, del = 0; + for (const ln of String(text).split('\n')) { + if (ln.startsWith('+++') || ln.startsWith('---')) continue; + if (ln.charAt(0) === '+') add++; + else if (ln.charAt(0) === '-') del++; + } + return { add, del }; +} +function statFromArgs(name, args) { + const a = args || {}, n = String(name || ''); + const body = a.patch ?? a.diff ?? a.input; + if (/patch|diff/i.test(n) && typeof body === 'string') return patchStat(body); + if (/write|create/i.test(n) && typeof a.content === 'string') + return { add: linesOf(a.content), del: 0 }; + if (/edit|replace|rename/i.test(n)) { + const old = a.old_string ?? a.old ?? a.find ?? a.old_text; + const nw = a.new_string ?? a.new ?? a.replace ?? a.new_text; + if (typeof nw === 'string' || typeof old === 'string') + return { add: linesOf(nw), del: linesOf(old) }; + } + return null; +} +function noteFileChange(ev) { + const st = statFromArgs(ev.name, ev.args); + if (!st) return; + const a = ev.args || {}; + const path = String(a.path || a.file_path || a.file || a.filename || '补丁'); + const cur = files.get(path) || { add: 0, del: 0 }; + cur.add += st.add; + cur.del += st.del; + files.set(path, cur); + renderFilesPanel(); +} +function renderFilesPanel() { + const box = $('files-body'); + if (!box) return; + box.textContent = ''; + box.className = ''; + if ($('files-count')) $('files-count').textContent = String(files.size); + if (!files.size) { + box.className = 'muted'; + box.textContent = '本轮还没有改动文件。'; + return; + } + for (const [p, v] of files) { + const row = el('div', 'fstat'); + row.appendChild(icon('file', 'fimg')); + const nm = el('span', 'p', p); + nm.title = p; + row.appendChild(nm); + row.appendChild(el('span', 'a', '+' + v.add)); + row.appendChild(el('span', 'd', '−' + v.del)); + box.appendChild(row); + } } - function onTool(ev) { - const c = toolCard(ev); - if (ev.phase === 'start' || ev.phase === 'begin') { - c.sum.classList.add('spin'); - c.open = true; - c.root.open = true; - } else if (ev.phase === 'end' || ev.phase === 'done') { - c.sum.classList.remove('spin'); + 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; + st.phase.textContent = phaseText(ev.phase); + st.root.classList.toggle('run', ev.phase !== 'done'); + if (ev.phase === 'done') { + st.root.classList.remove('run'); + st.root.classList.add('done'); + st.phase.textContent = '完成 · ' + ((Date.now() - st.t0) / 1000).toFixed(1) + 's'; } - if (ev.detail && !c.out.textContent) c.out.textContent = ev.detail; scrollDown(false); } - function onToolResult(ev) { - const c = toolCard(ev); - c.sum.classList.remove('spin'); - c.out.textContent = (ev.out || '') + (ev.truncated ? '\n… (截断)' : ''); - if (ev.exit) { - c.sum.appendChild(el('span', ev.exit > 0 ? 'exit-bad' : '', ' exit=' + ev.exit)); - } + const st = stepFor(ev); + st.root.classList.remove('run'); + st.bad = !!ev.exit; + st.root.classList.toggle('bad', st.bad); + st.phase.textContent = st.bad ? ('失败 exit=' + ev.exit) : '完成'; + st.body.textContent = ''; + if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent)); + if (ev.out) st.body.appendChild(el('pre', 'step-out', ev.out + (ev.truncated ? '\n… (截断)' : ''))); + if (!st.bad) noteFileChange(ev); scrollDown(false); } +function turnMeta(ms) { + const wrap = el('div', 'turn-meta'); + const d = el('details'); + d.appendChild(el('summary', null, '用时 ' + fmtSecs(ms))); + d.appendChild(el('div', 'muted tiny', '从发出去到最后一个工具结束;模型延迟和工具耗时都算在里面。')); + wrap.appendChild(d); + log.appendChild(wrap); +} -/* --- state / status ------------------------------------------------------ */ +/* --- model checklist ----------------------------------------------------- */ + +function todoRows(text) { + const rows = []; + for (const raw of String(text || '').split('\n')) { + let line = raw.replace(/\s+$/, ''); + if (!line.trim()) continue; + let done = false, doing = false; + line = line.replace(/^\s*[-*+]\s*/, '').replace(/^\s*\d+[.)]\s*/, ''); + if (/^\[[xX]\]/.test(line)) { done = true; line = line.replace(/^\[[xX]\]\s*/, ''); } + else line = line.replace(/^\[( |>)\]\s*/, ''); + if (/^(✔|✅|\[done\])/i.test(line)) { done = true; line = line.replace(/^(✔|✅|\[done\])\s*/i, ''); } + else if (/^(▶|➡|\[now\]|\[doing\])/i.test(line)) { doing = true; line = line.replace(/^(▶|➡|\[now\]|\[doing\])\s*/i, ''); } + if (!line.trim()) continue; + rows.push({ text: line, done, doing }); + } + return rows; +} +function renderTodo(text) { + const body = $('todo-body'); + body.textContent = ''; + const rows = todoRows(text); + if (!rows.length) { + body.className = 'muted'; + body.textContent = '模型还没有给出任务清单。'; + return; + } + body.className = ''; + const ul = el('ul', 'todo-list'); + for (const r of rows) { + const li = el('li', r.done ? 'done' : r.doing ? 'doing' : ''); + li.appendChild(el('span', 'boxmark', r.done ? '☑' : r.doing ? '▣' : '☐')); + li.appendChild(el('span', null, r.text)); + ul.appendChild(li); + } + body.appendChild(ul); +} + +/* --- state --------------------------------------------------------------- */ + +const APPROVALS = [ + { key: 'ask', icon: 'shield', title: '每次询问', desc: '动文件或跑命令之前都要你点一下' }, + { key: 'auto', icon: 'bolt', title: '自动', desc: '直接执行,只有危险命令才问你' }, + { key: 'yolo', icon: 'alert', title: '完全放行', desc: '不再询问,风险自负' }, +]; +const EFFORTS = [ + { key: 'low', title: '低', desc: '最快,适合小改动' }, + { key: 'medium', title: '中', desc: '默认平衡' }, + { key: 'high', title: '高', desc: '多想一点,慢一些' }, + { key: 'max', title: '极高', desc: '最慢最贵,难题再用' }, +]; function applyState(s) { - state = s; - $('ws').textContent = s.workspace || ''; - $('ws').title = s.workspace || ''; - $('model').textContent = (s.provider ? s.provider + ':' : '') + (s.model || '-'); - const pm = $('planmode'); - pm.textContent = s.plan_mode ? 'Plan' : 'Build'; - pm.classList.toggle('on', !!s.plan_mode); - $('approval').textContent = s.approval || 'auto'; - $('ctx').textContent = s.context_tokens ? (s.context_tokens + ' tok') : ''; + state = Object.assign({}, state, s); + const model = (s.provider ? s.provider + ' · ' : '') + (s.model || '-'); + $('chip-model').querySelector('.lbl').textContent = model; + $('chip-model').title = model + (s.effort ? ' · ' + s.effort : ''); + const ap = $('chip-approval'); + const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto')); + ap.querySelector('.lbl').textContent = (cur ? cur.title : s.approval || 'auto'); + ap.classList.toggle('warn', s.approval === 'ask'); + ap.title = '审批权限:' + (cur ? cur.title : s.approval); + for (const b of $('seg-mode').children) + b.classList.toggle('on', (b.dataset.mode === 'plan') === !!s.plan_mode); + $('foot-ws').textContent = s.workspace || 'workspace'; + $('foot-session').textContent = (s.session || '-').slice(0, 20); + const u = s.usage || {}; - $('usage').textContent = u.requests - ? `${u.requests} req · ${u.prompt_tokens}/${u.completion_tokens} tok` + - (u.priced && u.cost ? ` · ${u.currency}${u.cost.toFixed(4)}` : '') - : ''; - const busy = !!s.status_busy; + const money = (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : ''; + /* the panel carries two numbers only: what the context costs, and how much + * of it the provider already had cached */ + const hit = Math.max(0, u.cached_tokens || 0); + const miss = Math.max(0, u.cache_miss_tokens || 0); + $('kv-ctx').textContent = num(s.context_tokens) + ' tok'; + $('kv-cache').textContent = (hit + miss) ? Math.round((hit / (hit + miss)) * 100) + '%' : '-'; + $('kv-cache').title = (hit + miss) + ? num(hit) + ' 命中 / ' + num(miss) + ' 未命中' : '还没有缓存数据'; + $('cost').textContent = money; + $('usage').textContent = u.requests ? `${u.requests} 往返 · ${num(u.prompt_tokens)}/${num(u.completion_tokens)} tok` : ''; + $('btn-undo').disabled = !s.undo_available; + + busy = !!s.status_busy; $('btn-stop').hidden = !busy; - $('btn-send').hidden = busy; - $('status').textContent = busy ? '运行中…' : '就绪'; + syncSend(); + if (!busy && !permReq) $('status').textContent = '就绪'; + const p = $('pending'); - p.hidden = !(s.pending_images && s.pending_images.length); - p.textContent = (s.pending_images || []).join(' '); - const t = $('todo'); - t.hidden = !s.todo; - if (s.todo) { - t.textContent = ''; - t.appendChild(el('h4', null, '任务')); - t.appendChild(el('div', null, s.todo)); + const n = (s.pending_images || []).length; + p.hidden = !n; + if (n) { + p.textContent = ''; + for (const line of s.pending_images) { + const chip = el('span', 'chip'); + chip.appendChild(el('span', null, line)); + const x = el('button', 'icon tiny'); + x.appendChild(icon('close')); + x.title = '清空待发图片'; + x.onclick = () => post('api/attach', {}).catch(() => {}); + chip.appendChild(x); + p.appendChild(chip); + } } + renderTodo(s.todo); } /* --- SSE ----------------------------------------------------------------- */ function connect() { - const url = 'api/events' + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : ''); - const es = new EventSource(url); - es.addEventListener('state', (e) => applyState(JSON.parse(e.data))); - es.addEventListener('status', (e) => { $('status').textContent = JSON.parse(e.data).message; }); - es.addEventListener('assistant_html', (e) => { + const es = new EventSource('api/events' + qs()); + /* a turn can also start outside this page (the CLI, another client), so the + * clock starts on the first sign of activity, not only on send() */ + const markBusy = () => { if (!turnStart) turnStart = Date.now(); }; + const on = (name, fn) => es.addEventListener(name, (e) => { + 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('assistant_html', (d) => { + markBusy(); const l = ensureLive(); - l.html += JSON.parse(e.data).text; + l.html += d.text || ''; l.body.innerHTML = l.html; scrollDown(false); }); - es.addEventListener('assistant_delta', () => {}); - es.addEventListener('think_delta', (e) => { - thinkBlock(JSON.parse(e.data).text); + on('think_delta', (d) => { + markBusy(); + thinkInto(ensureLive().body, d.text || '', true); scrollDown(false); }); - es.addEventListener('tool', (e) => onTool(JSON.parse(e.data))); - es.addEventListener('tool_result', (e) => onToolResult(JSON.parse(e.data))); - es.addEventListener('todo', (e) => applyState(Object.assign({}, state, { todo: JSON.parse(e.data).text }))); - es.addEventListener('session', () => loadSession()); - es.addEventListener('permission', (e) => showPermission(JSON.parse(e.data))); - es.addEventListener('permission_done', () => closeModal()); - es.addEventListener('ask_user', (e) => showAsk(JSON.parse(e.data))); - es.addEventListener('turn_end', (e) => { - const d = JSON.parse(e.data); - live = null; - if (d.rc) bubble('Error', 'assistant').body.textContent = d.error || ('rc=' + d.rc); - else loadSession(); /* replace the live view with the stored messages */ + on('tool', (ev) => { markBusy(); onTool(ev); }); + on('tool_result', onToolResult); + on('todo', (d) => renderTodo(d.text)); + on('session', (d) => { + closePop(); + live = null; steps = null; stepIndex = new Map(); + loadSession().then(loadSessions).catch(() => {}); + closeDrawers(); + if (d && d.id) $('status').textContent = '已切换会话'; + }); + on('permission', showGate); + on('permission_done', hideGate); + on('ask_user', showAsk); + on('turn_end', (d) => { + 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); + loadSession().then(loadSessions).catch(() => {}); }); es.onerror = () => { $('status').textContent = '连接中断,重连中…'; }; } -/* --- modals -------------------------------------------------------------- */ +/* --- approval gate ------------------------------------------------------- */ + +const GATE_KEYS = [ + { v: 1, label: '允许一次', key: '1', cls: 'ok' }, + { v: 2, label: '总是允许这个工具', key: '2', cls: '' }, + { v: 3, label: '总是允许这个前缀', key: '3', cls: '' }, + { v: 0, label: '拒绝', key: '4', cls: 'danger' }, +]; +let gateTimer = null; +function showGate(ev) { + permReq = ev; + $('gate').hidden = false; + $('gate-title').textContent = ev.sandbox + ? '沙箱拦下了这一步,要在沙箱外重跑吗?' + : '允许 ' + (ev.tool || '工具') + ' 运行吗?'; + $('gate-detail').textContent = ev.preview || ev.detail || ''; + const row = $('gate-buttons'); + row.textContent = ''; + for (const a of GATE_KEYS) { + const b = el('button', a.cls, a.label + ' (' + a.key + ')'); + b.onclick = () => answerGate(a.v); + row.appendChild(b); + } + $('gate-keys').textContent = '按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。'; + $('status').textContent = '等待你的许可…'; + let left = 600; + clearInterval(gateTimer); + const tick = () => { + $('gate-timer').textContent = left >= 0 ? left + 's' : ''; + if (left-- <= 0) clearInterval(gateTimer); + }; + tick(); + gateTimer = setInterval(tick, 1000); + scrollDown(true); + $('input').focus(); +} +function hideGate() { + clearInterval(gateTimer); + gateTimer = null; + permReq = null; + $('gate').hidden = true; + $('gate-timer').textContent = ''; + if (!busy) $('status').textContent = '就绪'; +} +async function answerGate(value) { + if (!permReq) return; + const id = permReq.id; + hideGate(); + try { + await post('api/permission', { id, answer: value }); + $('status').textContent = value ? '已允许,继续执行…' : '已拒绝'; + } catch (e) { + $('status').textContent = '回答失败:' + (e.message || e); + } +} +window.addEventListener('keydown', (e) => { + if (permReq && !$('gate').hidden) { + if (e.key === '1' || e.key.toLowerCase() === 'y') answerGate(1); + else if (e.key === '2') answerGate(2); + else if (e.key === '3') answerGate(3); + else if (e.key === '4' || e.key.toLowerCase() === 'n') answerGate(0); + } + if (e.key === 'Escape') { + if (!$('modal').hidden) return closeModal(); + if (!$('pop').hidden) return closePop(); + if (busy) return void post('api/cancel', {}).catch(() => {}); + } + 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(); } +}); + +/* --- modal sheet (session list) ------------------------------------------ */ function openModal(node) { const box = $('modal-box'); @@ -241,54 +639,22 @@ function openModal(node) { function closeModal() { $('modal').hidden = true; $('modal-box').textContent = ''; - permReq = askReq = null; + askReq = null; } -window.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && !$('modal').hidden) closeModal(); -}); - -function showPermission(ev) { - permReq = ev; - const box = document.createDocumentFragment(); - box.appendChild(el('h3', null, (ev.sandbox ? '沙箱拦截:要在沙箱外重跑?' : '允许 ' + ev.tool + '?'))); - if (ev.preview) { - const pre = el('pre'); - pre.textContent = ev.preview; - box.appendChild(pre); - } else if (ev.detail) { - const pre = el('pre'); - pre.textContent = ev.detail; - box.appendChild(pre); - } - const row = el('div', 'buttons'); - const answer = (n) => { post('api/permission', { id: ev.id, answer: n }).catch(() => {}); closeModal(); }; - row.appendChild(Object.assign(el('button', 'danger', '拒绝'), { onclick: () => answer(0) })); - row.appendChild(Object.assign(el('button', 'ok', '允许一次'), { onclick: () => answer(1) })); - row.appendChild(Object.assign(el('button', null, '总是允许(工具)'), { onclick: () => answer(2) })); - if (ev.tool === 'run_shell') - row.appendChild(Object.assign(el('button', null, '总是允许(该前缀)'), { onclick: () => answer(3) })); - box.appendChild(row); - openModal(box); -} - function showAsk(ev) { askReq = ev; const box = document.createDocumentFragment(); - box.appendChild(el('h3', null, 'Agent 提问')); - const pre = el('pre'); - pre.textContent = ev.question || ''; - box.appendChild(pre); - const row = el('div', 'buttons'); + box.appendChild(el('h3', null, ev.question || '模型想确认一件事')); + if (ev.detail && ev.detail !== ev.question) box.appendChild(el('pre', 'step-out', ev.detail)); const send = (text, dismiss) => { post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(() => {}); closeModal(); }; - for (const opt of ev.options || []) { - row.appendChild(Object.assign(el('button', null, opt), { onclick: () => send(opt, false) })); - } + const row = el('div', 'buttons'); + for (const opt of ev.options || []) + row.appendChild(Object.assign(el('button', 'ok', opt), { onclick: () => send(opt, false) })); const free = el('input'); free.placeholder = '自定义回答'; - free.style.cssText = 'flex:1;min-width:220px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit'; row.appendChild(free); row.appendChild(Object.assign(el('button', 'ok', '回答'), { onclick: () => send(free.value, false) })); row.appendChild(Object.assign(el('button', 'danger', '忽略'), { onclick: () => send('', true) })); @@ -297,122 +663,374 @@ function showAsk(ev) { setTimeout(() => free.focus(), 0); } +/* --- sessions ------------------------------------------------------------ */ + +let sessionsCache = []; +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 { return; } + sessionsCache = data.sessions || []; + const box = $('sessions-list'); + box.textContent = ''; + for (const s of sessionsCache.slice(0, 60)) { + const b = el('button', 'sess-item' + (s.id === data.current ? ' on' : '')); + 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) || ''))); + b.title = s.id + ' · ' + s.messages + ' 条消息'; + b.onclick = async () => { + if (s.id === (state.session || '')) { closeDrawers(); return; } + await post('api/sessions/switch', { id: s.id }); + await loadSession(); + await loadSessions(); + closeDrawers(); + }; + box.appendChild(b); + } + if (!sessionsCache.length) box.appendChild(el('div', 'muted tiny', '还没有历史会话')); + /* measure after layout: only rows whose title really overflows get the scroll */ + requestAnimationFrame(() => { + for (const t of box.querySelectorAll('.t')) { + const ts = t.firstChild; + if (ts && ts.scrollWidth > t.clientWidth + 2) { + t.classList.add('mq'); + t.style.setProperty('--mq', (ts.scrollWidth - t.clientWidth + 6) + 'px'); + } + } + }); +} async function showSessions() { - const data = await getJSON('api/sessions'); + await loadSessions(); const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '会话')); - for (const s of data.sessions) { + for (const s of sessionsCache) { const row = el('div', 'sess'); - row.appendChild(el('span', 'id', (s.id === data.current ? '* ' : ' ') + s.id.slice(0, 12))); - row.appendChild(el('span', 't', s.title || '(空)')); - row.appendChild(el('span', 'id', s.messages + ' msgs')); + 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 () => { await post('api/sessions/switch', { id: s.id }); closeModal(); await loadSession(); + await loadSessions(); }, })); box.appendChild(row); } const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', 'ok', '新建会话'), { - onclick: async () => { await post('api/sessions/new', {}); closeModal(); await loadSession(); }, + onclick: async () => { + await post('api/sessions/new', {}); + closeModal(); + await loadSession(); + await loadSessions(); + }, })); row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row); openModal(box); } -function showModel() { - const box = document.createDocumentFragment(); - box.appendChild(el('h3', null, '模型与模式')); - const mk = (label, value, apply) => { - const row = el('div', 'buttons'); - row.appendChild(el('span', 'muted', label)); +/* --- pickers as popovers ------------------------------------------------- */ + +function modelPopover(anchor) { + openPop(anchor, (pop) => { + pop.appendChild(el('div', 'pop-title', '模型')); + const row = el('div', 'pop-input'); const input = el('input'); - input.value = value || ''; - input.style.cssText = 'flex:1;min-width:200px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit'; + input.value = state.model || ''; + input.placeholder = '模型名'; row.appendChild(input); - row.appendChild(Object.assign(el('button', 'ok', '应用'), { - onclick: async () => { await post('api/config', apply(input.value)); }, + const apply = el('button', null, '应用'); + apply.onclick = () => { post('api/config', { model: input.value }).catch(() => {}); }; + row.appendChild(apply); + pop.appendChild(row); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') apply.click(); }); + pop.appendChild(el('div', 'pop-sep')); + pop.appendChild(el('div', 'pop-title', '推理强度')); + const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' }; + for (const e2 of EFFORTS) + pop.appendChild(popRow({ + icon: LV[e2.key] || 'lv1', + title: e2.title, + desc: e2.desc, + on: e2.key === state.effort, + onClick: () => post('api/config', { effort: e2.key }).catch(() => {}), + })); + pop.appendChild(el('div', 'pop-sep')); + pop.appendChild(popRow({ + icon: 'settings', + title: 'provider:' + (state.provider || '-'), + desc: '在设置里改连接端点', + onClick: showSettings, })); - box.appendChild(row); - }; - mk('模型', state.model, (v) => ({ model: v })); - mk('provider', state.provider, (v) => ({ provider: v })); - const row = el('div', 'buttons'); - row.appendChild(el('span', 'muted', '推理强度')); - for (const level of ['low', 'medium', 'high', 'max']) { - row.appendChild(Object.assign(el('button', level === state.effort ? 'ok' : '', level), { - onclick: () => post('api/config', { effort: level }), + setTimeout(() => input.focus(), 0); + }); +} +function approvalPopover(anchor) { + openPop(anchor, (pop) => { + pop.appendChild(el('div', 'pop-title', '审批权限')); + for (const a of APPROVALS) + pop.appendChild(popRow({ + icon: a.icon, + title: a.title, + desc: a.desc, + on: a.key === (state.approval || 'auto'), + danger: a.key === 'yolo', + onClick: () => post('api/config', { approval: a.key }).catch(() => {}), + })); + }, { align: 'right' }); +} +function plusPopover(anchor) { + openPop(anchor, (pop) => { + pop.appendChild(popRow({ + icon: 'image', title: '上传图片', desc: '也可以直接粘贴或拖进来', + onClick: () => $('file').click(), })); - } - box.appendChild(row); - const row2 = el('div', 'buttons'); - row2.appendChild(el('span', 'muted', '审批')); - for (const mode of ['ask', 'auto', 'yolo']) { - row2.appendChild(Object.assign(el('button', mode === state.approval ? 'ok' : '', mode), { - onclick: () => post('api/config', { approval: mode }), + pop.appendChild(popRow({ + icon: 'clipboard', title: '粘贴剪贴板', desc: '从剪贴板里取一张图', + onClick: async () => { + try { + const items = await navigator.clipboard.read(); + for (const it of items) { + const type = it.types.find((t) => t.startsWith('image/')); + if (!type) continue; + const blob = await it.getType(type); + await upload(new File([blob], 'clipboard.png', { type })); + return; + } + $('status').textContent = '剪贴板里没有图片'; + } catch { $('status').textContent = '浏览器不允许读剪贴板,请直接粘贴'; } + }, })); - } - row2.appendChild(Object.assign(el('button', state.plan_mode ? 'ok' : '', state.plan_mode ? 'Plan' : 'Build'), { - onclick: () => post('api/config', { plan_mode: !state.plan_mode }), - })); - box.appendChild(row2); - const row3 = el('div', 'buttons'); - row3.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); - box.appendChild(row3); - openModal(box); + pop.appendChild(el('div', 'pop-sep')); + pop.appendChild(popRow({ + icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件', + on: !!state.plan_mode, + onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(() => {}), + })); + pop.appendChild(el('div', 'pop-sep')); + pop.appendChild(el('div', 'pop-title', '工作目录')); + const wrow = el('div', 'pop-input'); + const wpath = el('input'); + wpath.value = state.workspace || ''; + wpath.placeholder = '/绝对路径/到项目'; + wpath.spellcheck = false; + const wgo = el('button', null, '切换'); + wgo.onclick = async () => { + const p = wpath.value.trim(); + if (!p) return; + wgo.disabled = true; + try { + await post('api/workspace', { path: p }); + closePop(); + await loadSession(); + await loadSessions(); + $('status').textContent = '工作目录已切换'; + } catch (e) { + $('status').textContent = String(e.message || e); + wgo.disabled = false; + } + }; + wrow.appendChild(wpath); + wrow.appendChild(wgo); + pop.appendChild(wrow); + }); +} +function usagePopover(anchor) { + const u = state.usage || {}; + openPop(anchor, (pop) => { + pop.appendChild(el('div', 'pop-title', '状态')); + const kv = (k, v) => { + const row = el('div', 'kv'); + row.appendChild(el('span', null, k)); + row.appendChild(el('b', null, v)); + pop.appendChild(row); + }; + kv('工作区', state.workspace || '-'); + kv('会话', state.session || '-'); + kv('上下文占用', num(state.context_tokens) + ' tok'); + kv('模型往返', String(u.requests || 0)); + kv('Token', num(u.prompt_tokens) + ' / ' + num(u.completion_tokens)); + kv('费用', (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : (u.requests ? '未定价' : '-')); + kv('协议', state.protocol || '-'); + pop.appendChild(el('div', 'pop-sep')); + pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') })); + pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', desc: '把旧消息收成摘要', onClick: compact })); + }, { align: 'right' }); +} +function menuPopover(anchor) { + openPop(anchor, (pop) => { + pop.appendChild(popRow({ icon: 'list', title: '会话列表', desc: 'Ctrl+K', onClick: showSessions })); + pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') })); + 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: 'moon', title: '切换主题', onClick: toggleTheme })); + pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).catch(() => {}) })); + }, { align: 'right' }); +} +function showSettings() { + openPop($('btn-settings'), (pop) => { + pop.appendChild(el('div', 'pop-title', '工作区')); + const kv = (k, v) => { + const row = el('div', 'kv'); + row.appendChild(el('span', null, k)); + row.appendChild(el('b', null, v || '-')); + pop.appendChild(row); + }; + kv('目录', state.workspace); + kv('会话', state.session); + kv('模型', (state.provider ? state.provider + ' · ' : '') + state.model); + kv('协议', state.protocol); + pop.appendChild(el('div', 'pop-sep')); + pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) })); + pop.appendChild(popRow({ icon: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) })); + pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); + }); +} +async function copyText(t) { + try { + await navigator.clipboard.writeText(t); + $('status').textContent = '已复制:' + t; + } catch { $('status').textContent = '复制失败'; } } /* --- composer ------------------------------------------------------------ */ -async function send() { +function syncSend() { + $('btn-send').disabled = busy || !$('input').value.trim(); +} +async function compact() { + $('status').textContent = '压缩中…'; + try { await post('api/compact', {}); $('status').textContent = '上下文已压缩'; } + catch (e) { $('status').textContent = '压缩失败:' + (e.message || e); } +} +async function undo() { + try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; } + catch (e) { $('status').textContent = '撤销失败:' + (e.message || e); } +} +async function send(text) { const input = $('input'); - const text = input.value.trim(); - if (!text || state.status_busy) return; - input.value = ''; - bubble('You', 'user').body.textContent = text; + const body = (text !== undefined ? text : input.value).trim(); + if (!body || busy) return; + if (text === undefined) input.value = ''; + autoGrow(); + syncSend(); + bubble('user').body.textContent = body; + steps = null; + stepIndex = new Map(); + files = new Map(); + live = null; + renderFilesPanel(); + turnStart = Date.now(); try { - await post('api/turn', { text }); + await post('api/turn', { text: body }); + $('status').textContent = '已开始…'; } catch (e) { - bubble('Error', 'assistant').body.textContent = String(e.message || e); + bubble('error').body.textContent = String(e.message || e); } } - -$('btn-send').onclick = send; -$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {}); -$('btn-sessions').onclick = () => showSessions().catch((e) => alert(e.message)); -$('btn-model').onclick = showModel; -$('btn-undo').onclick = () => post('api/undo', {}).catch((e) => alert(e.message)); -$('btn-compact').onclick = () => post('api/compact', {}).catch((e) => alert(e.message)); -$('btn-clear').onclick = () => { log.textContent = ''; }; -$('input').addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { - e.preventDefault(); - send(); - } -}); -$('input').addEventListener('input', (e) => { - e.target.style.height = 'auto'; - e.target.style.height = Math.min(e.target.scrollHeight, 240) + 'px'; -}); - +function autoGrow() { + const t = $('input'); + t.style.height = 'auto'; + t.style.height = Math.min(t.scrollHeight, 240) + 'px'; +} async function upload(file) { - const r = await api('api/attach?name=' + encodeURIComponent(file.name), { + const r = await api('api/attach?name=' + encodeURIComponent(file.name || 'pasted.png'), { method: 'POST', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, }); - if (!r.ok) alert(await r.text()); + if (!r.ok) $('status').textContent = '上传失败:' + (await r.text()); } + +/* --- layout -------------------------------------------------------------- */ + +function toggleSide() { + if (window.innerWidth <= 900) { + const open = document.body.classList.toggle('side-open'); + document.body.classList.toggle('drawer-open', open); + document.body.classList.remove('panel-open'); + return; + } + localStorage.setItem('cst.side', document.body.classList.toggle('no-side') ? '0' : '1'); +} +function togglePanel() { + if (window.innerWidth <= 900) { + const open = document.body.classList.toggle('panel-open'); + document.body.classList.toggle('drawer-open', open); + document.body.classList.remove('side-open'); + return; + } + localStorage.setItem('cst.panel', document.body.classList.toggle('no-panel') ? '0' : '1'); +} +function closeDrawers() { + document.body.classList.remove('side-open', 'panel-open', 'drawer-open'); +} +function applyTheme(t) { + document.documentElement.dataset.theme = t; + localStorage.setItem('cst.theme', t); +} +function toggleTheme() { + applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); +} + +/* --- wiring -------------------------------------------------------------- */ + +$('btn-send').onclick = () => send(); +$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {}); +$('btn-new').onclick = async () => { + try { await post('api/sessions/new', {}); } catch (e) { $('status').textContent = String(e.message || e); } + await loadSession(); + await loadSessions(); + closeDrawers(); +}; +$('btn-refresh-sessions').onclick = () => loadSessions(); +$('chip-model').onclick = () => modelPopover($('chip-model')); +$('chip-approval').onclick = () => approvalPopover($('chip-approval')); +$('btn-plus').onclick = () => plusPopover($('btn-plus')); +$('cost').onclick = () => usagePopover($('cost')); +$('btn-menu').onclick = () => menuPopover($('btn-menu')); +$('usage').onclick = () => usagePopover($('cost')); +$('btn-settings').onclick = showSettings; +$('btn-collapse').onclick = toggleSide; +$('btn-side').onclick = toggleSide; +$('btn-drawer').onclick = toggleSide; +$('btn-panel').onclick = togglePanel; +$('btn-panel-close').onclick = togglePanel; +$('btn-theme').onclick = toggleTheme; +$('btn-undo').onclick = undo; +$('to-bottom').onclick = () => scrollDown(true); +for (const b of $('seg-mode').children) { + b.onclick = async () => { + await post('api/config', { plan_mode: b.dataset.mode === 'plan' }); + $('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写'; + }; +} +$('input').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } +}); +$('input').addEventListener('input', () => { autoGrow(); syncSend(); }); +$('scroll').addEventListener('scroll', updateJump); +$('scroll').addEventListener('wheel', closePop, { passive: true }); $('file').addEventListener('change', async (e) => { for (const f of e.target.files) await upload(f); e.target.value = ''; }); document.addEventListener('paste', async (e) => { - for (const item of e.clipboardData.items) { + const items = (e.clipboardData || {}).items || []; + for (const item of items) { if (item.kind === 'file' && item.type.startsWith('image/')) { e.preventDefault(); await upload(item.getAsFile()); @@ -425,9 +1043,21 @@ document.addEventListener('drop', async (e) => { e.preventDefault(); for (const f of e.dataTransfer.files) await upload(f); }); +$('backdrop').onclick = closeDrawers; /* --- go ------------------------------------------------------------------ */ +applyTheme(localStorage.getItem('cst.theme') || 'dark'); +/* the rail/panel/theme classes are already on from the inline script in + * index.html, so nothing here has to re-derive them */ +autoGrow(); +syncSend(); +renderFilesPanel(); connect(); -loadSession().catch(() => {}); +loadSession().catch((e) => { $('status').textContent = String(e.message || e); }); +loadSessions(); getJSON('api/state').then(applyState).catch(() => {}); +/* deep link: ?open=chip-approval / ?open=cost opens that popover once the + * state is in, so a screenshot or a bookmark can land on it directly */ +const wantsOpen = new URLSearchParams(location.search).get('open'); +if (wantsOpen) setTimeout(() => { const a = $(wantsOpen); if (a) a.click(); }, 900); diff --git a/web/index.html b/web/index.html index 24b74ca..a376bce 100644 --- a/web/index.html +++ b/web/index.html @@ -1,41 +1,230 @@ - + - + + CST-Agent -
- Build - auto - - - - - - - - - - -
+ + -
- +
-
- -
- - - - -
-
就绪
-
+ + + + +
+
+ + + + +
+ + +
+ + + + +
+ +
+
+

准备好开始构建了吗

+
+
+
+ + + +
+ + + + + +
+ +
+ + + + + + +
+
+
+ 就绪 + + +
+ +
+
+ + + + +
+ +
+ + diff --git a/web/style.css b/web/style.css index 40c79f0..7c5be9a 100644 --- a/web/style.css +++ b/web/style.css @@ -1,103 +1,647 @@ +/* CST-Agent WebUI - one stylesheet, no build step. + * + * Design rules + * - one fact, one home: sessions in the rail, model/approval/plan in the + * top bar, the run in the right panel, attachments in the composer; + * - settings open as popovers anchored to the control that owns them, + * with icon + label + one line of explanation + a check on the current + * value (the pattern all three reference apps use); + * - motion is short and eased: 120ms for feedback, 180ms for entrances, + * nothing bounces, and prefers-reduced-motion turns it all off. */ + +/* Tokens are named the way the reference desktop app names them: four text + * levels, three surfaces per zone (main / sidebar / composer), and borders + * mixed out of the foreground rather than hard-coded greys - which is what + * keeps the dark and the light sheet on the same visual rhythm. */ :root { - --bg: #14161a; - --bg2: #1b1e24; - --fg: #d8dde5; - --muted: #8b93a1; - --line: #2a2f38; - --accent: #7fb2ff; - --ok: #7ddc8f; - --warn: #ffcc66; - --err: #ff7b72; - --tool: #b5e3c8; + color-scheme: dark; + + --text-primary: #f2f2f5; + --text-secondary: #a9a9b4; + --text-tertiary: #7d7d89; + --text-quaternary: #5e5e69; + --text-error: #f0616d; + --text-success: #3ecf95; + + --main-surface-primary: #0d0d0f; + --main-surface-secondary: #141417; + --main-surface-tertiary: #1d1d22; + + --sidebar-surface-primary: #111114; + --sidebar-surface-secondary: #18181c; + --sidebar-surface-tertiary: #202026; + + --composer-surface-primary: #16161a; + --composer-surface-secondary: #202026; + + --bg-code: #08080a; + + /* every hover/active tint is alpha, so it lands correctly on any surface */ + --hover: color-mix(in srgb, var(--text-primary) 7%, transparent); + --hover-strong: color-mix(in srgb, var(--text-primary) 13%, transparent); + + --accent-primary: #3ecf95; + --accent-fg: #04241a; + --accent-weak: color-mix(in srgb, var(--accent-primary) 14%, transparent); + --accent-line: color-mix(in srgb, var(--accent-primary) 55%, transparent); + --link: var(--accent-primary); + --violet: #8b7bff; + --warn: #e0b341; + --selection-bg: color-mix(in srgb, var(--accent-primary) 30%, transparent); + --selection-fg: var(--text-primary); + + --border-light: color-mix(in srgb, var(--text-primary) 14%, transparent); + --border-medium: color-mix(in srgb, var(--text-primary) 24%, transparent); + --border-heavy: color-mix(in srgb, var(--text-primary) 34%, transparent); + + /* short aliases: the rest of the sheet keeps reading the same way */ + --bg: var(--main-surface-primary); + --bg-soft: var(--main-surface-secondary); + --bg-lift: var(--main-surface-tertiary); + --bg-hover: var(--hover); + --fg: var(--text-primary); + --fg-dim: var(--text-secondary); + --fg-faint: var(--text-tertiary); + --fg-quat: var(--text-quaternary); + --line: var(--border-light); + --line-strong: var(--border-medium); + --accent: var(--accent-primary); + --danger: var(--text-error); + + --rail: 252px; + --panel: 296px; + --radius: 12px; + --radius-lg: 22px; + --shadow: 0 16px 40px rgba(0, 0, 0, .45); + --shadow-sm: 0 6px 18px rgba(0, 0, 0, .3); + --mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans CJK SC", sans-serif; + --ease: cubic-bezier(.22, .61, .36, 1); + --ease-pop: cubic-bezier(.2, .9, .3, 1.06); /* enters with a hint of overshoot */ + --t-fast: 120ms; + --t: 180ms; } +[data-theme="light"] { + color-scheme: light; + + --text-primary: #14141a; + --text-secondary: #55555f; + --text-tertiary: #7c7c86; + --text-quaternary: #9c9ca6; + --text-error: #c8303f; + --text-success: #0f8f66; + + --main-surface-primary: #ffffff; + --main-surface-secondary: #f7f7f9; + --main-surface-tertiary: #ffffff; + + --sidebar-surface-primary: #fafafb; + --sidebar-surface-secondary: #f1f1f4; + --sidebar-surface-tertiary: #ffffff; + + --composer-surface-primary: #ffffff; + --composer-surface-secondary: #f4f4f6; + + --bg-code: #f5f5f7; + + --accent-primary: #0f9d70; + --accent-fg: #ffffff; + + --border-light: color-mix(in srgb, var(--text-primary) 12%, transparent); + --border-medium: color-mix(in srgb, var(--text-primary) 20%, transparent); + --border-heavy: color-mix(in srgb, var(--text-primary) 30%, transparent); + + --warn: #a9761a; + --shadow: 0 16px 40px rgba(20, 20, 35, .14); + --shadow-sm: 0 6px 18px rgba(20, 20, 35, .1); +} + * { box-sizing: border-box; } -html, body { margin: 0; height: 100%; } +html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); - font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, "Noto Sans Mono CJK SC", monospace; - display: flex; flex-direction: column; height: 100%; + font: 15px/1.62 var(--sans); + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + overflow: hidden; /* columns scroll, never the page */ } -button { - background: var(--bg2); color: var(--fg); border: 1px solid var(--line); - border-radius: 6px; padding: 3px 9px; font: inherit; cursor: pointer; -} -button:hover { border-color: var(--accent); } -.grow { flex: 1; } -.muted { color: var(--muted); } +button, textarea, input { font: inherit; color: inherit; } +button { cursor: pointer; background: none; border: 0; } +button:disabled { cursor: default; } +.grow { flex: 1 1 auto; } +.quiet, .muted { color: var(--fg-dim); } +.tiny { font-size: 12px; } +[hidden] { display: none !important; } +::selection { background: var(--selection-bg); color: var(--selection-fg); } -#bar { - display: flex; align-items: center; gap: 8px; padding: 6px 10px; - border-bottom: 1px solid var(--line); background: var(--bg2); - position: sticky; top: 0; z-index: 3; flex-wrap: wrap; +/* one icon system: everything is drawn on the same 24px grid, stroked with + * the current text colour, so header, rail, popovers and composer match */ +svg.i { + width: 18px; height: 18px; flex: 0 0 auto; display: block; + fill: none; stroke: currentColor; stroke-width: 1.7; + stroke-linecap: round; stroke-linejoin: round; } -.tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; color: var(--muted); } -.tag.on { color: var(--bg); background: var(--accent); border-color: var(--accent); } -.ws { color: var(--muted); overflow: hidden; text-overflow: ellipsis; max-width: 40vw; white-space: nowrap; } -.model { color: var(--accent); } +svg.i .f { fill: currentColor; stroke: none; } +.icon svg.i { width: 18px; height: 18px; } +.icon.tiny svg.i { width: 15px; height: 15px; } +.chip svg.i { width: 15px; height: 15px; } +.chip .chev { width: 13px; height: 13px; opacity: .6; } +.new-task svg.i { width: 17px; height: 17px; } +.send svg.i { width: 19px; height: 19px; stroke-width: 2; } +svg.i.only-light { display: none; } +[data-theme="light"] svg.i.only-dark { display: none; } +[data-theme="light"] svg.i.only-light { display: block; } -#log { flex: 1; overflow: auto; padding: 12px 14px 20px; } -#log .msg { margin: 0 0 14px; } -#log .who { color: var(--muted); font-size: 12px; margin-bottom: 2px; } -#log .body { white-space: pre-wrap; word-break: break-word; } -#log .assistant .body > :first-child { margin-top: 0; } -#log .body pre { - background: var(--bg2); border: 1px solid var(--line); border-radius: 6px; - padding: 8px 10px; overflow: auto; +/* the changed-file rows in the run panel: path, then +added / -removed */ +.fstat { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; animation: fade-in var(--t) var(--ease) both; } +.fstat .fimg { width: 15px; height: 15px; color: var(--text-quaternary); } +.fstat .p { + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; font-family: var(--mono); font-size: 12px; } -#log .body code { background: var(--bg2); border-radius: 4px; padding: 0 3px; } -#log .body pre code { background: none; padding: 0; } -#log .body table { border-collapse: collapse; } -#log .body th, #log .body td { border: 1px solid var(--line); padding: 2px 6px; } -#log .body blockquote { border-left: 3px solid var(--line); margin: 0; padding-left: 10px; color: var(--muted); } -#log .body a { color: var(--accent); } -#log .body img { max-width: min(420px, 100%); border-radius: 6px; border: 1px solid var(--line); } +.fstat .a, .fstat .d { font-variant-numeric: tabular-nums; font-size: 12px; } +.fstat .a { color: var(--text-success); } +.fstat .d { color: var(--text-error); } -details.think { color: var(--muted); margin: 4px 0; } -details.think summary { cursor: pointer; } -details.tool { - border: 1px solid var(--line); border-radius: 6px; margin: 6px 0; background: var(--bg2); +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { + background: var(--line-strong); border-radius: 99px; + border: 3px solid transparent; background-clip: content-box; } -details.tool > summary { cursor: pointer; padding: 4px 8px; color: var(--tool); } -details.tool .args, details.tool pre { margin: 0; padding: 6px 10px; white-space: pre-wrap; } -details.tool pre { border-top: 1px solid var(--line); max-height: 40vh; overflow: auto; } -.tool .exit-bad { color: var(--err); } -.spin::after { content: " …"; color: var(--warn); } +::-webkit-scrollbar-thumb:hover { background: var(--fg-faint); background-clip: content-box; } -#todo { - border-top: 1px solid var(--line); background: var(--bg2); padding: 6px 12px; - max-height: 22vh; overflow: auto; white-space: pre-wrap; font-size: 13px; +/* --- frame ---------------------------------------------------------------- */ +#app { + display: grid; + grid-template-columns: var(--rail) minmax(0, 1fr) var(--panel); + grid-template-rows: minmax(0, 1fr); + height: 100dvh; + /* the rail keeps its track and collapses to 0px, so the two states have the + * same track count and the column width can actually be interpolated */ + transition: grid-template-columns 300ms var(--ease); } -#todo h4 { margin: 0 0 4px; color: var(--muted); font-size: 12px; font-weight: normal; } +/* a zeroed track plus display:none used to slide #main into column one and + * crush it to nothing; the rail now stays in flow and is clipped instead */ +body.no-side #app { grid-template-columns: 0px minmax(0, 1fr) var(--panel); } +body.no-panel #app { grid-template-columns: var(--rail) minmax(0, 1fr) 0px; } +body.no-side.no-panel #app { grid-template-columns: 0px minmax(0, 1fr) 0px; } +/* the expand control only exists while the rail is folded away, so exactly one + * toggle is ever on screen */ +#btn-side { display: none; } +body.no-side #btn-side { display: inline-flex; } -#composer { border-top: 1px solid var(--line); background: var(--bg2); padding: 8px 10px; } -#composer .row { display: flex; gap: 8px; align-items: flex-end; } -#input { - flex: 1; resize: vertical; min-height: 34px; max-height: 40vh; - background: var(--bg); color: var(--fg); border: 1px solid var(--line); - border-radius: 6px; padding: 7px 9px; font: inherit; +.icon { + width: 32px; height: 32px; border-radius: 9px; flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + color: var(--fg-dim); font-size: 15px; + transition: background var(--t-fast), color var(--t-fast), transform 90ms var(--ease); } -.att { - border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px; cursor: pointer; +.icon:hover { background: var(--bg-hover); color: var(--fg); } +.icon:active { transform: scale(.94); } +.icon.tiny { width: 26px; height: 26px; font-size: 13px; } +.round { border-radius: 50%; } +.chip { + display: inline-flex; align-items: center; gap: 5px; height: 30px; + padding: 0 11px; border-radius: 99px; border: 1px solid transparent; + background: none; color: var(--fg-dim); font-size: 13px; + max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast), transform 90ms var(--ease); +} +.chip:hover { background: var(--bg-hover); color: var(--fg); } +.chip:active { transform: scale(.97); } +.chip.model { color: var(--fg); font-weight: 600; background: var(--bg-lift); border-color: var(--line); } +.chip.model:hover { border-color: var(--line-strong); } +.chip.warn { color: var(--warn); } +.chip .chev { font-size: 11px; opacity: .65; } +.badge { + min-width: 18px; height: 18px; padding: 0 5px; border-radius: 99px; + background: var(--accent-weak); color: var(--accent); + font-size: 11px; font-weight: 700; + display: inline-flex; align-items: center; justify-content: center; } -.hint { display: flex; gap: 10px; font-size: 12px; color: var(--muted); padding-top: 4px; } -#pending { color: var(--warn); font-size: 12px; padding-bottom: 4px; } +/* --- rail ----------------------------------------------------------------- */ +#side { + background: var(--sidebar-surface-primary); border-right: 1px solid var(--border-light); + min-width: 0; overflow: hidden; /* clips the rail while its track collapses */ +} +/* the contents keep their own width and slide out, so nothing reflows mid-flight + * and the exit reads as one movement instead of a squeeze */ +.side-inner { + width: var(--rail); height: 100%; min-width: 0; + display: flex; flex-direction: column; padding: 12px 10px 10px; + transition: transform 300ms var(--ease-pop), opacity 200ms var(--ease); +} +body.no-side .side-inner { transform: translateX(-18px); opacity: 0; } +body.no-side #side { pointer-events: none; } +.side-head { display: flex; align-items: center; gap: 2px; margin-bottom: 14px; } +/* the wordmark is a label, not a control: no logo tile, nothing to click */ +.brand { flex: 1 1 auto; display: flex; align-items: center; min-width: 0; padding: 6px 8px; } +.brand-name { + font-weight: 700; letter-spacing: .2px; font-size: 15px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.new-task { + display: flex; align-items: center; gap: 9px; height: 40px; padding: 0 12px; + margin-bottom: 18px; border-radius: 12px; font-weight: 600; + background: var(--bg-lift); border: 1px solid var(--line); + transition: border-color var(--t-fast), background var(--t-fast), transform 90ms var(--ease); +} +.new-task:hover { border-color: var(--line-strong); background: var(--bg-hover); } +.new-task:active { transform: scale(.985); } +.new-task .plus { color: var(--accent); font-size: 16px; line-height: 1; } + +.side-sec { + display: flex; align-items: center; gap: 6px; padding: 0 8px 6px; + color: var(--fg-faint); font-size: 11px; font-weight: 600; + letter-spacing: .7px; text-transform: uppercase; +} +.sessions { flex: 1 1 auto; overflow-y: auto; min-height: 40px; scrollbar-gutter: stable; } +.sess-item { + display: flex; align-items: baseline; gap: 8px; width: 100%; + padding: 7px 9px; border-radius: 9px; text-align: left; + color: var(--text-secondary); font-size: 13.5px; + animation: fade-in var(--t) var(--ease) both; + transition: background var(--t-fast), color var(--t-fast); +} +.sess-item:hover { background: var(--hover); color: var(--text-primary); } +.sess-item.on { background: var(--sidebar-surface-tertiary); color: var(--text-primary); font-weight: 600; } +.sess-item.on::before { + content: ""; width: 5px; height: 5px; border-radius: 50%; + background: var(--accent); flex: 0 0 auto; align-self: center; +} +.sess-item .t { flex: 1 1 auto; min-width: 0; overflow: hidden; } +.sess-item .ts { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* a title that does not fit fades at the edge and scrolls itself on hover, the + * way the reference sidebar handles long names (its data-marquee-text pattern) */ +.sess-item .t.mq { mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); } +.sess-item:hover .t.mq { mask-image: none; } +.sess-item:hover .t.mq .ts { + width: max-content; text-overflow: clip; + animation: marquee 2.6s var(--ease) 160ms infinite alternate; +} +@keyframes marquee { from { transform: none; } to { transform: translateX(calc(-1 * var(--mq, 0px))); } } +.sess-item .n { font-size: 11px; color: var(--text-quaternary); font-weight: 400; white-space: nowrap; } +.side-foot { + display: flex; align-items: center; gap: 6px; padding-top: 10px; + margin-top: 6px; border-top: 1px solid var(--line); +} +.who { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1 1 auto; } +.avatar { + width: 28px; height: 28px; border-radius: 50%; flex: 0 0 auto; + background: linear-gradient(135deg, var(--violet), var(--accent)); + color: #fff; font-weight: 800; font-size: 13px; + display: flex; align-items: center; justify-content: center; +} +.who-text { min-width: 0; display: flex; flex-direction: column; line-height: 1.3; } +.who-text b { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.who-text i { font-size: 11px; color: var(--fg-faint); font-style: normal; } + +/* --- top bar -------------------------------------------------------------- */ +#main { display: flex; flex-direction: column; min-width: 0; position: relative; } +#topbar { display: flex; align-items: center; gap: 4px; padding: 9px 12px; border-bottom: 1px solid var(--line); } +.seg { + display: flex; background: var(--bg-soft); border: 1px solid var(--line); + border-radius: 99px; padding: 2px; position: relative; +} +.seg button { + height: 25px; padding: 0 12px; border-radius: 99px; color: var(--fg-dim); font-size: 12.5px; + transition: color var(--t-fast), background var(--t); +} +.seg button.on { background: var(--bg-lift); color: var(--fg); font-weight: 600; } +.only-narrow { display: none; } + +#scroll { + flex: 1 1 auto; overflow-y: auto; padding: 22px 0 10px; + scroll-behavior: smooth; scrollbar-gutter: stable; +} +#log { max-width: 760px; margin: 0 auto; padding: 0 20px; } +#log:empty { display: none; } + +/* --- empty state ---------------------------------------------------------- */ +/* the first screen is one line: no logo, no suggestion wall. It goes away the + * moment the first message lands (see showEmpty). */ +#empty { margin: 22vh auto 0; padding: 0 20px; text-align: center; } +#empty.gone { display: none; } +#empty .ready { + margin: 0; color: var(--text-tertiary); font-size: 15px; letter-spacing: .3px; + animation: fade-in 320ms var(--ease) both; +} + +/* --- transcript ----------------------------------------------------------- */ +.msg { display: flex; gap: 12px; padding: 13px 0 0; animation: fade-up var(--t) var(--ease) both; } +.msg .gutter { flex: 0 0 24px; padding-top: 2px; color: var(--fg-faint); font-size: 11px; text-align: center; user-select: none; } +.msg .body { min-width: 0; flex: 1 1 auto; } +.msg.user { justify-content: flex-end; } +.msg.user .gutter { display: none; } +.msg.user .body { + background: var(--bg-lift); border-radius: 18px 18px 6px 18px; + padding: 9px 15px; max-width: 76%; white-space: pre-wrap; word-break: break-word; +} +.msg.error .body { color: var(--danger); } +.body > :first-child { margin-top: 0; } +.body > :last-child { margin-bottom: 0; } +.body h1, .body h2, .body h3, .body h4 { line-height: 1.35; margin: 1.1em 0 .5em; } +.body h1 { font-size: 1.45em; } .body h2 { font-size: 1.25em; } .body h3 { font-size: 1.1em; } +.body p, .body ul, .body ol { margin: .55em 0; } +.body ul, .body ol { padding-left: 1.4em; } +.body li { margin: .22em 0; } +.body a { color: var(--link); } +.body code { + font-family: var(--mono); font-size: .88em; background: var(--bg-code); + border: 1px solid var(--line); border-radius: 6px; padding: .1em .35em; +} +.body pre { + background: var(--bg-code); border: 1px solid var(--line); + border-radius: 12px; padding: 12px 14px; overflow-x: auto; margin: .75em 0; +} +.body pre code { background: none; border: 0; padding: 0; font-size: 13px; } +.body blockquote { margin: .75em 0; padding: .1em 0 .1em 12px; border-left: 3px solid var(--line-strong); color: var(--fg-dim); } +.body table { border-collapse: collapse; margin: .75em 0; font-size: 13.5px; display: block; overflow-x: auto; } +.body th, .body td { border: 1px solid var(--line); padding: 5px 9px; text-align: left; } +.body th { background: var(--bg-soft); } +.body img { max-width: 100%; border-radius: 10px; } +.body hr { border: 0; border-top: 1px solid var(--line); margin: 1.2em 0; } +/* a caret while tokens are still arriving */ +.body.live > :last-child::after { + content: ""; display: inline-block; width: 7px; height: 14px; + margin-left: 3px; vertical-align: -2px; border-radius: 2px; + background: var(--accent); animation: blink 1.1s steps(2, start) infinite; +} + +.attach-strip { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; justify-content: flex-end; } +.attach-strip img { + width: 108px; height: 108px; object-fit: cover; border-radius: 12px; + border: 1px solid var(--line); animation: rise var(--t) var(--ease) both; +} + +.turn-meta { display: flex; align-items: center; gap: 10px; padding: 10px 0 2px 36px; } +.turn-meta details > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12px; transition: color var(--t-fast); } +.turn-meta details > summary::-webkit-details-marker { display: none; } +.turn-meta details > summary:hover { color: var(--fg-dim); } +.msg-actions { display: flex; gap: 2px; padding: 4px 0 0 36px; opacity: 0; transition: opacity var(--t) var(--ease); } +.msg:hover .msg-actions, .msg-actions:focus-within { opacity: 1; } +.msg-actions button { + height: 26px; padding: 0 9px; border-radius: 8px; color: var(--fg-faint); font-size: 12px; + transition: background var(--t-fast), color var(--t-fast); +} +.msg-actions button:hover { background: var(--bg-hover); color: var(--fg); } + +.think { margin: .1em 0 .5em; } +.think summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); } +.think summary::-webkit-details-marker { display: none; } +.think summary::before { content: "▸ "; } +.think[open] summary::before { content: "▾ "; } +.think summary:hover { color: var(--fg-dim); } +.think > div { + color: var(--fg-dim); font-size: 13.5px; white-space: pre-wrap; + padding: 6px 0 2px 14px; border-left: 2px solid var(--line); + animation: fade-in var(--t) var(--ease) both; +} + +/* tool calls: one line, opens on demand (like "已执行 python · <") */ +.turn-steps { padding: 6px 0 0 36px; } +.turn-steps > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); } +.turn-steps > summary::-webkit-details-marker { display: none; } +.turn-steps > summary::before { content: "⌘ "; } +.turn-steps > summary:hover { color: var(--fg-dim); } +.steps-box { padding: 2px 0 0 12px; margin-top: 4px; border-left: 2px solid var(--line); } +.step-line { animation: fade-up var(--t) var(--ease) both; } +.step-line > summary { + display: flex; align-items: baseline; gap: 8px; cursor: pointer; list-style: none; + padding: 5px 0; font-size: 13px; color: var(--fg-dim); + transition: color var(--t-fast); +} +.step-line > summary::-webkit-details-marker { display: none; } +.step-line > summary:hover { color: var(--fg); } +.step-line .name { font-family: var(--mono); font-size: 12.5px; color: var(--fg); } +.step-line .args { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12px; } +.step-line .phase { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; } +.step-line.run .phase { color: var(--warn); } +.step-line.run .phase::before { + content: ""; width: 6px; height: 6px; border-radius: 50%; + background: var(--warn); animation: pulse 1.1s var(--ease) infinite; +} +.step-line.bad .phase, .step-line.bad .name { color: var(--danger); } +.step-line.done .phase::before { content: "✓"; } +.step-body { padding: 0 0 6px; animation: fade-in var(--t) var(--ease) both; } +.step-out, .step-args-full { + font-family: var(--mono); font-size: 12px; background: var(--bg-code); + border: 1px solid var(--line); border-radius: 10px; padding: 9px 11px; + margin: 4px 0 0; max-height: 300px; overflow: auto; white-space: pre-wrap; word-break: break-word; +} + +#to-bottom { + position: absolute; right: 26px; bottom: 172px; z-index: 5; + width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; + background: var(--bg-lift); border: 1px solid var(--line-strong); color: var(--fg-dim); + box-shadow: var(--shadow-sm); font-size: 16px; + animation: fade-in var(--t) var(--ease) both; + transition: color var(--t-fast), transform 90ms var(--ease), background var(--t-fast); +} +#to-bottom:hover { color: var(--fg); background: var(--bg-hover); } +#to-bottom:active { transform: scale(.94); } + +/* --- composer ------------------------------------------------------------- */ +#composer { padding: 8px 20px 12px; } +#composer > * { max-width: 760px; margin: 0 auto; } +.pending { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 0 8px; } +.pending .chip { background: var(--bg-soft); border-color: var(--line); } + +.box { + border: 1px solid var(--border-medium); border-radius: var(--radius-lg); + background: var(--composer-surface-primary); padding: 11px 13px 9px; + transition: border-color var(--t), box-shadow var(--t), background var(--t); +} +.box:hover { border-color: var(--border-heavy); } +.box:focus-within { + border-color: var(--accent-line); + box-shadow: 0 0 0 3px var(--accent-weak); +} +.box textarea { + width: 100%; border: 0; background: none; resize: none; outline: none; + max-height: 240px; min-height: 26px; padding: 2px 2px 8px; + caret-color: var(--accent); +} +.box textarea::placeholder { color: var(--fg-faint); } +.box-row { display: flex; align-items: center; gap: 6px; } +.att { border: 1px solid var(--line); font-size: 17px; line-height: 1; } +.cost { color: var(--fg-faint); font-size: 12px; padding: 0 4px; white-space: nowrap; } +.cost:hover { color: var(--fg-dim); } +.send { + width: 36px; height: 36px; flex: 0 0 auto; font-size: 17px; font-weight: 800; + background: var(--accent); color: var(--accent-fg); + transition: filter var(--t-fast), transform 90ms var(--ease), opacity var(--t-fast); +} +.send:hover { filter: brightness(1.08); } +.send:active { transform: scale(.94); } +.send:disabled { opacity: .38; } +.icon.stop { border: 1px solid var(--line); font-size: 12px; } +.hint-line { display: flex; align-items: center; gap: 10px; padding: 7px 6px 0; font-size: 12px; color: var(--fg-faint); } + +/* permission gate: inline, the turn really is waiting on it */ +.gate { + border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent); + border-radius: var(--radius); padding: 11px 13px; margin-bottom: 10px; + background: color-mix(in srgb, var(--warn) 7%, var(--bg-soft)); + animation: rise var(--t) var(--ease) both; +} +.gate-head { display: flex; align-items: center; gap: 8px; font-weight: 650; font-size: 14px; } +.gate-timer { color: var(--warn); font-size: 12.5px; font-variant-numeric: tabular-nums; } +.gate pre { + margin: 8px 0 0; background: var(--bg-code); border: 1px solid var(--line); + border-radius: 10px; padding: 9px 11px; max-height: 180px; overflow: auto; + font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; +} +.gate-keys { padding: 8px 0 2px; color: var(--fg-dim); font-size: 12.5px; } +.gate-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; } +.gate-row button { + height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600; + border: 1px solid var(--line-strong); background: var(--bg-lift); + transition: filter var(--t-fast), transform 90ms var(--ease), background var(--t-fast); +} +.gate-row button.ok { background: var(--accent); color: var(--accent-fg); border-color: transparent; } +.gate-row button.danger { color: var(--danger); } +.gate-row button:hover { filter: brightness(1.12); } +.gate-row button:active { transform: scale(.97); } + +/* --- popovers: anchored, animated, and the only place settings live ------- */ +.pop { + position: fixed; z-index: 45; min-width: 236px; max-width: 320px; + background: var(--main-surface-tertiary); border: 1px solid var(--border-medium); + border-radius: 14px; padding: 6px; box-shadow: var(--shadow); + animation: pop-in 160ms var(--ease) both; +} +.pop-item { + display: flex; align-items: flex-start; gap: 10px; width: 100%; + padding: 9px 10px; border-radius: 10px; text-align: left; + transition: background var(--t-fast); +} +.pop-item:hover { background: var(--bg-hover); } +.pop-item .pi { width: 18px; flex: 0 0 auto; text-align: center; color: var(--fg-dim); font-size: 14px; line-height: 1.4; } +/* title over description: two lines, never one run-on line */ +.pop-item .pm { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px; } +.pop-item .pt { font-size: 13.5px; font-weight: 600; line-height: 1.35; } +.pop-item .pd { font-size: 11.5px; color: var(--fg-faint); line-height: 1.4; } +.pop-item .pk { flex: 0 0 auto; color: var(--accent); font-size: 13px; } +.pop-item.on { background: var(--accent-weak); } +.pop-item.on .pi, .pop-item.on .pt { color: var(--accent); } +.pop-item.danger .pi, .pop-item.danger .pt { color: var(--danger); } +.pop-sep { height: 1px; background: var(--line); margin: 5px 4px; } +.pop-title { padding: 8px 10px 4px; color: var(--fg-faint); font-size: 11.5px; font-weight: 600; letter-spacing: .4px; } +.pop-input { display: flex; gap: 6px; padding: 4px 6px 6px; } +.pop-input input { + flex: 1 1 auto; min-width: 0; background: var(--bg-soft); border: 1px solid var(--line); + border-radius: 9px; padding: 7px 9px; outline: none; font-size: 13px; + caret-color: var(--accent); + transition: border-color var(--t-fast); +} +.pop-input input:focus { border-color: var(--accent-line); } +.pop-input button { + height: 32px; padding: 0 12px; border-radius: 9px; font-weight: 600; font-size: 13px; + background: var(--accent); color: var(--accent-fg); + transition: filter var(--t-fast), transform 90ms var(--ease); +} +.pop-input button:active { transform: scale(.96); } +.kv { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 10px; font-size: 13px; } +.kv span { color: var(--fg-dim); } +.kv b { font-variant-numeric: tabular-nums; font-weight: 600; } + +/* --- right panel ---------------------------------------------------------- */ +/* the right panel mirrors the rail: fixed-width contents, clipped by the track, + * so folding it in and out is one eased movement on each side */ +#panel { background: var(--main-surface-secondary); border-left: 1px solid var(--border-light); min-width: 0; overflow: hidden; } +.panel-inner { + width: var(--panel); height: 100%; overflow-y: auto; padding: 12px; + transition: transform 300ms var(--ease-pop), opacity 200ms var(--ease); +} +body.no-panel .panel-inner { transform: translateX(18px); opacity: 0; } +body.no-panel #panel { pointer-events: none; } +.panel-head { + display: flex; align-items: center; gap: 6px; padding: 2px 4px 10px; + color: var(--fg-faint); font-size: 11px; font-weight: 600; + letter-spacing: .7px; text-transform: uppercase; +} +.card { border-top: 1px solid var(--line); padding: 12px 2px; } +.card:first-of-type { border-top: 0; } +.card h4 { margin: 0 0 8px; font-size: 12.5px; font-weight: 650; display: flex; align-items: center; gap: 6px; } +.card .kv { padding: 3px 0; } +.todo-list { margin: 0; padding: 0; list-style: none; font-size: 13.5px; } +.todo-list li { display: flex; gap: 8px; padding: 3px 0; animation: fade-in var(--t) var(--ease) both; transition: color var(--t-fast); } +.todo-list .boxmark { color: var(--fg-faint); } +.todo-list li.done { color: var(--fg-faint); text-decoration: line-through; } +.todo-list li.doing { color: var(--fg); font-weight: 600; } +#steps-body .step-line > summary { font-size: 12.5px; } +#steps-body .step-line .phase { font-size: 11px; } + +/* --- modal sheet (session list) ------------------------------------------- */ #modal { - position: fixed; inset: 0; background: rgba(0, 0, 0, .55); - display: flex; align-items: center; justify-content: center; z-index: 9; + position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 40; + display: flex; align-items: center; justify-content: center; padding: 16px; + animation: fade-in var(--t) var(--ease) both; } #modal-box { - background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; - padding: 14px; width: min(820px, 94vw); max-height: 88vh; overflow: auto; + width: min(560px, 100%); max-height: 82vh; overflow-y: auto; + background: var(--main-surface-tertiary); border: 1px solid var(--border-medium); + border-radius: 18px; padding: 16px 18px; box-shadow: var(--shadow); + animation: pop-in 180ms var(--ease) both; } -#modal-box h3 { margin: 0 0 8px; } -#modal-box pre { - background: var(--bg); border: 1px solid var(--line); border-radius: 6px; - padding: 8px; max-height: 46vh; overflow: auto; white-space: pre-wrap; +#modal-box h3 { margin: 0 0 12px; font-size: 17px; } +#modal-box .field { display: flex; align-items: center; gap: 8px; margin: 9px 0; flex-wrap: wrap; } +#modal-box .field > span { width: 70px; color: var(--fg-dim); font-size: 13px; } +#modal-box .field input { + flex: 1 1 auto; min-width: 150px; background: var(--bg-lift); + border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; outline: none; +} +#modal-box .buttons { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; } +#modal-box .buttons button { + height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600; + border: 1px solid var(--line-strong); background: var(--bg-lift); + transition: filter var(--t-fast), transform 90ms var(--ease); +} +#modal-box .buttons button:active { transform: scale(.97); } +#modal-box .buttons button.ok { background: var(--accent); color: var(--accent-fg); border-color: transparent; } +#modal-box .buttons button.danger { color: var(--danger); } +#modal-box .sess { + display: flex; align-items: center; gap: 8px; padding: 8px 6px; + border-bottom: 1px solid var(--line); border-radius: 8px; + transition: background var(--t-fast); +} +#modal-box .sess:hover { background: var(--bg-hover); } +#modal-box .sess .id { font-family: var(--mono); font-size: 11.5px; color: var(--fg-faint); } +#modal-box .sess .t { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; } + +/* --- narrow screens: rail and panel become drawers ------------------------ */ +#backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 30; display: none; animation: fade-in var(--t) var(--ease) both; } +body.drawer-open #backdrop { display: block; } +@media (max-width: 900px) { + #app { grid-template-columns: minmax(0, 1fr) !important; transition: none; } + #side, #panel { + position: fixed; top: 0; bottom: 0; width: min(310px, 86vw); z-index: 31; + display: block; transition: transform 260ms var(--ease-pop); + } + .side-inner { width: auto; transform: none !important; opacity: 1 !important; } + #side { overflow: visible; pointer-events: auto; } + #side { left: 0; transform: translateX(-102%); } + #panel { right: 0; transform: translateX(102%); } + body.side-open #side, body.panel-open #panel { transform: none; } + .only-narrow { display: inline-flex; } + #to-bottom { bottom: 150px; right: 16px; } + .hero h1 { font-size: 21px; } + .msg.user .body { max-width: 88%; } + .turn-meta, .msg-actions, .turn-steps { padding-left: 0; } +} +@media (min-width: 901px) { #backdrop { display: none !important; } } + +/* --- motion --------------------------------------------------------------- */ +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.99); } to { opacity: 1; transform: none; } } +@keyframes pop-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } to { opacity: 1; transform: none; } } +@keyframes pulse { 0%, 100% { opacity: .3; transform: scale(.85); } 50% { opacity: 1; transform: scale(1); } } +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; } + #scroll { scroll-behavior: auto; } } -#modal-box .buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } -#modal-box .danger { border-color: var(--err); color: var(--err); } -#modal-box .ok { border-color: var(--ok); color: var(--ok); } -.sess { display: flex; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--line); } -.sess .id { color: var(--muted); font-size: 12px; } -.sess .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }