From 6e81513e3613f95820852e7633ebefa0d6645218 Mon Sep 17 00:00:00 2001 From: WHC Date: Fri, 9 Oct 2026 10:20:09 +0800 Subject: [PATCH] v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on a worker thread, the hooks publish events into a ring buffer that SSE clients read, so the engine itself needs no changes. - src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect - src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming, body caps (413), Expect: 100-continue, connection cap, exact routing - src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML (tag/attribute allowlist, javascript: URLs and off-site images dropped) - src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes, token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no client is attached - src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI - web/: no-build front end (transcript, approvals, todo, sessions, image upload, status bar) - docs/webui.md: engine API v1 - events, requests, security model - tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run driven by a recorded SSE stream (no API key needed) - src/main.c: --web --web-port --web-host --web-token --web-dev --web-open and CST_WEB_PORT/HOST/TOKEN/ROOT An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI. --- .gitea/workflows/ci.yml | 2 + .gitignore | 3 +- CMakeLists.txt | 71 ++- Makefile | 37 +- README.md | 17 + docs/webui.md | 176 ++++++ include/cst_http.h | 105 ++++ include/cst_net.h | 68 +++ include/cst_version.h | 2 +- include/cst_web_md.h | 48 ++ include/cst_webui.h | 40 ++ scripts/e2e_webui.sh | 263 ++++++++ scripts/embed_web.sh | 40 ++ src/http_srv.c | 684 +++++++++++++++++++++ src/main.c | 56 +- src/net.c | 319 ++++++++++ src/web_md.c | 319 ++++++++++ src/webui.c | 1257 +++++++++++++++++++++++++++++++++++++++ src/webui_stub.c | 15 + tests/test_webui.c | 280 +++++++++ web/app.js | 433 ++++++++++++++ web/index.html | 41 ++ web/style.css | 103 ++++ 23 files changed, 4361 insertions(+), 18 deletions(-) create mode 100644 docs/webui.md create mode 100644 include/cst_http.h create mode 100644 include/cst_net.h create mode 100644 include/cst_web_md.h create mode 100644 include/cst_webui.h create mode 100644 scripts/e2e_webui.sh create mode 100644 scripts/embed_web.sh create mode 100644 src/http_srv.c create mode 100644 src/net.c create mode 100644 src/web_md.c create mode 100644 src/webui.c create mode 100644 src/webui_stub.c create mode 100644 tests/test_webui.c create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cd40f1c..8a96635 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -54,6 +54,8 @@ jobs: ./cst-agent --help ./cst-agent --workspace "$PWD" --list-sessions ./cst-agent --demo-ctl --workspace . + - name: WebUI end-to-end (recorded stream, no API key) + run: sh scripts/e2e_webui.sh musl: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 3567efb..684e0d9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ vendor/sashidown/*.o vendor/sashidown/src/*.o vendor/sashidown/libsashidown.a vendor/sashidown/libsashidown-mingw.a -.gen/models_builtin.inc +.gen/ +.build-tmp/ diff --git a/CMakeLists.txt b/CMakeLists.txt index dca5f97..c58ee32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON) +option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" ON) option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF) option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON) @@ -114,16 +115,59 @@ set(CST_CORE_SOURCES src/models.c src/model_registry.c src/metrics.c + src/net.c + src/http_srv.c + src/web_md.c ) # Built-in model data as a C byte list (included by model_registry.c). set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json") + +# The WebUI shell (web/*) as C byte arrays, the same trick as the model data. +file(GLOB CST_WEB_ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/web/*") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CST_WEB_ASSETS}) +set(_cst_web_inc "/* Generated by CMake from web/ - do not edit. */\n") +set(_cst_web_table "") +foreach(_f ${CST_WEB_ASSETS}) + if(IS_DIRECTORY "${_f}") + continue() + endif() + get_filename_component(_name "${_f}" NAME) + string(REGEX REPLACE "[^A-Za-z0-9]" "_" _var "wui_asset_${_name}") + file(READ "${_f}" _hex HEX) + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _hex "${_hex}") + set(_ctype "application/octet-stream") + if(_name MATCHES "\\.html$") + set(_ctype "text/html; charset=utf-8") + elseif(_name MATCHES "\\.js$") + set(_ctype "text/javascript; charset=utf-8") + elseif(_name MATCHES "\\.css$") + set(_ctype "text/css; charset=utf-8") + elseif(_name MATCHES "\\.svg$") + set(_ctype "image/svg+xml") + endif() + string(APPEND _cst_web_inc "static const unsigned char ${_var}[] = {${_hex}0x00};\n") + string(APPEND _cst_web_table " {\"${_name}\", \"${_ctype}\", ${_var}, sizeof(${_var}) - 1},\n") +endforeach() +string(APPEND _cst_web_inc "static const wui_asset_t wui_assets[] = {\n${_cst_web_table}};\n") +string(APPEND _cst_web_inc "static const int wui_asset_count = (int)(sizeof(wui_assets) / sizeof(wui_assets[0]));\n") +file(WRITE "${CST_GEN_DIR}/web_assets.inc" "${_cst_web_inc}") +if(NOT CST_BUILD_WEBUI) + list(APPEND CST_CORE_SOURCES src/webui_stub.c) +else() + list(APPEND CST_CORE_SOURCES src/webui.c) +endif() + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX) string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}") file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n") include_directories("${CST_GEN_DIR}") + +if(NOT CST_BUILD_WEBUI) + add_compile_definitions(CST_NO_WEBUI=1) +endif() if(WIN32) # MinGW has no : musl's TRE engine, UTF-8 aware (vendor/tre-regex) include_directories(vendor/tre-regex) @@ -269,17 +313,26 @@ foreach(t ${_CST_SMALL_TESTS}) endforeach() # Tests that link the whole core (no TUI). -foreach(t test_llm_body test_prefix_stable test_sse_decode test_apply_patch - test_session_roundtrip test_attach - test_shell test_diff test_tools_fs test_registry - test_undo test_config test_models test_model_registry test_sandbox - test_keymap test_shell_compact test_skills - test_plugins test_mcp test_subagent test_lowil - test_win_utf8 test_os_dir test_tmpdir) +set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode + test_apply_patch test_session_roundtrip test_attach + test_shell test_diff test_tools_fs test_registry + test_undo test_config test_models test_model_registry test_sandbox + test_keymap test_shell_compact test_skills + test_plugins test_mcp test_subagent test_lowil + test_win_utf8 test_os_dir test_tmpdir) +if(CST_BUILD_WEBUI) + # test_webui drives the real server; the stub has nothing to test. + list(APPEND _CST_CORE_TESTS test_webui) +endif() +foreach(t ${_CST_CORE_TESTS}) add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES}) - target_include_directories(${t} PRIVATE include "${CJSON_DIR}") + # The core now contains src/web_md.c, which needs sashidown's header and + # archive exactly like the Makefile build does. + target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include") target_compile_definitions(${t} PRIVATE _GNU_SOURCE) - target_link_libraries(${t} PRIVATE CURL::libcurl Threads::Threads) + target_link_libraries(${t} PRIVATE + "${SASHIDOWN_A}" CURL::libcurl Threads::Threads) + add_dependencies(${t} sashidown) if(NOT WIN32) target_link_libraries(${t} PRIVATE m) else() diff --git a/Makefile b/Makefile index 035611b..22d9a08 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,10 @@ ifeq ($(strip $(CURL_LIBS)),) CURL_LIBS := -lcurl endif +# WebUI (HTTP + SSE front end): src/net.c src/http_srv.c src/web_md.c +# src/webui.c. CST_WEBUI=0 swaps in the stub (--web then prints a hint). +CST_WEBUI ?= 1 + PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS) PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS) @@ -50,6 +54,17 @@ CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_ LDFLAGS += -L$(SD_DIR) LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) +WEB_DIR := web +WEB_ASSETS := $(wildcard $(WEB_DIR)/*.html) $(wildcard $(WEB_DIR)/*.js) \ + $(wildcard $(WEB_DIR)/*.css) $(wildcard $(WEB_DIR)/*.svg) +ifeq ($(CST_WEBUI),0) +WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui_stub.c +# The stub body is guarded by this, so the switch has to define it too. +CPPFLAGS += -DCST_NO_WEBUI=1 +else +WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui.c +endif + CORE_SRC = \ $(CJSON_DIR)/cJSON.c \ src/util.c \ @@ -79,12 +94,13 @@ CORE_SRC = \ src/creds.c \ src/mm_tool.c \ src/models.c src/model_registry.c \ - src/metrics.c + src/metrics.c \ + $(WEB_SRC) SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c OBJ = $(SRC:.c=.o) HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \ - .gen/models_builtin.inc + .gen/models_builtin.inc .gen/web_assets.inc TESTS = \ tests/test_ctl_frontier \ @@ -120,8 +136,12 @@ TESTS = \ tests/test_win_utf8 \ tests/test_os_dir \ tests/test_tmpdir +# test_webui drives the real server; there is nothing to test with the stub. +ifneq ($(CST_WEBUI),0) +TESTS += tests/test_webui +endif -TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS) +TEST_LIBS = -L$(SD_DIR) -lsashidown -lm $(CURL_LIBS) $(PTHREAD_LIBS) .PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall @@ -153,6 +173,11 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a @mkdir -p .gen od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@ +# The WebUI shell is embedded the same way, one array per file. +.gen/web_assets.inc: $(WEB_ASSETS) scripts/embed_web.sh + @mkdir -p .gen + @sh scripts/embed_web.sh $@ $(WEB_DIR) + %.o: %.c $(HDRS) $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< @@ -186,10 +211,14 @@ tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS) +$(TESTS): sashidown-lib + check: $(TESTS) @set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done # AddressSanitizer + UBSan build of the agent and all tests. +# Under a ptrace-based sandbox (some Termux setups) LeakSanitizer aborts with +# "Tracer caught signal 11"; add ASAN_OPTIONS=detect_leaks=0 there. asan: $(MAKE) clean $(MAKE) sashidown-lib @@ -199,7 +228,7 @@ demo: cst-agent ./cst-agent --demo-ctl --workspace . clean: - rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc + rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc .gen/web_assets.inc install: cst-agent install -d "$(DESTDIR)$(PREFIX)/bin" diff --git a/README.md b/README.md index 180f9ad..542d2cc 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`( | Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox | | Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** | +### WebUI(`--web`) + +同一个二进制内置一个 HTTP + SSE 前端:`cst-agent --web`(默认 `http://127.0.0.1:8787/`)。 +它和 TUI 是同一引擎的两个前端,浏览器里能流式看回答、点审批、切会话、贴图。 +协议是一份稳定的 JSON + SSE 接口,供以后的专用客户端复用。详见 [docs/webui.md](docs/webui.md)。 + +```bash +cst-agent --web # 本机浏览器打开 http://127.0.0.1:8787/ +cst-agent --web --web-port 0 # 端口交给系统,启动日志里给 URL +cst-agent --web --web-dev ./web # 开发前端:直接读目录,改完刷新 +cst-agent --web --web-host 0.0.0.0 --web-token 口令 # 手机/局域网访问 +sh scripts/e2e_webui.sh # 用录制流跑完整轮次,不需要 API key +``` + +不带 TUI 的构建也可以开 WebUI(`cmake -DCST_BUILD_WEBUI=ON -DCST_BUILD_TUI=OFF`); +`-DCST_BUILD_WEBUI=OFF` 时 `--web` 只打印一条提示。 + ### Termux ```bash diff --git a/docs/webui.md b/docs/webui.md new file mode 100644 index 0000000..a9c2186 --- /dev/null +++ b/docs/webui.md @@ -0,0 +1,176 @@ +# WebUI(内置 HTTP + SSE 前端) + +`cst-agent --web` 让同一个进程同时提供命令行以外的前端:浏览器(或任何 HTTP 客户端) +连上后能看到流式回答、工具卡片、审批弹窗和任务清单。它是 TUI 之外的第二个前端, +**引擎没有为它改一行**——两边用的都是 `cst_agent_hooks_t` 这组回调。 + +这份文档同时是**引擎 API v1 的规范**:以后专门客户端(Tauri / Qt / 移动端)直接照它实现, +不需要读 C 代码。 + +## 快速开始 + +```bash +# 默认只监听 127.0.0.1:8787 +cst-agent --web + +# 指定端口;0 = 让系统分配(启动日志里会打印实际 URL) +cst-agent --web --web-port 8787 + +# 让手机/另一台机器访问(必须给 token,否则拒绝启动在非 loopback 上) +cst-agent --web --web-host 0.0.0.0 --web-token 我自己定的口令 + +# 开发前端:直接从目录读 web/,改完刷新页面即可,不用重新编译 +cst-agent --web --web-dev ./web + +# 启动后自动打开浏览器 +cst-agent --web --web-open + +# 启动即跑一轮(不点页面也能用) +cst-agent --web -p "看看这个仓库有什么问题" +``` + +启动日志: + +``` +webui: http://127.0.0.1:8787/ +webui: token 3f9a… (append #token=3f9a… to the URL) +``` + +带 token 时把 `#token=…` 贴到浏览器地址栏后面即可(`#` 之后不会发给服务器, +页面会用 `?token=` 再请求一次)。 + +环境变量:`CST_WEB_PORT`、`CST_WEB_HOST`、`CST_WEB_TOKEN`、`CST_WEB_ROOT`。 + +退出:终端里 Ctrl+C,或 `POST /api/quit`。 + +## 安全模型 + +| 场景 | 行为 | +|---|---| +| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding) | +| `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token | +| token 传递 | `Authorization: Bearer `、`Cookie: cst_token=` 或 `?token=`(`EventSource` 不能设 header,所以查询参数是必需的一条路) | +| 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) | +| 输出渲染 | 模型与工具输出都当不可信内容:Markdown 由服务端渲染,并经过标签/属性白名单(`javascript:` 链接、站外图片、`" + "\"a\"" + "l"; + html = cst_web_md_sanitize(evil, strlen(evil)); + CHECK(html != NULL); + CHECK(strstr(html, "ok")); + CHECK(strstr(html, "ok") != NULL); + cst_buf_free(&g_md_out); + } + { + /* and the same text fed one byte at a time still comes out whole */ + cst_buf_free(&g_md_out); + const char *md_src = "**bold** and [ok](https://example.com)"; + cst_web_md_t *md = cst_web_md_new(collect_md, NULL); + CHECK(md != NULL); + for (size_t i = 0; i < strlen(md_src); i++) + CHECK(cst_web_md_feed(md, md_src + i, 1) == 0); + CHECK(cst_web_md_finish(md) == 0); + cst_web_md_free(md); + CHECK(strstr(g_md_out.buf, "bold")); + CHECK(strstr(g_md_out.buf, "https://example.com")); + CHECK(strstr(g_md_out.buf, "noopener") != NULL); + cst_buf_free(&g_md_out); + } +} + +int main(void) { + CHECK(os_net_init() == 0); + test_query(); + test_sanitize(); + test_http(); + printf("test_webui: OK\n"); + return 0; +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..0c50f9c --- /dev/null +++ b/web/app.js @@ -0,0 +1,433 @@ +/* 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. */ +'use strict'; + +/* --- token: from ?token= / #token= once, then kept in a cookie by the + * server; the query parameter is appended to every request so EventSource + * (which cannot set headers) works as well. --- */ +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') || ''; +})(); + +function api(path, opt) { + if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN); + return fetch(path, opt); +} +async function getJSON(path) { + const r = await api(path); + if (!r.ok) throw new Error((await r.text()) || r.statusText); + return r.json(); +} +async function post(path, body) { + const r = await api(path, { + method: 'POST', + headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await r.text(); + if (!r.ok) throw new Error(text || r.statusText); + return text ? JSON.parse(text) : {}; +} + +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 e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; + return e; +} +function bubble(who, cls) { + const m = el('div', 'msg ' + (cls || '')); + m.appendChild(el('div', 'who', who)); + const b = el('div', 'body'); + m.appendChild(b); + log.appendChild(m); + scrollDown(true); + return { root: m, body: b }; +} + +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; + 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); + } + } +} + +async function loadSession() { + const data = await getJSON('api/session'); + log.textContent = ''; + firstPaint = true; + for (const m of data.messages) renderMessage(m); + firstPaint = false; + scrollDown(true); +} + +/* --- live blocks --------------------------------------------------------- */ + +function ensureLive() { + if (live) return live; + const b = bubble('Assistant', 'assistant'); + live = { root: b.root, body: b.body, html: '', think: null }; + 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 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; +} + +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'); + } + 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)); + } + scrollDown(false); +} + +/* --- state / status ------------------------------------------------------ */ + +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') : ''; + 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; + $('btn-stop').hidden = !busy; + $('btn-send').hidden = busy; + $('status').textContent = busy ? '运行中…' : '就绪'; + 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)); + } +} + +/* --- 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 l = ensureLive(); + l.html += JSON.parse(e.data).text; + l.body.innerHTML = l.html; + scrollDown(false); + }); + es.addEventListener('assistant_delta', () => {}); + es.addEventListener('think_delta', (e) => { + thinkBlock(JSON.parse(e.data).text); + 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 */ + }); + es.onerror = () => { $('status').textContent = '连接中断,重连中…'; }; +} + +/* --- modals -------------------------------------------------------------- */ + +function openModal(node) { + const box = $('modal-box'); + box.textContent = ''; + box.appendChild(node); + $('modal').hidden = false; +} +function closeModal() { + $('modal').hidden = true; + $('modal-box').textContent = ''; + permReq = 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'); + 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 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) })); + box.appendChild(row); + openModal(box); + setTimeout(() => free.focus(), 0); +} + +async function showSessions() { + const data = await getJSON('api/sessions'); + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '会话')); + for (const s of data.sessions) { + 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(Object.assign(el('button', null, '打开'), { + onclick: async () => { + await post('api/sessions/switch', { id: s.id }); + closeModal(); + await loadSession(); + }, + })); + 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(); }, + })); + 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)); + 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'; + row.appendChild(input); + row.appendChild(Object.assign(el('button', 'ok', '应用'), { + onclick: async () => { await post('api/config', apply(input.value)); }, + })); + 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 }), + })); + } + 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 }), + })); + } + 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); +} + +/* --- composer ------------------------------------------------------------ */ + +async function send() { + const input = $('input'); + const text = input.value.trim(); + if (!text || state.status_busy) return; + input.value = ''; + bubble('You', 'user').body.textContent = text; + try { + await post('api/turn', { text }); + } catch (e) { + bubble('Error', 'assistant').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'; +}); + +async function upload(file) { + const r = await api('api/attach?name=' + encodeURIComponent(file.name), { + method: 'POST', + headers: { 'Content-Type': file.type || 'application/octet-stream' }, + body: file, + }); + if (!r.ok) alert(await r.text()); +} +$('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) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + e.preventDefault(); + await upload(item.getAsFile()); + } + } +}); +document.addEventListener('dragover', (e) => e.preventDefault()); +document.addEventListener('drop', async (e) => { + if (!e.dataTransfer.files.length) return; + e.preventDefault(); + for (const f of e.dataTransfer.files) await upload(f); +}); + +/* --- go ------------------------------------------------------------------ */ + +connect(); +loadSession().catch(() => {}); +getJSON('api/state').then(applyState).catch(() => {}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..24b74ca --- /dev/null +++ b/web/index.html @@ -0,0 +1,41 @@ + + + + + +CST-Agent + + + +
+ Build + auto + + - + + + + + + + +
+ +
+ + +
+ +
+ + + + +
+
就绪
+
+ + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..40c79f0 --- /dev/null +++ b/web/style.css @@ -0,0 +1,103 @@ +:root { + --bg: #14161a; + --bg2: #1b1e24; + --fg: #d8dde5; + --muted: #8b93a1; + --line: #2a2f38; + --accent: #7fb2ff; + --ok: #7ddc8f; + --warn: #ffcc66; + --err: #ff7b72; + --tool: #b5e3c8; +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +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%; +} +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); } + +#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; +} +.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); } + +#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; +} +#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); } + +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); +} +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); } + +#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; +} +#todo h4 { margin: 0 0 4px; color: var(--muted); font-size: 12px; font-weight: normal; } + +#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; +} +.att { + border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px; cursor: pointer; +} +.hint { display: flex; gap: 10px; font-size: 12px; color: var(--muted); padding-top: 4px; } +#pending { color: var(--warn); font-size: 12px; padding-bottom: 4px; } + +#modal { + position: fixed; inset: 0; background: rgba(0, 0, 0, .55); + display: flex; align-items: center; justify-content: center; z-index: 9; +} +#modal-box { + background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; + padding: 14px; width: min(820px, 94vw); max-height: 88vh; overflow: auto; +} +#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 .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; }