diff --git a/CMakeLists.txt b/CMakeLists.txt index c58ee32..17e57de 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,7 @@ set(CST_CORE_SOURCES src/agent_store.c src/json_baseline.c src/creds.c + src/provider_switch.c src/mm_tool.c src/models.c src/model_registry.c diff --git a/Makefile b/Makefile index 22d9a08..d280c1b 100644 --- a/Makefile +++ b/Makefile @@ -91,7 +91,7 @@ CORE_SRC = \ src/agent_loop.c \ src/agent_store.c \ src/json_baseline.c \ - src/creds.c \ + src/creds.c src/provider_switch.c \ src/mm_tool.c \ src/models.c src/model_registry.c \ src/metrics.c \ diff --git a/docs/webui.md b/docs/webui.md index 68baa7b..53e8310 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -91,7 +91,7 @@ GET /api/events?since= → text/event-stream (chunked) | `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`done` | | `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` | | `todo` | `text` | `todo_write` 替换后的清单(一行一项) | -| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox` | 需要回答(见下) | +| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox`,`timeout` | 需要回答(见下);`timeout` 秒后按拒绝处理 | | `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) | | `ask_user` | `id`,`question`,`options[]` | 需要回答 | | `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` | @@ -100,9 +100,16 @@ 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,cached_tokens,cache_miss_tokens,cost,priced,currency}`。 +completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}` +`turn_usage{…}`(上一轮,字段同 `usage`)`api_base` `key_set` `key_from` +(`saved` / 环境变量名 / `session` / 空,**从不含 key 本身**) +`model_info{label,context,vision,reasoning,source,price?}` `effort_applies` +`sees_images`(1 / 0 / -1 未知)`max_steps` `tool_profile` `perm_timeout`。 `cached_tokens` / `cache_miss_tokens` 来自上游的 prompt cache 统计,前端用它算命中率。 +`think_delta` / `assistant_delta` 的 `text` 总是完整的 UTF-8:上游在多字节字符中间 +断开时,残缺的尾巴留到下一块再发。 + ### 请求 | 方法 | 路径 | 说明 | @@ -117,7 +124,11 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`。 | POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程);挂起的权限问题按拒绝、提问按忽略处理 | | POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix | | POST | `/api/ask_user` | `{"id":N,"text":"…"}` 或 `{"id":N,"dismiss":true}` | -| POST | `/api/config` | `{model?,provider?,effort?,approval?,plan_mode?}` | +| POST | `/api/config` | `{model?,effort?,approval?,plan_mode?}`;先整体校验再生效,model/effort/approval(`yolo` 除外)写进 `settings.json`;当前模型不吃 effort 时回 `warning`;带 `provider` → 400(改用 `/api/provider`) | +| GET | `/api/providers` | `{current, providers:[{name,api_base,model,has_key,key_from,key_env,source,current}]}` | +| GET | `/api/models[?refresh=1]` | 当前提供商的模型:`{provider,current,api_base,key_set,api_count,models:[{id,api,label,context,vision,reasoning,source,price?}]}`;先用缓存,过期/缺失/`refresh=1` 时向 `/v1/models` 拉;key 被拒 → 401(仍带内置模型和 `auth:true`),其它失败放在 `api_error` | +| POST | `/api/provider` | `{"name":"…","key":"…"?}`:切换提供商。缺 key → 400 `{need_key:true}`;key 被拒 → 401;没这个提供商 → 404;成功 → `{ok,provider,model,warning?}`,key 验证后存进 `credentials`(0600) | +| POST | `/api/key` | `{"key":"…"}`:换当前提供商的 key,验证与回复同上 | | POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 | | POST | `/api/compact` | 手动压缩上下文 | | POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤32 MiB),进入"待发送"列表 | @@ -127,8 +138,11 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`。 未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。 轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`workspace`、 -`undo`、`compact`、`attach`(上传/清空)以及改 `model/provider/effort` 的 `config` -一律 **409**;`approval`/`plan_mode` 可以中途改。 +`undo`、`compact`、`attach`(上传/清空)、`provider`、`key` 以及改 `model/effort` 的 +`config` 一律 **409**;`approval`/`plan_mode` 可以中途改。 + +key 只出现在请求体里:回复、事件、日志都不会带它。`/api/provider` 与 `/api/key` +跟其它 `/api/*` 一样受 loopback 同源检查或 token 保护。 ### 客户端接入示例(10 行) @@ -176,6 +190,7 @@ TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目 ```bash make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒) sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key + # (含 e2e_webui_settings.py:假 /v1/models、换提供商/key、中文思考流) sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl ctl ``` diff --git a/include/cst_provider_switch.h b/include/cst_provider_switch.h new file mode 100644 index 0000000..07e8acc --- /dev/null +++ b/include/cst_provider_switch.h @@ -0,0 +1,79 @@ +#ifndef CST_PROVIDER_SWITCH_H +#define CST_PROVIDER_SWITCH_H + +/* Model lists, API keys and provider switches, shared by the TUI and the + * WebUI so both front ends validate and persist the same way. + * + * A key is checked with GET /models: a 401/403 (CST_MODELS_AUTH) rejects + * it, any other failure (offline, no /models route) lets it through + * unverified. A key that passes is saved to /credentials. Keys are + * never copied into err. */ + +#include "cst_agent.h" +#include "cst_models.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define CST_PS_MAX_MODELS 512 +#define CST_PS_UNKNOWN (-1) /* no such provider */ +#define CST_PS_NEED_KEY (-10) /* no key given, none saved or in the env */ +/* Non-negative results are flags: */ +#define CST_PS_UNVERIFIED 1 /* /models failed for another reason; err says */ +#define CST_PS_UNSAVED 2 /* the credentials file could not be written */ + +typedef struct { + char id[128]; + int api; /* listed by the provider's /models (else built-in only) */ + cst_model_info_t info; +} cst_ps_model_t; + +/* GET /models; a good answer refreshes models-cache/.json. The + * count, CST_MODELS_AUTH, or another negative value with err set. */ +int cst_ps_fetch(const char *provider, const char *api_base, const char *key, + char ids[][128], int max, char *err, size_t err_sz); +/* The cached list: count, or -1; *fresh when younger than the TTL. */ +int cst_ps_cached(const char *provider, char ids[][128], int max, int *fresh); + +/* Base URL for provider: cfg's own when it is the current provider (it may + * be overridden), else the preset's; "" when unknown. */ +const char *cst_ps_base(const cst_agent_config_t *cfg, const char *provider); + +/* The model picker list for cfg's provider: the API's ids (the cache while + * it is fresh, else a fetch; refresh always fetches) followed by the + * built-in names it did not list. *api_rc gets the fetch result (count, + * CST_MODELS_AUTH, -1 with err) or the cached count. Returns the count. */ +int cst_ps_models(const cst_agent_config_t *cfg, int refresh, + cst_ps_model_t *out, int max, int *api_rc, char *err, + size_t err_sz); + +/* Checks key against /models and saves it: CST_MODELS_AUTH (err set), or + * the CST_PS_* flags. When ids is given it gets the fetched list and *nids + * the raw fetch result. */ +int cst_ps_verify_save(const char *provider, const char *api_base, + const char *key, char ids[][128], int max, int *nids, + char *err, size_t err_sz); + +/* Point cfg at provider p with key: base URL, the remembered model (else + * the preset's), settings saved. */ +void cst_ps_apply_provider(cst_agent_config_t *cfg, const cst_provider_t *p, + const char *key); + +/* The whole switch without a prompt: key NULL/"" uses the saved or env + * key (else CST_PS_NEED_KEY); a given key is verified and saved first. A + * preset with no model takes the first one the API lists. The CST_PS_* + * flags, or CST_PS_UNKNOWN / CST_PS_NEED_KEY / CST_MODELS_AUTH. */ +int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name, + const char *key, char *err, size_t err_sz); + +/* Replace the current provider's key: verified, saved, put in cfg. Same + * results as cst_ps_verify_save. */ +int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err, + size_t err_sz); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/cst_util.h b/include/cst_util.h index abf922e..fedaebf 100644 --- a/include/cst_util.h +++ b/include/cst_util.h @@ -27,6 +27,17 @@ size_t cst_utf8_floor(const char *s, size_t n); char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len); int cst_utf8_valid(const char *s, size_t n); +/* Re-chunks a byte stream so no piece ends inside a UTF-8 sequence: the + * incomplete tail (at most 3 bytes) waits in the hold for the next feed. */ +typedef struct { + char b[4]; + size_t n; +} cst_utf8_hold_t; +/* Returns the complete prefix of hold+s[0..n) (malloc'd, NUL-terminated, + * *out_len may be 0), or NULL on OOM. */ +char *cst_utf8_hold_feed(cst_utf8_hold_t *h, const char *s, size_t n, + size_t *out_len); + /* Text files may start with a UTF-8 BOM (Windows editors): its length (0/3), * and an in-place strip of the NUL-terminated s (*len updated when given). */ size_t cst_bom_len(const char *s, size_t n); diff --git a/include/cst_version.h b/include/cst_version.h index d977551..58c1988 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 2 +#define CST_VERSION_PATCH 3 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/scripts/e2e_webui.sh b/scripts/e2e_webui.sh index 7e2bb5f..44fb09c 100644 --- a/scripts/e2e_webui.sh +++ b/scripts/e2e_webui.sh @@ -282,6 +282,9 @@ if command -v python3 > /dev/null; then echo "== HTTP hardening" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_http.py" > "$WORK/http.txt" 2>&1 || { cat "$WORK/http.txt"; fail "the HTTP hardening checks"; } + echo "== settings and streaming" + CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_settings.py" > "$WORK/settings.txt" 2>&1 || + { cat "$WORK/settings.txt"; fail "the settings checks"; } fi echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))" diff --git a/scripts/e2e_webui_settings.py b/scripts/e2e_webui_settings.py new file mode 100644 index 0000000..92611a6 --- /dev/null +++ b/scripts/e2e_webui_settings.py @@ -0,0 +1,313 @@ +"""WebUI settings and streaming against fake OpenAI-compatible APIs. + + python3 scripts/e2e_webui_settings.py (CST_AGENT= picks the binary) + +Checks: Chinese reasoning streams as whole characters in few events; the +provider list, the model list with capabilities, config validation and +persistence; a wrong key is rejected, a good one switches the provider +(requests then go to the new base URL) and is saved; and all of it +survives a restart. HOME and XDG_CONFIG_HOME point at a temp dir, so no +real credentials or settings are read or written. +""" +import json, os, shutil, stat, subprocess, sys, tempfile, threading, time, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent")) +THINK = ["好的,我先看看工作区里", "有哪些文件,然后用中文", "回答这个问题。😀 结束"] +ANSWER = "这是中文回答。" +GOOD_KEYS = {"sk-good", "sk-good2"} +B_REQS = [] + + +def sse_reply(h, model, think, answer): + h.send_response(200) + h.send_header("Content-Type", "text/event-stream") + h.end_headers() + base = {"id": "x", "object": "chat.completion.chunk", "model": model} + + def ev(o): + h.wfile.write(b"data: " + json.dumps({**base, **o}, ensure_ascii=False).encode() + b"\n\n") + h.wfile.flush() + for part in think: + ev({"choices": [{"index": 0, "delta": {"role": "assistant", "reasoning_content": part}}]}) + ev({"choices": [{"index": 0, "delta": {"content": answer}}]}) + ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}) + h.wfile.write(b"data: [DONE]\n\n") + h.wfile.flush() + + +def send_json(h, code, obj): + body = json.dumps(obj).encode() + h.send_response(code) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(body))) + h.end_headers() + h.wfile.write(body) + + +class A(BaseHTTPRequestHandler): + """The starting provider: any key, one model, Chinese reasoning.""" + def log_message(self, *a): + pass + + def do_GET(self): + send_json(self, 200, {"data": [{"id": "deepseek-flash"}]}) + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", 0))) + sse_reply(self, "deepseek-flash", THINK, ANSWER) + + +class B(BaseHTTPRequestHandler): + """The provider switched to: /models checks the key.""" + def log_message(self, *a): + pass + + def key_ok(self): + auth = self.headers.get("Authorization", "") + return auth.startswith("Bearer ") and auth[7:] in GOOD_KEYS + + def do_GET(self): + if not self.key_ok(): + return send_json(self, 401, {"error": {"message": "invalid api key"}}) + send_json(self, 200, {"data": [{"id": "fake-vis"}, {"id": "fake-text"}]}) + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + B_REQS.append({"auth": self.headers.get("Authorization", ""), "model": body.get("model")}) + if not self.key_ok(): + return send_json(self, 401, {"error": {"message": "invalid api key"}}) + sse_reply(self, body.get("model") or "fake-vis", [], "B 的回答") + + +fails = 0 + + +def check(ok, msg): + global fails + print(("PASS " if ok else "FAIL ") + msg) + fails += 0 if ok else 1 + + +class Sse: + def __init__(self, base): + self.lines = [] + self.r = urllib.request.urlopen(base + "/api/events", timeout=60) + threading.Thread(target=self.pump, daemon=True).start() + + def pump(self): + try: + for raw in self.r: + self.lines.append(raw) + except Exception: + pass + + def events(self, name): + out = [] + for i, l in enumerate(self.lines): + if l.strip() == b"event: " + name.encode() and i + 1 < len(self.lines): + out.append(self.lines[i + 1][6:]) + return out + + def close(self): + try: + self.r.close() + except Exception: + pass + + +def wait(fn, t=15): + end = time.time() + t + while time.time() < end: + v = fn() + if v: + return v + time.sleep(0.1) + return None + + +def call(base, path, obj=None, method=None): + data = None if obj is None else json.dumps(obj).encode() + req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + raw = r.read() + return r.status, json.loads(raw or b"{}"), raw + except urllib.error.HTTPError as e: + raw = e.read() + try: + return e.code, json.loads(raw or b"{}"), raw + except ValueError: + return e.code, {}, raw + + +def serve(handler): + s = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=s.serve_forever, daemon=True).start() + return s + + +api_a, api_b = serve(A), serve(B) +ws = tempfile.mkdtemp(prefix="cst-set-") +home = tempfile.mkdtemp(prefix="cst-home-") +cfgdir = home + "/.config/cst-agent" +os.makedirs(cfgdir) +with open(cfgdir + "/models.json", "w") as f: + json.dump({"providers": {"fakeb": {"api_base": "http://127.0.0.1:%d/v1" % api_b.server_address[1], + "key_env": "FAKEB_API_KEY", "default_model": ""}}, + "models": [{"match": "fake-vis", "vision": True, "context": 200000}, + {"match": "fake-text", "vision": False}]}, f) +env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config", + XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache") +for k in ("DEEPSEEK_API_KEY", "FAKEB_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"): + env.pop(k, None) +procs = [] + + +def start(args, tag): + log = open("%s/%s.log" % (ws, tag), "w+") + p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws] + args, + stdout=log, stderr=subprocess.STDOUT, env=env) + procs.append((p, log)) + + def port(): + log.seek(0) + for l in log.read().splitlines(): + if l.startswith("webui: http://127.0.0.1:"): + return l.split(":")[3].split("/")[0] + pt = wait(port) + return p, "http://127.0.0.1:%s" % pt + + +def stop(p): + p.terminate() + try: + p.wait(20) + except subprocess.TimeoutExpired: + p.kill() + check(False, "server hung on SIGTERM") + check(p.returncode is not None and 0 <= p.returncode < 128, "clean exit (%s)" % p.returncode) + + +def leaked(raw): + return any(k.encode() in raw for k in GOOD_KEYS | {"sk-bad", "sk-x"}) + + +try: + p, base = start(["--new", "--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash", + "--api-base", "http://127.0.0.1:%d/v1" % api_a.server_address[1], + "--protocol", "native-eos", "--approval", "auto"], "first") + a = Sse(base) + time.sleep(0.3) + + # 1. Chinese reasoning: whole characters, a handful of events + code, _, _ = call(base, "/api/turn", {"text": "用中文想一想"}) + check(code == 202, "turn accepted") + end = wait(lambda: a.events("turn_end"), 20) + check(bool(end) and json.loads(end[0]).get("rc") == 0, "turn ended rc=0") + raw = a.events("think_delta") + ok = True + for r in raw: + try: + r.decode("utf-8") + except UnicodeDecodeError: + ok = False + check(ok, "every think_delta frame is valid UTF-8") + text = "".join(json.loads(r.decode("utf-8", "replace"))["text"] for r in raw) + want = "".join(THINK) + check(text == want and "\ufffd" not in text, "the thinking text round trips: %r" % text[:60]) + check(0 < len(raw) <= 2 * len(THINK) + 2, + "think_delta events: %d for %d bytes" % (len(raw), len(want.encode()))) + ans = "".join(json.loads(r)["text"] for r in a.events("assistant_delta")) + check(ans == ANSWER, "the answer round trips: %r" % ans) + + # 2. providers and models + code, d, rawp = call(base, "/api/providers") + names = {x["name"]: x for x in d.get("providers", [])} + check(code == 200 and "deepseek" in names and "fakeb" in names, "provider list: %s" % sorted(names)) + check(names.get("deepseek", {}).get("current") and names["deepseek"].get("has_key") + and names["deepseek"].get("key_from") == "session", "deepseek: current, key for this run") + check(names.get("fakeb", {}).get("has_key") is False and names["fakeb"].get("source") == "user", + "fakeb: no key yet, from models.json") + check(not leaked(rawp), "no key in /api/providers") + code, d, _ = call(base, "/api/models") + ids = [m["id"] for m in d.get("models", [])] + first = (d.get("models") or [{}])[0] + check(code == 200 and "deepseek-flash" in ids and first.get("api") is True + and "context" in first and "label" in first, "model list: %s" % ids[:5]) + + # 3. config: validation and persistence + check(call(base, "/api/config", {"effort": "bogus"})[0] == 400, "a bad effort is a 400") + check(call(base, "/api/config", {"provider": "fakeb"})[0] == 400, "provider via /api/config is a 400") + check(call(base, "/api/config", {"model": ""})[0] == 400, "an empty model is a 400") + code, d, _ = call(base, "/api/config", {"model": "deepseek-v4-pro", "effort": "high"}) + check(code == 200 and d.get("ok"), "model + effort accepted") + code, s, _ = call(base, "/api/state") + check(s.get("model") == "deepseek-v4-pro" and s.get("effort") == "high", "the state has them") + check(s.get("effort_applies") is True and s.get("key_set") is True and "model_info" in s + and s.get("perm_timeout", 0) > 0 and "turn_usage" in s, "the state has the new fields") + st = open(cfgdir + "/settings.json").read() if os.path.exists(cfgdir + "/settings.json") else "" + check("deepseek-v4-pro" in st and "high" in st, "settings.json remembers model and effort") + + # 4. switching provider: no key, a wrong key, a good key + code, d, _ = call(base, "/api/provider", {"name": "fakeb"}) + check(code == 400 and d.get("need_key"), "fakeb without a key: need_key (%s)" % code) + code, d, rawp = call(base, "/api/provider", {"name": "fakeb", "key": "sk-bad"}) + check(code == 401 and "拒绝" in d.get("error", ""), "a wrong key is rejected (%s %s)" % (code, d.get("error"))) + check(not leaked(rawp), "no key in the rejection") + check(call(base, "/api/state")[1].get("provider") == "deepseek", "still on deepseek") + check(call(base, "/api/provider", {"name": "nope"})[0] == 404, "an unknown provider is a 404") + code, d, rawp = call(base, "/api/provider", {"name": "fakeb", "key": "sk-good"}) + check(code == 200 and d.get("provider") == "fakeb" and d.get("model") == "fake-vis", + "switched to fakeb, first listed model picked (%s %s)" % (code, d)) + check(not leaked(rawp), "no key in the switch reply") + creds = cfgdir + "/credentials" + cr = open(creds).read() if os.path.exists(creds) else "" + check("fakeb.api_key=sk-good" in cr, "the key was saved to credentials") + if os.name == "posix" and os.path.exists(creds): + check(stat.S_IMODE(os.stat(creds).st_mode) == 0o600, "credentials is 0600") + code, d, _ = call(base, "/api/models") + ms = {m["id"]: m for m in d.get("models", [])} + check("fake-vis" in ms and ms["fake-vis"].get("vision") is True and ms["fake-vis"].get("context") == 200000 + and ms.get("fake-text", {}).get("vision") is False, "fakeb models with capabilities") + code, d, _ = call(base, "/api/config", {"effort": "low"}) + check(code == 200 and d.get("warning"), "effort on a model without it: saved with a warning") + + # 5. a turn now goes to the new base URL with the new key + n0 = len(B_REQS) + code, _, _ = call(base, "/api/turn", {"text": "hi B"}) + check(code == 202, "turn on fakeb accepted") + wait(lambda: len(a.events("turn_end")) >= 2, 20) + check(len(B_REQS) > n0 and B_REQS[-1]["auth"] == "Bearer sk-good" and B_REQS[-1]["model"] == "fake-vis", + "the request reached fakeb: %s" % (B_REQS[-1:] or "none")) + + # 6. replacing the key + code, d, _ = call(base, "/api/key", {"key": "sk-bad"}) + check(code == 401, "a wrong replacement key is rejected (%s)" % code) + code, d, _ = call(base, "/api/key", {"key": "sk-good2"}) + check(code == 200 and d.get("ok"), "the replacement key is accepted") + check("fakeb.api_key=sk-good2" in open(creds).read(), "the replacement key was saved") + check(call(base, "/api/key", {})[0] == 400, "an empty key body is a 400") + a.close() + stop(p) + + # 7. the next start remembers provider, model and key + p, base = start(["--new"], "second") + code, s, raws = call(base, "/api/state") + check(s.get("provider") == "fakeb" and s.get("model") == "fake-vis" and s.get("key_set") is True + and s.get("key_from") == "saved", "restart: %s/%s key %s" % (s.get("provider"), s.get("model"), s.get("key_from"))) + check(not leaked(raws), "no key in /api/state") + stop(p) + + logs = b"".join(open(lg.name, "rb").read() for _, lg in procs) + check(not leaked(logs.replace(b"sk-x", b"")) , "no key in the server logs") +finally: + for p, log in procs: + if p.poll() is None: + p.kill() + log.close() + shutil.rmtree(ws, ignore_errors=True) + shutil.rmtree(home, ignore_errors=True) +print("FAILED:", fails) +sys.exit(1 if fails else 0) diff --git a/scripts/tui_keys/provider.py b/scripts/tui_keys/provider.py new file mode 100644 index 0000000..273183c --- /dev/null +++ b/scripts/tui_keys/provider.py @@ -0,0 +1,65 @@ +"""TUI /provider and /key against a fake API whose /models checks the key. + + nix-shell -p python3Packages.pyte --run "python3 scripts/tui_keys/provider.py" + +A wrong key is asked for again, a good one is saved and switches the +provider; /key replaces it. Same code path as the WebUI (provider_switch.c). +""" +import json, os, sys, threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import Term +from keys import check, base + +GOOD = {"sk-good", "sk-good2"} + + +class B(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + auth = self.headers.get("Authorization", "") + ok = auth.startswith("Bearer ") and auth[7:] in GOOD + body = json.dumps({"data": [{"id": "fake-one"}]} if ok else {"error": {"message": "bad key"}}).encode() + self.send_response(200 if ok else 401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +srv = ThreadingHTTPServer(("127.0.0.1", 0), B) +threading.Thread(target=srv.serve_forever, daemon=True).start() + +t = Term(args=("-b", base, "-k", "sk-test")) +cfg = os.path.join(t.home, ".config", "cst-agent") +os.makedirs(cfg, exist_ok=True) +t.close() +with open(os.path.join(cfg, "models.json"), "w") as f: + json.dump({"providers": {"fakeb": {"api_base": "http://127.0.0.1:%d/v1" % srv.server_address[1], + "key_env": "FAKEB_API_KEY", "default_model": ""}}}, f) +t = Term(args=("-b", base, "-k", "sk-test"), home=t.home, env={"FAKEB_API_KEY": ""}) +creds = os.path.join(cfg, "credentials") + + +def screen(): + return "\n".join(t.lines()) + + +t.send("/provider fakeb\r", 0.8) +check("a key prompt for fakeb", "API key for fakeb" in screen(), t) +t.send("sk-bad\r", 1.5) +check("a wrong key is asked for again", "rejected that key" in screen(), t) +check("the wrong key is not saved", not os.path.exists(creds) or "sk-bad" not in open(creds).read(), t) +t.send("sk-good\r", 2.0) +check("switched to fakeb", "provider → fakeb" in screen(), t) +check("the good key is saved", os.path.exists(creds) and "fakeb.api_key=sk-good" in open(creds).read(), t) +check("no key on screen", "sk-good" not in screen() and "sk-bad" not in screen(), t) +t.send("/key\r", 0.8) +t.send("sk-good2\r", 2.0) +check("/key replaces it", "fakeb.api_key=sk-good2" in open(creds).read(), t) +t.close() +from keys import FAILS +print("FAILED:", len(FAILS)) +sys.exit(min(len(FAILS), 100)) diff --git a/src/agent_loop.c b/src/agent_loop.c index 84023dc..ccc7d7e 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -1282,13 +1282,22 @@ static void on_mm(const ctl_event_t *ev, void *ud) { } static void feed_visible(turn_t *t, const char *data, size_t len) { - if (!len || t->stop) - return; - cst_buf_append(&t->raw, data, len); - if (t->ctl) - ctl_parser_feed(t->ctl, data, len); - else if (t->mm) - mm_scan_feed(t->mm, data, len); + /* cut after each '\n' and '>': a call only becomes ready there, and + * raw_mark must not take in what follows it in the same chunk */ + while (len && !t->stop) { + size_t n = 0; + while (n < len && data[n] != '\n' && data[n] != '>') + n++; + if (n < len) + n++; + cst_buf_append(&t->raw, data, n); + if (t->ctl) + ctl_parser_feed(t->ctl, data, n); + else if (t->mm) + mm_scan_feed(t->mm, data, n); + data += n; + len -= n; + } } static void think_out(turn_t *t, const char *s, size_t n) { @@ -1306,10 +1315,16 @@ static void on_delta(const char *data, size_t len, void *ud) { const char *want = t->in_think ? "" : ""; size_t wl = strlen(want); if (t->taglen == 0 && c != '<') { + /* the whole run up to the next '<' in one call: byte by byte, a + * front end would get every CJK character in three invalid pieces */ + size_t j = i + 1; + while (j < len && data[j] != '<') + j++; if (t->in_think) - think_out(t, &c, 1); + think_out(t, data + i, j - i); else - feed_visible(t, &c, 1); + feed_visible(t, data + i, j - i); + i = j - 1; continue; } t->tagbuf[t->taglen++] = c; diff --git a/src/provider_switch.c b/src/provider_switch.c new file mode 100644 index 0000000..6cf22fb --- /dev/null +++ b/src/provider_switch.c @@ -0,0 +1,193 @@ +#include "cst_provider_switch.h" + +#include +#include +#include + +int cst_ps_fetch(const char *provider, const char *api_base, const char *key, + char ids[][128], int max, char *err, size_t err_sz) { + cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m)); + if (!m) { + snprintf(err, err_sz, "out of memory"); + return -1; + } + int r = cst_llm_list_models_meta(api_base, key, m, max, err, err_sz); + if (r >= 0) + cst_models_cache_save(provider, api_base, m, r); + for (int i = 0; i < r; i++) + snprintf(ids[i], 128, "%s", m[i].id); + free(m); + return r; +} + +int cst_ps_cached(const char *provider, char ids[][128], int max, int *fresh) { + *fresh = 0; + cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m)); + if (!m) + return -1; + long age = 0; + int n = cst_models_cache_load(provider, m, max, &age); + for (int i = 0; i < n; i++) + snprintf(ids[i], 128, "%s", m[i].id); + free(m); + *fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL; + return n > 0 ? n : -1; +} + +const char *cst_ps_base(const cst_agent_config_t *cfg, const char *provider) { + if (cfg && strcmp(cfg->provider, provider) == 0) + return cfg->api_base; + const cst_provider_t *p = cst_provider_find(provider); + return p ? p->api_base : ""; +} + +int cst_ps_models(const cst_agent_config_t *cfg, int refresh, + cst_ps_model_t *out, int max, int *api_rc, char *err, + size_t err_sz) { + err[0] = 0; + *api_rc = -1; + char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids)); + if (!ids) { + snprintf(err, err_sz, "out of memory"); + return 0; + } + int fresh = 0; + int na = cst_ps_cached(cfg->provider, ids, CST_PS_MAX_MODELS, &fresh); + *api_rc = na; + if ((refresh || !fresh) && cfg->api_key[0]) { + char(*got)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*got)); + if (got) { + int r = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, got, + CST_PS_MAX_MODELS, err, err_sz); + *api_rc = r; + if (r >= 0) { + memcpy(ids, got, (size_t)r * sizeof(*ids)); + na = r; + } + free(got); + } + } + int n = 0; + for (int i = 0; i < na && n < max; i++, n++) { + snprintf(out[n].id, sizeof(out[n].id), "%s", ids[i]); + out[n].api = 1; + cst_model_info(cfg->provider, ids[i], &out[n].info); + } + char builtin[32][128]; + int nb = cst_models_list(cfg->model, builtin, 32); + for (int i = 0; i < nb && n < max; i++) { + int dup = 0; + for (int k = 0; k < na && !dup; k++) + dup = strcmp(ids[k], builtin[i]) == 0; + if (dup) + continue; + memcpy(out[n].id, builtin[i], sizeof(out[n].id)); + out[n].id[sizeof(out[n].id) - 1] = 0; + out[n].api = 0; + cst_model_info(cfg->provider, builtin[i], &out[n].info); + n++; + } + free(ids); + return n; +} + +int cst_ps_verify_save(const char *provider, const char *api_base, + const char *key, char ids[][128], int max, int *nids, + char *err, size_t err_sz) { + err[0] = 0; + char(*own)[128] = NULL; + if (!ids) { + own = calloc(CST_PS_MAX_MODELS, sizeof(*own)); + if (!own) { + snprintf(err, err_sz, "out of memory"); + return CST_PS_UNVERIFIED | CST_PS_UNSAVED; + } + ids = own; + max = CST_PS_MAX_MODELS; + } + int r = cst_ps_fetch(provider, api_base, key, ids, max, err, err_sz); + free(own); + if (nids) + *nids = r; + if (r == CST_MODELS_AUTH) + return CST_MODELS_AUTH; + int flags = r < 0 ? CST_PS_UNVERIFIED : 0; + if (r >= 0) + err[0] = 0; + if (cst_creds_save(provider, key) != 0) + flags |= CST_PS_UNSAVED; + return flags; +} + +void cst_ps_apply_provider(cst_agent_config_t *cfg, const cst_provider_t *p, + const char *key) { + cst_settings_t s; + cst_settings_load(&s); + const char *model = cst_settings_model(&s, p->name); + snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name); + snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base); + snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model); + snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key); + cst_settings_remember(cfg); +} + +int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name, + const char *key, char *err, size_t err_sz) { + err[0] = 0; + const cst_provider_t *p = name ? cst_provider_find(name) : NULL; + if (!p) { + snprintf(err, err_sz, "unknown provider '%.64s'", name ? name : ""); + return CST_PS_UNKNOWN; + } + char k[512]; + int flags = 0; + char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids)); + int nids = -1; + if (key && key[0]) { + snprintf(k, sizeof(k), "%s", key); + flags = cst_ps_verify_save(p->name, p->api_base, k, ids, + ids ? CST_PS_MAX_MODELS : 0, &nids, err, + err_sz); + if (flags < 0) { + memset(k, 0, sizeof(k)); + free(ids); + return flags; + } + } else if (cst_creds_resolve(p->name, k, sizeof(k), NULL) != 0) { + free(ids); + snprintf(err, err_sz, "%s needs an API key", p->name); + return CST_PS_NEED_KEY; + } + cst_ps_apply_provider(cfg, p, k); + memset(k, 0, sizeof(k)); + if (!cfg->model[0] && ids) { + /* a router preset: the first model the API lists */ + int fresh = 0; + if (nids <= 0) + nids = cst_ps_cached(p->name, ids, CST_PS_MAX_MODELS, &fresh); + if (nids <= 0) { + char e2[256]; + nids = cst_ps_fetch(p->name, cfg->api_base, cfg->api_key, ids, + CST_PS_MAX_MODELS, e2, sizeof(e2)); + } + if (nids > 0) { + snprintf(cfg->model, sizeof(cfg->model), "%s", ids[0]); + cst_settings_remember(cfg); + } + } + free(ids); + return flags; +} + +int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err, + size_t err_sz) { + if (!key || !key[0]) { + snprintf(err, err_sz, "empty key"); + return CST_PS_NEED_KEY; + } + int r = cst_ps_verify_save(cfg->provider, cfg->api_base, key, NULL, 0, NULL, + err, err_sz); + if (r >= 0) + snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key); + return r; +} diff --git a/src/tui.c b/src/tui.c index ce00941..38060c8 100644 --- a/src/tui.c +++ b/src/tui.c @@ -11,6 +11,7 @@ #include "cst_mcp.h" #include "cst_models.h" #include "cst_plugins.h" +#include "cst_provider_switch.h" #include "cst_skills.h" #include "cst_subagent.h" #include "cst_textsel.h" @@ -3886,46 +3887,11 @@ static void am_store(const char *provider, int r, char ids[][128], pthread_mutex_unlock(&g_am_mu); } -/* GET /models; a good answer also refreshes models-cache/.json. */ -static int fetch_models(const char *provider, const char *base, - const char *key, char ids[][128], int max, char *err, - size_t err_sz) { - cst_model_meta_t *m = calloc((size_t)max, sizeof(*m)); - if (!m) { - snprintf(err, err_sz, "out of memory"); - return -1; - } - int r = cst_llm_list_models_meta(base, key, m, max, err, err_sz); - if (r >= 0) - cst_models_cache_save(provider, base, m, r); - for (int i = 0; i < r; i++) - snprintf(ids[i], 128, "%s", m[i].id); - free(m); - return r; -} - -/* The cached list for provider into ids: count, or -1; *fresh when it is - * younger than CST_MODELS_CACHE_TTL. */ -static int cached_models(const char *provider, char ids[][128], int max, - int *fresh) { - *fresh = 0; - cst_model_meta_t *m = calloc((size_t)max, sizeof(*m)); - if (!m) - return -1; - long age = 0; - int n = cst_models_cache_load(provider, m, max, &age); - for (int i = 0; i < n; i++) - snprintf(ids[i], 128, "%s", m[i].id); - free(m); - *fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL; - return n > 0 ? n : -1; -} - static void *probe_thread(void *arg) { probe_arg_t *a = arg; static char ids[AM_MAX][128]; /* one probe runs per provider switch */ char err[256]; - int r = fetch_models(a->provider, a->base, a->key, ids, AM_MAX, err, + int r = cst_ps_fetch(a->provider, a->base, a->key, ids, AM_MAX, err, sizeof(err)); if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache) am_store(a->provider, r, ids, err); @@ -3937,23 +3903,13 @@ static void *probe_thread(void *arg) { return NULL; } -/* Base URL for a provider: the live config for the current one (it may be - * overridden), else the preset. */ -static const char *provider_base(const tui_t *t, const char *provider) { - const cst_agent_config_t *cfg = cst_agent_config(t->agent); - if (cfg && strcmp(cfg->provider, provider) == 0) - return cfg->api_base; - const cst_provider_t *p = cst_provider_find(provider); - return p ? p->api_base : ""; -} - static void start_probe(tui_t *t) { const cst_agent_config_t *cfg = cst_agent_config(t->agent); if (!cfg || !cfg->api_key[0] || g_am_threads > 0) return; static char ids[AM_MAX][128]; int fresh = 0; - int nc = cached_models(cfg->provider, ids, AM_MAX, &fresh); + int nc = cst_ps_cached(cfg->provider, ids, AM_MAX, &fresh); pthread_mutex_lock(&g_am_mu); snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); pthread_mutex_unlock(&g_am_mu); @@ -4039,13 +3995,14 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n, return 0; char dir[1024] = "the config directory"; os_config_dir(dir, sizeof(dir)); - const char *base = provider_base(t, provider); + const char *base = cst_ps_base(cst_agent_config(t->agent), provider); char title[128], hint[1300]; snprintf(title, sizeof(title), "API key for %s", provider); snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you " "can read it)", base, dir); char note[300] = ""; + int flags; for (;;) { if (secret_input_run(t, title, hint, out, n) != 0) return -1; @@ -4053,8 +4010,10 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n, draw_status(t); static char ids[AM_MAX][128]; char err[256]; - int r = fetch_models(provider, base, out, ids, AM_MAX, err, sizeof(err)); - if (r == CST_MODELS_AUTH) { + int r = -1; + flags = cst_ps_verify_save(provider, base, out, ids, AM_MAX, &r, err, + sizeof(err)); + if (flags == CST_MODELS_AUTH) { snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste " "another one", base, err); @@ -4064,13 +4023,13 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n, snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider); pthread_mutex_unlock(&g_am_mu); am_store(provider, r, ids, err); - if (r < 0) + if (flags & CST_PS_UNVERIFIED) snprintf(note, sizeof(note), "could not verify the key (%.200s); " "saved anyway", err); break; } - if (cst_creds_save(provider, out) != 0) + if (flags & CST_PS_UNSAVED) set_status(t, "warning: could not save the key; it is used for this run " "only"); else if (note[0]) @@ -4302,7 +4261,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) { set_status(t, "fetching the model list…"); draw_status(t); char err[256]; - na = fetch_models(cfg->provider, cfg->api_base, cfg->api_key, names, + na = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, names, AM_MAX, err, sizeof(err)); pthread_mutex_lock(&g_am_mu); snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); @@ -4436,15 +4395,8 @@ static void cmd_provider(tui_t *t, char *arg, int *quit) { push_system_line(t, "(provider unchanged: %s needs an API key)", p->name); return; } - cst_settings_t s; - cst_settings_load(&s); - const char *model = cst_settings_model(&s, p->name); - snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name); - snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base); - snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model); - snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key); + cst_ps_apply_provider(cfg, p, key); memset(key, 0, sizeof(key)); - cst_settings_remember(cfg); push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base, cfg->model); if (am_models(p->name, NULL, 0) < 0) diff --git a/src/util.c b/src/util.c index 8f98d59..a30137c 100644 --- a/src/util.c +++ b/src/util.c @@ -99,6 +99,26 @@ size_t cst_utf8_floor(const char *s, size_t n) { return i - 1; /* sequence incomplete: cut before lead byte */ } +char *cst_utf8_hold_feed(cst_utf8_hold_t *h, const char *s, size_t n, + size_t *out_len) { + size_t have = h->n; + char *all = malloc(have + n + 1); + if (!all) + return NULL; + memcpy(all, h->b, have); + if (n) + memcpy(all + have, s, n); + size_t len = have + n; + size_t cut = cst_utf8_floor(all, len); + if (len - cut > sizeof(h->b)) + cut = len; + h->n = len - cut; + memcpy(h->b, all + cut, h->n); + all[cut] = 0; + *out_len = cut; + return all; +} + static size_t utf8_seq_ok(const unsigned char *p, size_t left) { unsigned char c = p[0]; size_t need; diff --git a/src/webui.c b/src/webui.c index 9c2afbc..c2d6e86 100644 --- a/src/webui.c +++ b/src/webui.c @@ -15,6 +15,9 @@ #include "cst_agent.h" #include "cst_attach.h" #include "cst_http.h" +#include "cst_models.h" +#include "cst_provider_switch.h" +#include "cst_tools.h" #include "cst_util.h" #include "cst_web_md.h" #include "cst_webui.h" @@ -82,6 +85,10 @@ typedef struct wui { uint64_t pending_seq; /* event of the open permission/ask_user, 0 = none */ char ask_ans[WUI_ASK_MAX]; + /* turn thread only: the tail of a UTF-8 sequence split across deltas, + * [0] think_delta, [1] assistant_delta */ + cst_utf8_hold_t carry[2]; + volatile int stopping; } wui_t; @@ -157,8 +164,79 @@ static void wui_emit_text(wui_t *w, const char *type, const char *text, wui_emit(w, type, o); } +/* A JSON string with half a character turns into U+FFFD in the browser: + * hold an incomplete trailing sequence until the next delta completes it. */ +static void wui_emit_stream(wui_t *w, int k, const char *type, + const char *text, size_t len) { + size_t n = 0; + char *s = cst_utf8_hold_feed(&w->carry[k], text, len, &n); + if (s && n) + wui_emit_text(w, type, s, n); + free(s); +} + +static void wui_reset_carry(wui_t *w) { + w->carry[0].n = w->carry[1].n = 0; +} + /* --- state --------------------------------------------------------------- */ +/* Whether provider has a key and where it comes from: "saved", the env + * variable's name, or "session" (given for this run only). The key itself + * never leaves this function. */ +static int key_source(const cst_agent_config_t *cfg, const char *provider, + char *from, size_t from_sz) { + char k[512]; + const char *src = NULL; + int found = cst_creds_resolve(provider, k, sizeof(k), &src) == 0; + int cur = cfg && strcmp(cfg->provider, provider) == 0; + int has = cur ? cfg->api_key[0] != 0 : found; + if (!has) + from[0] = 0; + else if (cur && (!found || strcmp(k, cfg->api_key) != 0)) + snprintf(from, from_sz, "session"); + else + snprintf(from, from_sz, "%s", src ? src : "saved"); + memset(k, 0, sizeof(k)); + return has; +} + +static void model_info_json(cJSON *o, const char *provider, + const char *model) { + cst_model_info_t mi; + cst_model_info(provider, model, &mi); + char label[48]; + cst_model_info_label(&mi, label, sizeof(label)); + cJSON_AddStringToObject(o, "label", label); + cJSON_AddNumberToObject(o, "context", (double)mi.context); + if (mi.vision < 0) + cJSON_AddNullToObject(o, "vision"); + else + cJSON_AddBoolToObject(o, "vision", mi.vision == 1); + cJSON_AddBoolToObject(o, "reasoning", mi.reasoning != 0); + cJSON_AddStringToObject(o, "source", mi.source); + if (mi.has_price) { + cJSON *p = cJSON_AddObjectToObject(o, "price"); + if (p) { + cJSON_AddStringToObject(p, "currency", mi.price.currency); + cJSON_AddNumberToObject(p, "miss", mi.price.miss); + cJSON_AddNumberToObject(p, "hit", mi.price.hit); + cJSON_AddNumberToObject(p, "out", mi.price.out); + } + } +} + +static void usage_json(cJSON *u, const cst_usage_t *s) { + cJSON_AddNumberToObject(u, "requests", s->requests); + cJSON_AddNumberToObject(u, "prompt_tokens", (double)s->prompt); + cJSON_AddNumberToObject(u, "completion_tokens", (double)s->out); + cJSON_AddNumberToObject(u, "cached_tokens", (double)s->cached); + cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)s->miss); + cJSON_AddNumberToObject(u, "cost", s->cost); + cJSON_AddBoolToObject(u, "priced", s->priced != 0); + cJSON_AddStringToObject(u, "currency", s->currency); +} + static cJSON *wui_state_new(wui_t *w) { const cst_agent_config_t *cfg = cst_agent_config(w->agent); cJSON *o = cJSON_CreateObject(); @@ -188,16 +266,29 @@ static cJSON *wui_state_new(wui_t *w) { cJSON_AddBoolToObject(o, "undo_available", cst_agent_undo_available(w->agent) != 0); cJSON *u = cJSON_AddObjectToObject(o, "usage"); - if (u) { - 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); - } + if (u) + usage_json(u, &sess); + u = cJSON_AddObjectToObject(o, "turn_usage"); + if (u) + usage_json(u, &turn); + cJSON_AddStringToObject(o, "api_base", cfg->api_base); + char from[64]; + cJSON_AddBoolToObject(o, "key_set", + key_source(cfg, cfg->provider, from, sizeof(from))); + cJSON_AddStringToObject(o, "key_from", from); + cJSON *mi = cJSON_AddObjectToObject(o, "model_info"); + if (mi) + model_info_json(mi, cfg->provider, cfg->model); + cJSON_AddBoolToObject(o, "effort_applies", + cst_effort_wire(cfg->provider, cfg->model, + cfg->effort) != NULL); + /* 1 yes, 0 no, -1 not known */ + cJSON_AddNumberToObject(o, "sees_images", cst_agent_sees_images(w->agent)); + cJSON_AddNumberToObject(o, "max_steps", cfg->max_steps); + cJSON_AddStringToObject(o, "tool_profile", + cst_tool_profile_name(cfg->tool_profile)); + cJSON_AddNumberToObject(o, "perm_timeout", + w->perm_timeout_s > 0 ? w->perm_timeout_s : 600); cJSON *imgs = cJSON_AddArrayToObject(o, "pending_images"); int n = cst_agent_pending_count(w->agent); for (int i = 0; i < n && imgs; i++) { @@ -309,11 +400,11 @@ static void on_assistant_delta(const char *text, size_t len, void *ud) { w->md = cst_web_md_new(wui_md_emit, w); if (w->md) cst_web_md_feed(w->md, text, len); - wui_emit_text(w, "assistant_delta", text, len); + wui_emit_stream(w, 1, "assistant_delta", text, len); } static void on_think_delta(const char *text, size_t len, void *ud) { - wui_emit_text((wui_t *)ud, "think_delta", text, len); + wui_emit_stream((wui_t *)ud, 0, "think_delta", text, len); } static cJSON *call_json(const ctl_tool_call_t *call) { @@ -380,6 +471,10 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail, int has_client = w->subs != NULL; pthread_mutex_unlock(&w->mu); + int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600; + /* Nobody is watching: do not hang the turn for ten minutes. */ + if (!has_client) + timeout = 3; cJSON *o = cJSON_CreateObject(); if (o) { cJSON_AddNumberToObject(o, "id", (double)id); @@ -390,12 +485,9 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail, strncmp(detail, CST_PERM_SANDBOX, strlen(CST_PERM_SANDBOX)) == 0); + cJSON_AddNumberToObject(o, "timeout", timeout); wui_set_pending(w, wui_emit(w, "permission", o)); } - int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600; - /* Nobody is watching: do not hang the turn for ten minutes. */ - if (!has_client) - timeout = 3; int answer = CST_PERM_DENY; struct timespec deadline; clock_gettime(CLOCK_REALTIME, &deadline); @@ -765,7 +857,9 @@ static void *wui_turn_main(void *arg) { hooks.userdata = w; char err[512]; err[0] = 0; + wui_reset_carry(w); int rc = cst_agent_run_turn_ex(w->agent, t->prompt, &hooks, err, sizeof(err)); + wui_reset_carry(w); /* a dangling partial character is dropped */ wui_end_turn(w, rc, err); free(t->prompt); free(t); @@ -863,6 +957,46 @@ static void wui_release(wui_t *w) { pthread_mutex_unlock(&w->mu); } +/* A copy of the config for a slow read (a model list fetch). Changes only + * write it after claiming under mu, so none is half done while mu is held + * and op_busy is clear; a running turn does not write these fields. */ +static int wui_cfg_snapshot(wui_t *w, cst_agent_config_t *out) { + pthread_mutex_lock(&w->mu); + int ok = !w->op_busy; + if (ok) + *out = *cst_agent_config(w->agent); + pthread_mutex_unlock(&w->mu); + return ok ? 0 : -1; +} + +static int wui_send_obj(cst_http_conn_t *c, int status, cJSON *o) { + char *json = o ? cJSON_PrintUnformatted(o) : NULL; + cJSON_Delete(o); + if (!json) + return cst_http_send_err(c, 500, "out of memory"); + int rc = cst_http_send_json(c, status, json); + free(json); + return rc; +} + +static cJSON *wui_body(cst_http_conn_t *c) { + const cst_http_req_t *req = cst_http_request(c); + return req->body_len ? cJSON_ParseWithLength(req->body, req->body_len) + : NULL; +} + +static int wui_model_ok(const cJSON *m) { + if (!cJSON_IsString(m)) + return 0; + size_t n = strlen(m->valuestring); + if (n == 0 || n > 127) + return 0; + for (size_t i = 0; i < n; i++) + if ((unsigned char)m->valuestring[i] < 0x21) + return 0; + return 1; +} + #define WUI_EXCLUSIVE(name) \ static int name(cst_http_conn_t *c, void *ud) { \ wui_t *w = ud; \ @@ -1053,40 +1187,256 @@ static int h_config(cst_http_conn_t *c, void *ud) { cJSON_Delete(j); return cst_http_send_err(c, 400, "expected a JSON object"); } + /* everything is checked before anything changes */ + const cJSON *model = cJSON_GetObjectItem(j, "model"); + const cJSON *effort = cJSON_GetObjectItem(j, "effort"); + const cJSON *approval = cJSON_GetObjectItem(j, "approval"); + const cJSON *plan = cJSON_GetObjectItem(j, "plan_mode"); + const char *bad = NULL; + if (cJSON_GetObjectItem(j, "provider")) + bad = "切换提供商请用 POST /api/provider"; + else if (model && !wui_model_ok(model)) + bad = "模型名无效(1-127 个可见字符)"; + else if (effort && (!cJSON_IsString(effort) || + !cst_effort_valid(effort->valuestring))) + bad = "推理强度只能是 low / medium / high / max"; + int a = 0; + if (!bad && approval && (!cJSON_IsString(approval) || + cst_approval_parse(approval->valuestring, &a) != 0)) + bad = "审批模式只能是 ask / auto / yolo"; + if (!bad && plan && !cJSON_IsBool(plan)) + bad = "plan_mode 必须是 true 或 false"; + if (bad) { + cJSON_Delete(j); + return cst_http_send_err(c, 400, bad); + } /* the strings are read by the turn as it builds each request; approval * and plan mode may change mid-turn (say, to yolo at a pending gate) */ - int claimed = cJSON_GetObjectItem(j, "model") || - cJSON_GetObjectItem(j, "provider") || - cJSON_GetObjectItem(j, "effort"); + int claimed = model || effort; if (claimed && wui_claim(w) != 0) { cJSON_Delete(j); - return cst_http_send_err(c, 409, "busy: wait for the running turn"); + return cst_http_send_err(c, 409, "正在运行,等这一轮结束再改模型"); } - const cJSON *item; - if ((item = cJSON_GetObjectItem(j, "model")) && cJSON_IsString(item)) - snprintf(cfg->model, sizeof(cfg->model), "%s", item->valuestring); - if ((item = cJSON_GetObjectItem(j, "provider")) && cJSON_IsString(item)) - snprintf(cfg->provider, sizeof(cfg->provider), "%s", item->valuestring); - if ((item = cJSON_GetObjectItem(j, "effort")) && cJSON_IsString(item) && - cst_effort_valid(item->valuestring)) - snprintf(cfg->effort, sizeof(cfg->effort), "%s", item->valuestring); - if ((item = cJSON_GetObjectItem(j, "approval")) && cJSON_IsString(item)) { - int a = 0; - if (cst_approval_parse(item->valuestring, &a) == 0) { - cfg->approval = a; - cfg->auto_approve = a == CST_APPROVAL_YOLO; - } + if (model) + snprintf(cfg->model, sizeof(cfg->model), "%s", model->valuestring); + if (effort) + snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort->valuestring); + if (claimed) + cst_settings_remember(cfg); + if (approval) { + cfg->approval = a; + cfg->auto_approve = a == CST_APPROVAL_YOLO; + if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this run only */ + cst_settings_remember_approval(a); } - if ((item = cJSON_GetObjectItem(j, "plan_mode")) && cJSON_IsBool(item)) { - cfg->plan_mode = cJSON_IsTrue(item) ? 1 : 0; - cfg->read_only = cfg->plan_mode; + if (plan) + cfg->plan_mode = cJSON_IsTrue(plan) ? 1 : 0; + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddBoolToObject(o, "ok", 1); + if (effort && !cst_effort_wire(cfg->provider, cfg->model, cfg->effort)) + cJSON_AddStringToObject(o, "warning", "当前模型不接受推理强度参数:已保存," + "换到支持的模型时生效"); } cJSON_Delete(j); if (claimed) wui_release(w); wui_emit_state(w); - return cst_http_send_str(c, 200, "application/json; charset=utf-8", - "{\"ok\":true}"); + return wui_send_obj(c, 200, o); +} + +/* GET /api/providers: the presets, whether each has a key and from where. + * Never the key itself. */ +static int h_providers(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cst_agent_config_t *cfg = malloc(sizeof(*cfg)); + if (!cfg) + return cst_http_send_err(c, 500, "out of memory"); + if (wui_cfg_snapshot(w, cfg) != 0) { + free(cfg); + return cst_http_send_err(c, 409, "正在切换设置,稍后再试"); + } + cJSON *o = cJSON_CreateObject(); + if (!o) { + free(cfg); + return cst_http_send_err(c, 500, "out of memory"); + } + cJSON_AddStringToObject(o, "current", cfg->provider); + cJSON *arr = cJSON_AddArrayToObject(o, "providers"); + int n = cst_provider_count(); + for (int i = 0; arr && i < n; i++) { + const cst_provider_t *p = cst_provider_at(i); + cJSON *e = cJSON_CreateObject(); + if (!p || !e) { + cJSON_Delete(e); + continue; + } + int cur = strcmp(p->name, cfg->provider) == 0; + cJSON_AddStringToObject(e, "name", p->name); + cJSON_AddStringToObject(e, "api_base", cur ? cfg->api_base : p->api_base); + cJSON_AddStringToObject(e, "model", cur ? cfg->model + : (p->model ? p->model : "")); + char from[64]; + cJSON_AddBoolToObject(e, "has_key", + key_source(cfg, p->name, from, sizeof(from))); + cJSON_AddStringToObject(e, "key_from", from); + cJSON_AddStringToObject(e, "key_env", p->key_env ? p->key_env : ""); + cJSON_AddStringToObject(e, "source", cst_provider_source(p)); + cJSON_AddBoolToObject(e, "current", cur); + cJSON_AddItemToArray(arr, e); + } + memset(cfg->api_key, 0, sizeof(cfg->api_key)); + free(cfg); + return wui_send_obj(c, 200, o); +} + +/* GET /api/models[?refresh=1]: the picker list for the current provider. + * A rejected key is a 401 that still carries the built-in names. */ +static int h_models(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + const cst_http_req_t *req = cst_http_request(c); + char rf[8] = ""; + cst_http_query(req, "refresh", rf, sizeof(rf)); + cst_agent_config_t *snap = malloc(sizeof(*snap)); + int max = CST_PS_MAX_MODELS + 32; + cst_ps_model_t *list = calloc((size_t)max, sizeof(*list)); + if (!snap || !list) { + free(snap); + free(list); + return cst_http_send_err(c, 500, "out of memory"); + } + if (wui_cfg_snapshot(w, snap) != 0) { + free(snap); + free(list); + return cst_http_send_err(c, 409, "正在切换设置,稍后再试"); + } + int api_rc = -1; + char err[256] = ""; + int n = cst_ps_models(snap, rf[0] == '1', list, max, &api_rc, err, + sizeof(err)); + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddStringToObject(o, "provider", snap->provider); + cJSON_AddStringToObject(o, "current", snap->model); + cJSON_AddStringToObject(o, "api_base", snap->api_base); + cJSON_AddBoolToObject(o, "key_set", snap->api_key[0] != 0); + cJSON_AddNumberToObject(o, "api_count", api_rc >= 0 ? api_rc : -1); + if (api_rc == CST_MODELS_AUTH) { + char msg[400]; + snprintf(msg, sizeof(msg), "%s 拒绝了当前 API key(%.200s),请换一个", + snap->provider, err); + cJSON_AddStringToObject(o, "error", msg); + cJSON_AddBoolToObject(o, "auth", 1); + } else if (api_rc < 0 && err[0]) { + char msg[400]; + snprintf(msg, sizeof(msg), "拉取模型列表失败:%.300s", err); + cJSON_AddStringToObject(o, "api_error", msg); + } + cJSON *arr = cJSON_AddArrayToObject(o, "models"); + for (int i = 0; arr && i < n; i++) { + cJSON *e = cJSON_CreateObject(); + if (!e) + continue; + cJSON_AddStringToObject(e, "id", list[i].id); + cJSON_AddBoolToObject(e, "api", list[i].api); + model_info_json(e, snap->provider, list[i].id); + cJSON_AddItemToArray(arr, e); + } + } + memset(snap->api_key, 0, sizeof(snap->api_key)); + free(snap); + free(list); + return wui_send_obj(c, api_rc == CST_MODELS_AUTH ? 401 : 200, o); +} + +/* The answer to a key check / provider switch, in the browser's words. */ +static int ps_reply(wui_t *w, cst_http_conn_t *c, int r, const char *who, + const char *err) { + char msg[512]; + if (r == CST_PS_UNKNOWN) { + snprintf(msg, sizeof(msg), "没有这个提供商:%.64s", who); + return cst_http_send_err(c, 404, msg); + } + if (r == CST_MODELS_AUTH) { + snprintf(msg, sizeof(msg), "%.64s 拒绝了这个 API key(%.300s)", who, err); + return cst_http_send_err(c, 401, msg); + } + if (r < 0) { + cJSON *o = cJSON_CreateObject(); + if (o) { + snprintf(msg, sizeof(msg), "%.64s 需要 API key", who); + cJSON_AddStringToObject(o, "error", msg); + cJSON_AddBoolToObject(o, "need_key", 1); + } + return wui_send_obj(c, 400, o); + } + const cst_agent_config_t *cfg = cst_agent_config(w->agent); + cJSON *o = cJSON_CreateObject(); + if (o) { + cJSON_AddBoolToObject(o, "ok", 1); + cJSON_AddStringToObject(o, "provider", cfg->provider); + cJSON_AddStringToObject(o, "model", cfg->model); + msg[0] = 0; + if (r & CST_PS_UNSAVED) + snprintf(msg, sizeof(msg), "key 没能写进 credentials 文件,只在本次运行有效"); + else if (r & CST_PS_UNVERIFIED) + snprintf(msg, sizeof(msg), "没能验证 key(%.300s),已先保存", err); + if (msg[0]) + cJSON_AddStringToObject(o, "warning", msg); + } + wui_emit_state(w); + return wui_send_obj(c, 200, o); +} + +/* Wipe a secret out of a parsed body before it is freed. */ +static void wipe_str(const cJSON *s) { + if (cJSON_IsString(s) && s->valuestring) + memset(s->valuestring, 0, strlen(s->valuestring)); +} + +/* POST /api/provider {"name": "...", "key": "..."?} */ +static int h_provider_x(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cst_agent_config_t *cfg = cst_agent_config_mut(w->agent); + cJSON *j = wui_body(c); + const cJSON *name = j ? cJSON_GetObjectItem(j, "name") : NULL; + const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL; + if (!cJSON_IsString(name) || (key && !cJSON_IsString(key))) { + wipe_str(key); + cJSON_Delete(j); + return cst_http_send_err(c, 400, "需要 {\"name\": \"提供商\", \"key\": 可选}"); + } + char who[64], err[256] = ""; + snprintf(who, sizeof(who), "%s", name->valuestring); + int r = cst_ps_switch_provider(cfg, who, key ? key->valuestring : NULL, err, + sizeof(err)); + wipe_str(key); + cJSON_Delete(j); + if (r >= 0) { + char line[256]; + snprintf(line, sizeof(line), "提供商 → %s · 模型 %s", cfg->provider, + cfg->model[0] ? cfg->model : "(未选)"); + wui_emit_str(w, "status", "message", line); + } + return ps_reply(w, c, r, who, err); +} + +/* POST /api/key {"key": "..."}: replace the current provider's key. */ +static int h_key_x(cst_http_conn_t *c, void *ud) { + wui_t *w = ud; + cst_agent_config_t *cfg = cst_agent_config_mut(w->agent); + cJSON *j = wui_body(c); + const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL; + if (!cJSON_IsString(key) || !key->valuestring[0]) { + wipe_str(key); + cJSON_Delete(j); + return cst_http_send_err(c, 400, "需要 {\"key\": \"...\"}"); + } + char err[256] = ""; + int r = cst_ps_set_key(cfg, key->valuestring, err, sizeof(err)); + wipe_str(key); + cJSON_Delete(j); + return ps_reply(w, c, r, cfg->provider, err); } static int h_undo_x(cst_http_conn_t *c, void *ud) { @@ -1140,6 +1490,8 @@ WUI_EXCLUSIVE(h_undo) WUI_EXCLUSIVE(h_compact) WUI_EXCLUSIVE(h_attach_upload) WUI_EXCLUSIVE(h_attach_clear) +WUI_EXCLUSIVE(h_provider) +WUI_EXCLUSIVE(h_key) static int h_quit(cst_http_conn_t *c, void *ud) { wui_t *w = ud; @@ -1360,6 +1712,10 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { {"POST", "/api/permission", h_permission, NULL, 0, 0}, {"POST", "/api/ask_user", h_ask_user, NULL, 0, 0}, {"POST", "/api/config", h_config, NULL, 0, 0}, + {"GET", "/api/providers", h_providers, NULL, 0, 0}, + {"GET", "/api/models", h_models, NULL, 0, 0}, + {"POST", "/api/provider", h_provider, NULL, 0, 0}, + {"POST", "/api/key", h_key, NULL, 0, 0}, {"POST", "/api/undo", h_undo, NULL, 0, 0}, {"POST", "/api/compact", h_compact, NULL, 0, 0}, {"POST", "/api/attach", h_attach_upload, NULL, 0, 32u * 1024u * 1024u}, diff --git a/tests/test_webui.c b/tests/test_webui.c index f1d08ac..99c25d3 100644 --- a/tests/test_webui.c +++ b/tests/test_webui.c @@ -280,8 +280,38 @@ static void test_sanitize(void) { } } +/* Streamed deltas cut anywhere must reach the browser as whole characters. */ +static void test_utf8_hold(void) { + const char *src = "思考:先读 main.c,再改 😀 emoji。"; + size_t len = strlen(src); + for (size_t step = 1; step <= 5; step++) { + cst_utf8_hold_t h = {0}; + cst_buf_t out = {0}; + for (size_t i = 0; i < len; i += step) { + size_t n = len - i < step ? len - i : step; + size_t got = 0; + char *piece = cst_utf8_hold_feed(&h, src + i, n, &got); + CHECK(piece != NULL); + CHECK(h.n < 4); + CHECK(cst_utf8_valid(piece, got)); + CHECK(cst_buf_append(&out, piece, got) == 0); + free(piece); + } + CHECK(h.n == 0); + CHECK(out.len == len && memcmp(out.buf, src, len) == 0); + cst_buf_free(&out); + } + /* a stray continuation byte is not held forever */ + cst_utf8_hold_t h = {0}; + size_t got = 0; + char *piece = cst_utf8_hold_feed(&h, "\x80\x80\x80\x80", 4, &got); + CHECK(piece && got == 4 && h.n == 0); + free(piece); +} + int main(void) { CHECK(os_net_init() == 0); + test_utf8_hold(); test_query(); test_sanitize(); test_http(); diff --git a/web/app.js b/web/app.js index 4aafbd9..4ff5d6f 100644 --- a/web/app.js +++ b/web/app.js @@ -24,21 +24,61 @@ 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(); +/* the server answers errors as {"error": "..."}; status and body ride along + * so a caller can tell "needs a key" from "rejected key" */ +class ApiError extends Error { + constructor(msg, status, data) { super(msg); this.status = status; this.data = data || {}; } } -async function post(path, body) { +async function readReply(r) { + const text = await r.text(); + let data = null; + try { data = text ? JSON.parse(text) : {}; } catch { data = null; } + if (!r.ok) throw new ApiError(errorText(r.status, (data && data.error) || text || r.statusText), r.status, data); + return data === null ? text : data; +} +async function getJSON(path) { + return readReply(await api(path)); +} +async function post(path, body, method) { const r = await api(path, { - method: 'POST', + method: 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) : {}; + return readReply(r); } +/* engine messages that are still English, in the words the page uses */ +const ERR_ZH = [ + [/^busy: wait for the running turn/, '正在运行,等这一轮结束再试'], + [/^a turn is already running/, '已经有一轮在运行'], + [/^another change is running/, '另一项修改正在进行,稍后再试'], + [/^nothing to undo/, '没有可以撤销的写入'], + [/^no such session/, '找不到这个会话'], + [/^no such permission request/, '这个许可请求已经结束'], + [/^no such question/, '这个问题已经结束'], + [/^unauthorized|^forbidden/i, '没有权限:请带上 token 打开页面'], + [/^out of memory/, '服务器内存不足'], + [/^path is required/, '需要填写路径'], + [/^empty body/, '内容为空'], +]; +function errorText(status, msg) { + msg = String(msg || ''); + for (const [re, zh] of ERR_ZH) if (re.test(msg)) return zh; + if (status === 401 && !msg) return '没有权限:请带上 token 打开页面'; + return msg || ('HTTP ' + status); +} +const errMsg = (e) => (e && e.message) || String(e); +let toastTimer = null; +function toast(msg, kind) { + const t = document.getElementById('toast'); + if (!t) return; + t.textContent = String(msg); + t.className = 'toast ' + (kind || 'err'); + t.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { t.hidden = true; }, kind === 'ok' ? 2500 : 6000); +} +const toastErr = (e) => toast(errMsg(e), 'err'); const $ = (id) => document.getElementById(id); const el = (tag, cls, text) => { @@ -466,7 +506,9 @@ function applyState(s) { 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 : ''); + $('chip-model').title = model + (s.effort ? ' · 推理强度 ' + s.effort : '') + + (s.key_set === false ? ' · 还没有 API key' : ''); + $('chip-model').classList.toggle('warn', s.key_set === false); const ap = $('chip-approval'); const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto')); ap.querySelector('.lbl').textContent = (cur ? cur.title : s.approval || 'auto'); @@ -507,10 +549,12 @@ function applyState(s) { const x = el('button', 'icon tiny'); x.appendChild(icon('close')); x.title = '清空待发图片'; - x.onclick = () => api('api/attach', { method: 'DELETE' }).catch(() => {}); + x.onclick = () => post('api/attach', undefined, 'DELETE').catch(toastErr); chip.appendChild(x); p.appendChild(chip); } + if (s.sees_images === 0) + p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发')); } renderTodo(s.todo); } @@ -545,7 +589,7 @@ function connect() { on('session', (d) => { closePop(); live = null; steps = null; stepIndex = new Map(); - loadSession().then(loadSessions).catch(() => {}); + loadSession().then(loadSessions).catch(toastErr); closeDrawers(); if (d && d.id) $('status').textContent = '已切换会话'; }); @@ -557,7 +601,7 @@ function connect() { 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(() => {}); + loadSession().then(loadSessions).catch(toastErr); }); /* the browser resumes from Last-Event-ID; a restarted server has none of * those events, so its state (and maybe session) is fetched afresh */ @@ -570,8 +614,8 @@ function connect() { const was = state.session; getJSON('api/state').then((s) => { applyState(s); - if (s.session !== was) loadSession().then(loadSessions).catch(() => {}); - }).catch(() => {}); + if (s.session !== was) loadSession().then(loadSessions).catch(toastErr); + }).catch(toastErr); }; } @@ -600,7 +644,7 @@ function showGate(ev) { } $('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。'; $('status').textContent = '等待你的许可…'; - let left = 600; + let left = ev.timeout || state.perm_timeout || 600; clearInterval(gateTimer); const tick = () => { $('gate-timer').textContent = left >= 0 ? left + 's' : ''; @@ -626,7 +670,7 @@ async function answerGate(value) { await post('api/permission', { id, answer: value }); $('status').textContent = value ? '已允许,继续执行…' : '已拒绝'; } catch (e) { - $('status').textContent = '回答失败:' + (e.message || e); + toast('回答失败:' + errMsg(e)); } } window.addEventListener('keydown', (e) => { @@ -642,7 +686,7 @@ window.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (!$('modal').hidden) return closeModal(); if (!$('pop').hidden) return closePop(); - if (busy) return void post('api/cancel', {}).catch(() => {}); + if (busy) return void post('api/cancel', {}).catch(toastErr); } if (e.ctrlKey && e.key.toLowerCase() === 'b') { e.preventDefault(); toggleSide(); } if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); } @@ -668,7 +712,7 @@ function showAsk(ev) { 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(() => {}); + post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(toastErr); closeModal(); }; const row = el('div', 'buttons'); @@ -693,7 +737,7 @@ function timeLabel(id) { } async function loadSessions() { let data; - try { data = await getJSON('api/sessions'); } catch { return; } + try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; } sessionsCache = data.sessions || []; const box = $('sessions-list'); box.textContent = ''; @@ -707,10 +751,12 @@ async function loadSessions() { 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(); + try { + await post('api/sessions/switch', { id: s.id }); + await loadSession(); + await loadSessions(); + closeDrawers(); + } catch (e) { toastErr(e); } }; box.appendChild(b); } @@ -737,10 +783,12 @@ async function showSessions() { 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(); + try { + await post('api/sessions/switch', { id: s.id }); + closeModal(); + await loadSession(); + await loadSessions(); + } catch (e) { toastErr(e); } }, })); box.appendChild(row); @@ -748,10 +796,12 @@ async function showSessions() { const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', 'ok', '新建会话'), { onclick: async () => { - await post('api/sessions/new', {}); - closeModal(); - await loadSession(); - await loadSessions(); + try { + await post('api/sessions/new', {}); + closeModal(); + await loadSession(); + await loadSessions(); + } catch (e) { toastErr(e); } }, })); row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); @@ -761,40 +811,245 @@ async function showSessions() { /* --- pickers as popovers ------------------------------------------------- */ +/* "1M" / "200K" for a context window in tokens */ +function fmtCtx(n) { + if (!n) return ''; + if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + 'M'; + return Math.round(n / 1000) + 'K'; +} +function modelDesc(m) { + const parts = []; + if (m.context) parts.push(fmtCtx(m.context) + ' 上下文'); + if (m.vision === true) parts.push('识图'); + else if (m.vision === false) parts.push('纯文本'); + if (m.reasoning) parts.push('推理强度'); + if (m.price) parts.push((m.price.currency || '') + m.price.miss + '/' + m.price.out + ' 每百万'); + parts.push(m.api ? '提供商列出' : '内置'); + return parts.join(' · '); +} +let modelsCache = null; /* {provider, data} of the last GET api/models */ +async function fetchModels(refresh) { + const r = await api('api/models' + (refresh ? '?refresh=1' : '')); + let d = null; + try { d = await readReply(r); } catch (e) { + /* 401 = the key was rejected: the body still has the built-in list */ + if (e.status !== 401) throw e; + d = e.data || {}; + } + modelsCache = { provider: d.provider, data: d }; + return d; +} +function setModel(id) { + post('api/config', { model: id }) + .then(() => { toast('模型 → ' + id, 'ok'); }) + .catch(toastErr); +} function modelPopover(anchor) { openPop(anchor, (pop) => { - pop.appendChild(el('div', 'pop-title', '模型')); + const head = el('div', 'pop-title pop-head'); + head.appendChild(el('span', null, '模型 · ' + (state.provider || '-'))); + const rb = el('button', 'icon tiny'); + rb.title = '从提供商重新拉取模型列表'; + rb.appendChild(icon('refresh')); + head.appendChild(rb); + pop.appendChild(head); const row = el('div', 'pop-input'); const input = el('input'); - input.value = state.model || ''; - input.placeholder = '模型名'; + input.placeholder = '搜索,或直接输入模型名'; + input.spellcheck = false; row.appendChild(input); - 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(); }); + const note = el('div', 'pop-note'); + pop.appendChild(note); + const list = el('div', 'pop-list'); + pop.appendChild(list); + let data = (modelsCache && modelsCache.provider === state.provider) ? modelsCache.data : null; + let loading = false; + const pick = (id) => { closePop(); if (id && id !== state.model) setModel(id); }; + const render = () => { + list.textContent = ''; + const q = input.value.trim(); + const ql = q.toLowerCase(); + const ms = (data && data.models) || []; + const shown = ms.filter((m) => !ql || m.id.toLowerCase().includes(ql)).slice(0, 300); + for (const m of shown) + list.appendChild(popRow({ title: m.id, desc: modelDesc(m), on: m.id === state.model, onClick: () => pick(m.id) })); + if (q && !ms.some((m) => m.id === q)) + list.appendChild(popRow({ icon: 'plus', title: '使用「' + q + '」', desc: '不在列表里也可以直接用', onClick: () => pick(q) })); + if (!list.children.length) + list.appendChild(el('div', 'pop-empty', loading ? '加载中…' : '没有匹配的模型')); + }; + const setNote = () => { + note.textContent = ''; + note.className = 'pop-note'; + if (loading) { note.textContent = '正在向 ' + (state.provider || '提供商') + ' 拉取模型列表…'; return; } + if (!data) return; + if (data.auth) { + note.classList.add('bad'); + note.appendChild(el('span', null, data.error || 'API key 被拒绝')); + const fix = el('button', 'link', '更换 key'); + fix.onclick = (e) => { e.stopPropagation(); closePop(); changeKey(); }; + note.appendChild(fix); + } else if (data.api_error) { + note.classList.add('warn'); + note.textContent = data.api_error + ';下面是内置列表'; + } else if (!data.key_set) { + note.classList.add('warn'); + note.textContent = '还没有 API key,只能显示内置列表'; + } else if (data.api_count >= 0) { + note.textContent = data.provider + ' 列出 ' + data.api_count + ' 个模型'; + } + }; + const load = (refresh) => { + loading = true; setNote(); render(); + fetchModels(refresh) + .then((d) => { data = d; }) + .catch((e) => { data = data || { models: [] }; toastErr(e); }) + .finally(() => { loading = false; setNote(); render(); }); + }; + rb.onclick = (e) => { e.stopPropagation(); load(true); }; + input.addEventListener('input', render); + input.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + const q = input.value.trim(); + const ms = (data && data.models) || []; + const hit = ms.filter((m) => m.id.toLowerCase().includes(q.toLowerCase())); + pick(hit.length === 1 ? hit[0].id : q); + }); + if (data) { setNote(); render(); } else load(false); + pop.appendChild(el('div', 'pop-sep')); pop.appendChild(el('div', 'pop-title', '推理强度')); + if (state.effort_applies === false) + pop.appendChild(el('div', 'pop-note warn', '当前模型不接受推理强度参数:可以先选好,换到支持的模型时生效')); const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' }; - for (const e2 of EFFORTS) - pop.appendChild(popRow({ + for (const e2 of EFFORTS) { + const r = 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(() => {}), - })); + onClick: () => post('api/config', { effort: e2.key }) + .then((d) => { if (d && d.warning) toast(d.warning, 'warn'); }) + .catch(toastErr), + }); + if (state.effort_applies === false) r.classList.add('dim'); + pop.appendChild(r); + } pop.appendChild(el('div', 'pop-sep')); pop.appendChild(popRow({ - icon: 'settings', - title: 'provider:' + (state.provider || '-'), - desc: '在设置里改连接端点', - onClick: showSettings, + icon: 'cpu', + title: '提供商:' + (state.provider || '-'), + desc: (state.api_base || '') + ' · 点这里切换', + onClick: showProviders, })); setTimeout(() => input.focus(), 0); }); } + +/* --- providers and keys -------------------------------------------------- */ + +function keyFromText(p) { + if (!p.has_key) return '未设置 key'; + if (p.key_from === 'saved') return 'key 已保存'; + if (p.key_from === 'session') return 'key 仅本次运行'; + return 'key 来自环境变量 ' + p.key_from; +} +/* one password field; submit(key) returns a promise, a rejection stays in + * the dialog so the user can paste another key */ +function keyDialog(title, sub, submit) { + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, title)); + if (sub) box.appendChild(el('div', 'muted tiny', sub)); + box.appendChild(el('div', 'muted tiny', 'key 会先用 /models 验证,再保存到配置目录的 credentials 文件(仅你可读),不会出现在页面、日志或事件里。')); + const input = el('input', 'key-input'); + input.type = 'password'; + input.autocomplete = 'off'; + input.placeholder = 'sk-…'; + box.appendChild(input); + const err = el('div', 'key-err'); + box.appendChild(err); + const row = el('div', 'buttons'); + const ok = el('button', 'ok', '验证并保存'); + const go = async () => { + const k = input.value.trim(); + if (!k) { err.textContent = '请先粘贴 key'; return; } + ok.disabled = true; + err.textContent = '验证中…'; + try { + await submit(k); + input.value = ''; + closeModal(); + } catch (e) { + err.textContent = errMsg(e); + ok.disabled = false; + input.select(); + } + }; + ok.onclick = go; + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); }); + row.appendChild(ok); + row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); + box.appendChild(row); + openModal(box); + setTimeout(() => input.focus(), 0); +} +function afterSwitch(r) { + modelsCache = null; + toast('已切换到 ' + r.provider + (r.model ? ' · ' + r.model : '(请选一个模型)'), 'ok'); + if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200); +} +function askKeyFor(p) { + keyDialog('API key · ' + p.name, p.api_base, async (key) => { + afterSwitch(await post('api/provider', { name: p.name, key })); + }); +} +async function switchProvider(p) { + if (!p.has_key) return askKeyFor(p); + try { + closeModal(); + afterSwitch(await post('api/provider', { name: p.name })); + } catch (e) { + if (e.data && e.data.need_key) return askKeyFor(p); + toastErr(e); + } +} +async function showProviders() { + let d; + try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); } + const box = document.createDocumentFragment(); + box.appendChild(el('h3', null, '切换提供商')); + for (const p of d.providers || []) { + const row = el('div', 'prov' + (p.current ? ' on' : '')); + const m = el('div', 'pm'); + m.appendChild(el('b', null, p.name + (p.current ? ' · 当前' : ''))); + m.appendChild(el('span', 'muted tiny', p.api_base)); + m.appendChild(el('span', 'tiny ' + (p.has_key ? 'ok-text' : 'warn-text'), + keyFromText(p) + ' · ' + (p.model || '模型从 API 获取') + (p.source === 'user' ? ' · models.json' : ''))); + row.appendChild(m); + const acts = el('div', 'prov-acts'); + if (p.current) + acts.appendChild(Object.assign(el('button', null, '换 key'), { onclick: changeKey })); + else + acts.appendChild(Object.assign(el('button', 'ok', p.has_key ? '切换' : '设 key 并切换'), { + onclick: () => switchProvider(p), + })); + row.appendChild(acts); + box.appendChild(row); + } + const row = el('div', 'buttons'); + row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); + box.appendChild(row); + openModal(box); +} +function changeKey() { + keyDialog('更换 API key · ' + (state.provider || ''), state.api_base || '', async (key) => { + const r = await post('api/key', { key }); + modelsCache = null; + toast('key 已验证并保存', 'ok'); + if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200); + }); +} function approvalPopover(anchor) { openPop(anchor, (pop) => { pop.appendChild(el('div', 'pop-title', '审批权限')); @@ -805,7 +1060,7 @@ function approvalPopover(anchor) { desc: a.desc, on: a.key === (state.approval || 'auto'), danger: a.key === 'yolo', - onClick: () => post('api/config', { approval: a.key }).catch(() => {}), + onClick: () => post('api/config', { approval: a.key }).catch(toastErr), })); }, { align: 'right' }); } @@ -835,7 +1090,7 @@ function plusPopover(anchor) { pop.appendChild(popRow({ icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件', on: !!state.plan_mode, - onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(() => {}), + onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(toastErr), })); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(el('div', 'pop-title', '工作目录')); @@ -856,7 +1111,7 @@ function plusPopover(anchor) { await loadSessions(); $('status').textContent = '工作目录已切换'; } catch (e) { - $('status').textContent = String(e.message || e); + toastErr(e); wgo.disabled = false; } }; @@ -877,11 +1132,16 @@ function usagePopover(anchor) { }; kv('工作区', state.workspace || '-'); kv('会话', state.session || '-'); - kv('上下文占用', num(state.context_tokens) + ' tok'); + const mi = state.model_info || {}; + kv('上下文占用', num(state.context_tokens) + (mi.context ? ' / ' + fmtCtx(mi.context) : '') + ' 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 || '-'); + const t = state.turn_usage || {}; + if (t.requests) + kv('上一轮', t.requests + ' 往返 · ' + num(t.prompt_tokens) + ' / ' + num(t.completion_tokens) + + ((t.priced && t.cost) ? ' · ' + (t.currency || '') + t.cost.toFixed(4) : '')); + kv('工具协议', state.protocol_tools || '-'); 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 })); @@ -898,7 +1158,7 @@ function menuPopover(anchor) { })); 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(() => {}) })); + pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).then(() => toast('服务已退出', 'ok')).catch(toastErr) })); }, { align: 'right' }); } function showSettings() { @@ -912,10 +1172,17 @@ function showSettings() { }; kv('目录', state.workspace); kv('会话', state.session); - kv('模型', (state.provider ? state.provider + ' · ' : '') + state.model); - kv('协议', state.protocol); + kv('提供商', state.provider); + kv('端点', state.api_base); + kv('API key', keyFromText({ has_key: state.key_set, key_from: state.key_from })); + kv('模型', state.model); + kv('工具协议', state.protocol_tools); + kv('工具集', state.tool_profile); + kv('每轮最多步数', String(state.max_steps || '-')); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) })); + pop.appendChild(popRow({ icon: 'refresh', title: '切换提供商', desc: '换端点,需要时输入 key', onClick: showProviders })); + pop.appendChild(popRow({ icon: 'shield', title: '更换 API key', desc: '验证后保存,只对当前提供商', onClick: changeKey })); pop.appendChild(popRow({ icon: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) })); pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); }); @@ -935,11 +1202,11 @@ function syncSend() { async function compact() { $('status').textContent = '压缩中…'; try { await post('api/compact', {}); $('status').textContent = '上下文已压缩'; } - catch (e) { $('status').textContent = '压缩失败:' + (e.message || e); } + catch (e) { $('status').textContent = '就绪'; toast('压缩失败:' + errMsg(e)); } } async function undo() { try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; } - catch (e) { $('status').textContent = '撤销失败:' + (e.message || e); } + catch (e) { toast('撤销失败:' + errMsg(e)); } } async function send(text) { const input = $('input'); @@ -959,7 +1226,7 @@ async function send(text) { await post('api/turn', { text: body }); $('status').textContent = '已开始…'; } catch (e) { - bubble('error').body.textContent = String(e.message || e); + bubble('error').body.textContent = errMsg(e); } } function autoGrow() { @@ -973,7 +1240,8 @@ async function upload(file) { headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, }); - if (!r.ok) $('status').textContent = '上传失败:' + (await r.text()); + try { await readReply(r); } catch (e) { toast('上传失败:' + errMsg(e)); return; } + if (state.sees_images === 0) toast((state.model || '当前模型') + ' 不识图,这张图不会发给模型', 'warn'); } /* --- layout -------------------------------------------------------------- */ @@ -1010,9 +1278,9 @@ function toggleTheme() { /* --- wiring -------------------------------------------------------------- */ $('btn-send').onclick = () => send(); -$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {}); +$('btn-stop').onclick = () => post('api/cancel', {}).catch(toastErr); $('btn-new').onclick = async () => { - try { await post('api/sessions/new', {}); } catch (e) { $('status').textContent = String(e.message || e); } + try { await post('api/sessions/new', {}); } catch (e) { toastErr(e); } await loadSession(); await loadSessions(); closeDrawers(); @@ -1035,8 +1303,10 @@ $('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' ? '计划模式:只读' : '构建模式:可写'; + try { + await post('api/config', { plan_mode: b.dataset.mode === 'plan' }); + $('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写'; + } catch (e) { toastErr(e); } }; } $('input').addEventListener('keydown', (e) => { @@ -1075,9 +1345,9 @@ autoGrow(); syncSend(); renderFilesPanel(); connect(); -loadSession().catch((e) => { $('status').textContent = String(e.message || e); }); +loadSession().catch(toastErr); loadSessions(); -getJSON('api/state').then(applyState).catch(() => {}); +getJSON('api/state').then(applyState).catch(toastErr); /* 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'); diff --git a/web/index.html b/web/index.html index a376bce..a1c9676 100644 --- a/web/index.html +++ b/web/index.html @@ -208,6 +208,7 @@
+