1 Commits
Author SHA1 Message Date
WHC 2d1052fd5f v0.8.3: WebUI models, providers and keys; garbled thinking fixed
ci / linux (clang) (push) Successful in 5m56s
ci / linux (gcc) (push) Successful in 8m38s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 9m21s
Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

src/provider_switch.c: model list fetch/cache, key verify-and-save and
provider switching moved out of tui.c and shared by TUI and WebUI.

WebUI API:
- GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected
  key, still listing built-ins), POST /api/provider, POST /api/key
  (key checked against /v1/models, saved to credentials 0600, never
  echoed in replies, events or logs)
- /api/config validates everything before applying, persists model,
  effort and approval (not yolo), warns when effort does not apply;
  plan_mode no longer forces read-only
- state: turn_usage, api_base, key_set/key_from, model_info,
  effort_applies, sees_images, max_steps, tool_profile, perm_timeout;
  permission events carry their timeout

Frontend: searchable model picker with refresh and manual entry,
provider picker and key dialog, effort/vision hints, toasts instead of
silent failures, Chinese error text, real protocol in the settings and
usage panels, approval countdown from the server's timeout.

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
2026-10-09 15:54:53 +08:00
18 changed files with 1553 additions and 182 deletions
+1
View File
@@ -111,6 +111,7 @@ set(CST_CORE_SOURCES
src/agent_store.c src/agent_store.c
src/json_baseline.c src/json_baseline.c
src/creds.c src/creds.c
src/provider_switch.c
src/mm_tool.c src/mm_tool.c
src/models.c src/models.c
src/model_registry.c src/model_registry.c
+1 -1
View File
@@ -91,7 +91,7 @@ CORE_SRC = \
src/agent_loop.c \ src/agent_loop.c \
src/agent_store.c \ src/agent_store.c \
src/json_baseline.c \ src/json_baseline.c \
src/creds.c \ src/creds.c src/provider_switch.c \
src/mm_tool.c \ src/mm_tool.c \
src/models.c src/model_registry.c \ src/models.c src/model_registry.c \
src/metrics.c \ src/metrics.c \
+20 -5
View File
@@ -91,7 +91,7 @@ GET /api/events?since=<seq> → text/event-stream (chunked)
| `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`done` | | `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`done` |
| `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` | | `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` |
| `todo` | `text` | `todo_write` 替换后的清单(一行一项) | | `todo` | `text` | `todo_write` 替换后的清单(一行一项) |
| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox` | 需要回答(见下) | | `permission` | `id`,`tool`,`detail`,`preview`,`sandbox`,`timeout` | 需要回答(见下);`timeout` 秒后按拒绝处理 |
| `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) | | `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) |
| `ask_user` | `id`,`question`,`options[]` | 需要回答 | | `ask_user` | `id`,`question`,`options[]` | 需要回答 |
| `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` | | `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` |
@@ -100,9 +100,16 @@ GET /api/events?since=<seq> → text/event-stream (chunked)
`state` 字段:`workspace` `session` `provider` `model` `effort` `approval` `state` 字段:`workspace` `session` `provider` `model` `effort` `approval`
(`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo` (`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo`
`context_tokens` `undo_available` `pending_images[]` `usage{requests,prompt_tokens, `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 统计,前端用它算命中率。 `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/cancel` | `{}` —— 取消当前轮(不会杀进程);挂起的权限问题按拒绝、提问按忽略处理 |
| POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix | | 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/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/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
| POST | `/api/compact` | 手动压缩上下文 | | POST | `/api/compact` | 手动压缩上下文 |
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤32 MiB),进入"待发送"列表 | | 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`。 未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`workspace`、 轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`workspace`、
`undo`、`compact`、`attach`(上传/清空)以及改 `model/provider/effort` 的 `config` `undo`、`compact`、`attach`(上传/清空)、`provider`、`key` 以及改 `model/effort` 的
一律 **409**;`approval`/`plan_mode` 可以中途改。 `config` 一律 **409**;`approval`/`plan_mode` 可以中途改。
key 只出现在请求体里:回复、事件、日志都不会带它。`/api/provider` 与 `/api/key`
跟其它 `/api/*` 一样受 loopback 同源检查或 token 保护。
### 客户端接入示例(10 行) ### 客户端接入示例(10 行)
@@ -176,6 +190,7 @@ TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目
```bash ```bash
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒) make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key 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 sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl ctl
``` ```
+79
View File
@@ -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 <config>/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/<provider>.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
+11
View File
@@ -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); char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len);
int cst_utf8_valid(const char *s, size_t n); 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), /* 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). */ * 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); size_t cst_bom_len(const char *s, size_t n);
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 8 #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) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+3
View File
@@ -282,6 +282,9 @@ if command -v python3 > /dev/null; then
echo "== HTTP hardening" echo "== HTTP hardening"
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_http.py" > "$WORK/http.txt" 2>&1 || CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_http.py" > "$WORK/http.txt" 2>&1 ||
{ cat "$WORK/http.txt"; fail "the HTTP hardening checks"; } { 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 fi
echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))" echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))"
+313
View File
@@ -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)
+65
View File
@@ -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))
+24 -9
View File
@@ -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) { static void feed_visible(turn_t *t, const char *data, size_t len) {
if (!len || t->stop) /* cut after each '\n' and '>': a call only becomes ready there, and
return; * raw_mark must not take in what follows it in the same chunk */
cst_buf_append(&t->raw, data, len); while (len && !t->stop) {
if (t->ctl) size_t n = 0;
ctl_parser_feed(t->ctl, data, len); while (n < len && data[n] != '\n' && data[n] != '>')
else if (t->mm) n++;
mm_scan_feed(t->mm, data, len); 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) { 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 ? "</think>" : "<think>"; const char *want = t->in_think ? "</think>" : "<think>";
size_t wl = strlen(want); size_t wl = strlen(want);
if (t->taglen == 0 && c != '<') { 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) if (t->in_think)
think_out(t, &c, 1); think_out(t, data + i, j - i);
else else
feed_visible(t, &c, 1); feed_visible(t, data + i, j - i);
i = j - 1;
continue; continue;
} }
t->tagbuf[t->taglen++] = c; t->tagbuf[t->taglen++] = c;
+193
View File
@@ -0,0 +1,193 @@
#include "cst_provider_switch.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}
+13 -61
View File
@@ -11,6 +11,7 @@
#include "cst_mcp.h" #include "cst_mcp.h"
#include "cst_models.h" #include "cst_models.h"
#include "cst_plugins.h" #include "cst_plugins.h"
#include "cst_provider_switch.h"
#include "cst_skills.h" #include "cst_skills.h"
#include "cst_subagent.h" #include "cst_subagent.h"
#include "cst_textsel.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); pthread_mutex_unlock(&g_am_mu);
} }
/* GET /models; a good answer also refreshes models-cache/<provider>.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) { static void *probe_thread(void *arg) {
probe_arg_t *a = arg; probe_arg_t *a = arg;
static char ids[AM_MAX][128]; /* one probe runs per provider switch */ static char ids[AM_MAX][128]; /* one probe runs per provider switch */
char err[256]; 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)); sizeof(err));
if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache) if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache)
am_store(a->provider, r, ids, err); am_store(a->provider, r, ids, err);
@@ -3937,23 +3903,13 @@ static void *probe_thread(void *arg) {
return NULL; 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) { static void start_probe(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent); const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg || !cfg->api_key[0] || g_am_threads > 0) if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
return; return;
static char ids[AM_MAX][128]; static char ids[AM_MAX][128];
int fresh = 0; 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); pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu); 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; return 0;
char dir[1024] = "the config directory"; char dir[1024] = "the config directory";
os_config_dir(dir, sizeof(dir)); 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]; char title[128], hint[1300];
snprintf(title, sizeof(title), "API key for %s", provider); snprintf(title, sizeof(title), "API key for %s", provider);
snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you " snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you "
"can read it)", "can read it)",
base, dir); base, dir);
char note[300] = ""; char note[300] = "";
int flags;
for (;;) { for (;;) {
if (secret_input_run(t, title, hint, out, n) != 0) if (secret_input_run(t, title, hint, out, n) != 0)
return -1; return -1;
@@ -4053,8 +4010,10 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
draw_status(t); draw_status(t);
static char ids[AM_MAX][128]; static char ids[AM_MAX][128];
char err[256]; char err[256];
int r = fetch_models(provider, base, out, ids, AM_MAX, err, sizeof(err)); int r = -1;
if (r == CST_MODELS_AUTH) { 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 " snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
"another one", "another one",
base, err); 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); snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider);
pthread_mutex_unlock(&g_am_mu); pthread_mutex_unlock(&g_am_mu);
am_store(provider, r, ids, err); am_store(provider, r, ids, err);
if (r < 0) if (flags & CST_PS_UNVERIFIED)
snprintf(note, sizeof(note), "could not verify the key (%.200s); " snprintf(note, sizeof(note), "could not verify the key (%.200s); "
"saved anyway", "saved anyway",
err); err);
break; 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 " set_status(t, "warning: could not save the key; it is used for this run "
"only"); "only");
else if (note[0]) 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…"); set_status(t, "fetching the model list…");
draw_status(t); draw_status(t);
char err[256]; 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)); AM_MAX, err, sizeof(err));
pthread_mutex_lock(&g_am_mu); pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); 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); push_system_line(t, "(provider unchanged: %s needs an API key)", p->name);
return; return;
} }
cst_settings_t s; cst_ps_apply_provider(cfg, p, key);
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);
memset(key, 0, sizeof(key)); memset(key, 0, sizeof(key));
cst_settings_remember(cfg);
push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base, push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base,
cfg->model); cfg->model);
if (am_models(p->name, NULL, 0) < 0) if (am_models(p->name, NULL, 0) < 0)
+20
View File
@@ -99,6 +99,26 @@ size_t cst_utf8_floor(const char *s, size_t n) {
return i - 1; /* sequence incomplete: cut before lead byte */ 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) { static size_t utf8_seq_ok(const unsigned char *p, size_t left) {
unsigned char c = p[0]; unsigned char c = p[0];
size_t need; size_t need;
+395 -39
View File
@@ -15,6 +15,9 @@
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_http.h" #include "cst_http.h"
#include "cst_models.h"
#include "cst_provider_switch.h"
#include "cst_tools.h"
#include "cst_util.h" #include "cst_util.h"
#include "cst_web_md.h" #include "cst_web_md.h"
#include "cst_webui.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 */ uint64_t pending_seq; /* event of the open permission/ask_user, 0 = none */
char ask_ans[WUI_ASK_MAX]; 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; volatile int stopping;
} wui_t; } 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); 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 --------------------------------------------------------------- */ /* --- 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) { static cJSON *wui_state_new(wui_t *w) {
const cst_agent_config_t *cfg = cst_agent_config(w->agent); const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cJSON *o = cJSON_CreateObject(); cJSON *o = cJSON_CreateObject();
@@ -188,16 +266,29 @@ static cJSON *wui_state_new(wui_t *w) {
cJSON_AddBoolToObject(o, "undo_available", cJSON_AddBoolToObject(o, "undo_available",
cst_agent_undo_available(w->agent) != 0); cst_agent_undo_available(w->agent) != 0);
cJSON *u = cJSON_AddObjectToObject(o, "usage"); cJSON *u = cJSON_AddObjectToObject(o, "usage");
if (u) { if (u)
cJSON_AddNumberToObject(u, "requests", sess.requests); usage_json(u, &sess);
cJSON_AddNumberToObject(u, "prompt_tokens", (double)sess.prompt); u = cJSON_AddObjectToObject(o, "turn_usage");
cJSON_AddNumberToObject(u, "completion_tokens", (double)sess.out); if (u)
cJSON_AddNumberToObject(u, "cached_tokens", (double)sess.cached); usage_json(u, &turn);
cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)sess.miss); cJSON_AddStringToObject(o, "api_base", cfg->api_base);
cJSON_AddNumberToObject(u, "cost", sess.cost); char from[64];
cJSON_AddBoolToObject(u, "priced", sess.priced != 0); cJSON_AddBoolToObject(o, "key_set",
cJSON_AddStringToObject(u, "currency", sess.currency); 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"); cJSON *imgs = cJSON_AddArrayToObject(o, "pending_images");
int n = cst_agent_pending_count(w->agent); int n = cst_agent_pending_count(w->agent);
for (int i = 0; i < n && imgs; i++) { 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); w->md = cst_web_md_new(wui_md_emit, w);
if (w->md) if (w->md)
cst_web_md_feed(w->md, text, len); 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) { 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) { 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; int has_client = w->subs != NULL;
pthread_mutex_unlock(&w->mu); 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(); cJSON *o = cJSON_CreateObject();
if (o) { if (o) {
cJSON_AddNumberToObject(o, "id", (double)id); 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, strncmp(detail, CST_PERM_SANDBOX,
strlen(CST_PERM_SANDBOX)) == strlen(CST_PERM_SANDBOX)) ==
0); 0);
cJSON_AddNumberToObject(o, "timeout", timeout);
wui_set_pending(w, wui_emit(w, "permission", o)); 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; int answer = CST_PERM_DENY;
struct timespec deadline; struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline); clock_gettime(CLOCK_REALTIME, &deadline);
@@ -765,7 +857,9 @@ static void *wui_turn_main(void *arg) {
hooks.userdata = w; hooks.userdata = w;
char err[512]; char err[512];
err[0] = 0; err[0] = 0;
wui_reset_carry(w);
int rc = cst_agent_run_turn_ex(w->agent, t->prompt, &hooks, err, sizeof(err)); 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); wui_end_turn(w, rc, err);
free(t->prompt); free(t->prompt);
free(t); free(t);
@@ -863,6 +957,46 @@ static void wui_release(wui_t *w) {
pthread_mutex_unlock(&w->mu); 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) \ #define WUI_EXCLUSIVE(name) \
static int name(cst_http_conn_t *c, void *ud) { \ static int name(cst_http_conn_t *c, void *ud) { \
wui_t *w = ud; \ wui_t *w = ud; \
@@ -1053,40 +1187,256 @@ static int h_config(cst_http_conn_t *c, void *ud) {
cJSON_Delete(j); cJSON_Delete(j);
return cst_http_send_err(c, 400, "expected a JSON object"); 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 /* 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) */ * and plan mode may change mid-turn (say, to yolo at a pending gate) */
int claimed = cJSON_GetObjectItem(j, "model") || int claimed = model || effort;
cJSON_GetObjectItem(j, "provider") ||
cJSON_GetObjectItem(j, "effort");
if (claimed && wui_claim(w) != 0) { if (claimed && wui_claim(w) != 0) {
cJSON_Delete(j); 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 (model)
if ((item = cJSON_GetObjectItem(j, "model")) && cJSON_IsString(item)) snprintf(cfg->model, sizeof(cfg->model), "%s", model->valuestring);
snprintf(cfg->model, sizeof(cfg->model), "%s", item->valuestring); if (effort)
if ((item = cJSON_GetObjectItem(j, "provider")) && cJSON_IsString(item)) snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort->valuestring);
snprintf(cfg->provider, sizeof(cfg->provider), "%s", item->valuestring); if (claimed)
if ((item = cJSON_GetObjectItem(j, "effort")) && cJSON_IsString(item) && cst_settings_remember(cfg);
cst_effort_valid(item->valuestring)) if (approval) {
snprintf(cfg->effort, sizeof(cfg->effort), "%s", item->valuestring); cfg->approval = a;
if ((item = cJSON_GetObjectItem(j, "approval")) && cJSON_IsString(item)) { cfg->auto_approve = a == CST_APPROVAL_YOLO;
int a = 0; if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this run only */
if (cst_approval_parse(item->valuestring, &a) == 0) { cst_settings_remember_approval(a);
cfg->approval = a;
cfg->auto_approve = a == CST_APPROVAL_YOLO;
}
} }
if ((item = cJSON_GetObjectItem(j, "plan_mode")) && cJSON_IsBool(item)) { if (plan)
cfg->plan_mode = cJSON_IsTrue(item) ? 1 : 0; cfg->plan_mode = cJSON_IsTrue(plan) ? 1 : 0;
cfg->read_only = cfg->plan_mode; 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); cJSON_Delete(j);
if (claimed) if (claimed)
wui_release(w); wui_release(w);
wui_emit_state(w); wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8", return wui_send_obj(c, 200, o);
"{\"ok\":true}"); }
/* GET /api/providers: the presets, whether each has a key and from where.
* Never the key itself. */
static int h_providers(cst_http_conn_t *c, void *ud) {
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) { 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_compact)
WUI_EXCLUSIVE(h_attach_upload) WUI_EXCLUSIVE(h_attach_upload)
WUI_EXCLUSIVE(h_attach_clear) WUI_EXCLUSIVE(h_attach_clear)
WUI_EXCLUSIVE(h_provider)
WUI_EXCLUSIVE(h_key)
static int h_quit(cst_http_conn_t *c, void *ud) { static int h_quit(cst_http_conn_t *c, void *ud) {
wui_t *w = 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/permission", h_permission, NULL, 0, 0},
{"POST", "/api/ask_user", h_ask_user, NULL, 0, 0}, {"POST", "/api/ask_user", h_ask_user, NULL, 0, 0},
{"POST", "/api/config", h_config, 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/undo", h_undo, NULL, 0, 0},
{"POST", "/api/compact", h_compact, NULL, 0, 0}, {"POST", "/api/compact", h_compact, NULL, 0, 0},
{"POST", "/api/attach", h_attach_upload, NULL, 0, 32u * 1024u * 1024u}, {"POST", "/api/attach", h_attach_upload, NULL, 0, 32u * 1024u * 1024u},
+30
View File
@@ -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) { int main(void) {
CHECK(os_net_init() == 0); CHECK(os_net_init() == 0);
test_utf8_hold();
test_query(); test_query();
test_sanitize(); test_sanitize();
test_http(); test_http();
+335 -65
View File
@@ -24,21 +24,61 @@ function api(path, opt) {
if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN); if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
return fetch(path, opt); return fetch(path, opt);
} }
async function getJSON(path) { /* the server answers errors as {"error": "..."}; status and body ride along
const r = await api(path); * so a caller can tell "needs a key" from "rejected key" */
if (!r.ok) throw new Error((await r.text()) || r.statusText); class ApiError extends Error {
return r.json(); 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, { const r = await api(path, {
method: 'POST', method: method || 'POST',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}); });
const text = await r.text(); return readReply(r);
if (!r.ok) throw new Error(text || r.statusText);
return text ? JSON.parse(text) : {};
} }
/* 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 $ = (id) => document.getElementById(id);
const el = (tag, cls, text) => { const el = (tag, cls, text) => {
@@ -466,7 +506,9 @@ function applyState(s) {
state = Object.assign({}, state, s); state = Object.assign({}, state, s);
const model = (s.provider ? s.provider + ' · ' : '') + (s.model || '-'); const model = (s.provider ? s.provider + ' · ' : '') + (s.model || '-');
$('chip-model').querySelector('.lbl').textContent = 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 ap = $('chip-approval');
const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto')); const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto'));
ap.querySelector('.lbl').textContent = (cur ? cur.title : 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'); const x = el('button', 'icon tiny');
x.appendChild(icon('close')); x.appendChild(icon('close'));
x.title = '清空待发图片'; x.title = '清空待发图片';
x.onclick = () => api('api/attach', { method: 'DELETE' }).catch(() => {}); x.onclick = () => post('api/attach', undefined, 'DELETE').catch(toastErr);
chip.appendChild(x); chip.appendChild(x);
p.appendChild(chip); p.appendChild(chip);
} }
if (s.sees_images === 0)
p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
} }
renderTodo(s.todo); renderTodo(s.todo);
} }
@@ -545,7 +589,7 @@ function connect() {
on('session', (d) => { on('session', (d) => {
closePop(); closePop();
live = null; steps = null; stepIndex = new Map(); live = null; steps = null; stepIndex = new Map();
loadSession().then(loadSessions).catch(() => {}); loadSession().then(loadSessions).catch(toastErr);
closeDrawers(); closeDrawers();
if (d && d.id) $('status').textContent = '已切换会话'; if (d && d.id) $('status').textContent = '已切换会话';
}); });
@@ -557,7 +601,7 @@ function connect() {
if (live) live.body.classList.remove('live'); if (live) live.body.classList.remove('live');
live = null; steps = null; stepIndex = new Map(); live = null; steps = null; stepIndex = new Map();
if (d && d.rc) bubble('error').body.textContent = d.error || ('rc=' + d.rc); 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 /* the browser resumes from Last-Event-ID; a restarted server has none of
* those events, so its state (and maybe session) is fetched afresh */ * those events, so its state (and maybe session) is fetched afresh */
@@ -570,8 +614,8 @@ function connect() {
const was = state.session; const was = state.session;
getJSON('api/state').then((s) => { getJSON('api/state').then((s) => {
applyState(s); applyState(s);
if (s.session !== was) loadSession().then(loadSessions).catch(() => {}); if (s.session !== was) loadSession().then(loadSessions).catch(toastErr);
}).catch(() => {}); }).catch(toastErr);
}; };
} }
@@ -600,7 +644,7 @@ function showGate(ev) {
} }
$('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。'; $('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。';
$('status').textContent = '等待你的许可…'; $('status').textContent = '等待你的许可…';
let left = 600; let left = ev.timeout || state.perm_timeout || 600;
clearInterval(gateTimer); clearInterval(gateTimer);
const tick = () => { const tick = () => {
$('gate-timer').textContent = left >= 0 ? left + 's' : ''; $('gate-timer').textContent = left >= 0 ? left + 's' : '';
@@ -626,7 +670,7 @@ async function answerGate(value) {
await post('api/permission', { id, answer: value }); await post('api/permission', { id, answer: value });
$('status').textContent = value ? '已允许,继续执行…' : '已拒绝'; $('status').textContent = value ? '已允许,继续执行…' : '已拒绝';
} catch (e) { } catch (e) {
$('status').textContent = '回答失败:' + (e.message || e); toast('回答失败:' + errMsg(e));
} }
} }
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
@@ -642,7 +686,7 @@ window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (!$('modal').hidden) return closeModal(); if (!$('modal').hidden) return closeModal();
if (!$('pop').hidden) return closePop(); 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() === 'b') { e.preventDefault(); toggleSide(); }
if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); } if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); }
@@ -668,7 +712,7 @@ function showAsk(ev) {
box.appendChild(el('h3', null, ev.question || '模型想确认一件事')); box.appendChild(el('h3', null, ev.question || '模型想确认一件事'));
if (ev.detail && ev.detail !== ev.question) box.appendChild(el('pre', 'step-out', ev.detail)); if (ev.detail && ev.detail !== ev.question) box.appendChild(el('pre', 'step-out', ev.detail));
const send = (text, dismiss) => { 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(); closeModal();
}; };
const row = el('div', 'buttons'); const row = el('div', 'buttons');
@@ -693,7 +737,7 @@ function timeLabel(id) {
} }
async function loadSessions() { async function loadSessions() {
let data; let data;
try { data = await getJSON('api/sessions'); } catch { return; } try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; }
sessionsCache = data.sessions || []; sessionsCache = data.sessions || [];
const box = $('sessions-list'); const box = $('sessions-list');
box.textContent = ''; box.textContent = '';
@@ -707,10 +751,12 @@ async function loadSessions() {
b.title = s.id + ' · ' + s.messages + ' 条消息'; b.title = s.id + ' · ' + s.messages + ' 条消息';
b.onclick = async () => { b.onclick = async () => {
if (s.id === (state.session || '')) { closeDrawers(); return; } if (s.id === (state.session || '')) { closeDrawers(); return; }
await post('api/sessions/switch', { id: s.id }); try {
await loadSession(); await post('api/sessions/switch', { id: s.id });
await loadSessions(); await loadSession();
closeDrawers(); await loadSessions();
closeDrawers();
} catch (e) { toastErr(e); }
}; };
box.appendChild(b); box.appendChild(b);
} }
@@ -737,10 +783,12 @@ async function showSessions() {
row.appendChild(el('span', 'id', s.messages + ' 条')); row.appendChild(el('span', 'id', s.messages + ' 条'));
row.appendChild(Object.assign(el('button', null, '打开'), { row.appendChild(Object.assign(el('button', null, '打开'), {
onclick: async () => { onclick: async () => {
await post('api/sessions/switch', { id: s.id }); try {
closeModal(); await post('api/sessions/switch', { id: s.id });
await loadSession(); closeModal();
await loadSessions(); await loadSession();
await loadSessions();
} catch (e) { toastErr(e); }
}, },
})); }));
box.appendChild(row); box.appendChild(row);
@@ -748,10 +796,12 @@ async function showSessions() {
const row = el('div', 'buttons'); const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', 'ok', '新建会话'), { row.appendChild(Object.assign(el('button', 'ok', '新建会话'), {
onclick: async () => { onclick: async () => {
await post('api/sessions/new', {}); try {
closeModal(); await post('api/sessions/new', {});
await loadSession(); closeModal();
await loadSessions(); await loadSession();
await loadSessions();
} catch (e) { toastErr(e); }
}, },
})); }));
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
@@ -761,40 +811,245 @@ async function showSessions() {
/* --- pickers as popovers ------------------------------------------------- */ /* --- 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) { function modelPopover(anchor) {
openPop(anchor, (pop) => { 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 row = el('div', 'pop-input');
const input = el('input'); const input = el('input');
input.value = state.model || ''; input.placeholder = '搜索,或直接输入模型名';
input.placeholder = '模型名'; input.spellcheck = false;
row.appendChild(input); row.appendChild(input);
const apply = el('button', null, '应用');
apply.onclick = () => { post('api/config', { model: input.value }).catch(() => {}); };
row.appendChild(apply);
pop.appendChild(row); 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-sep'));
pop.appendChild(el('div', 'pop-title', '推理强度')); 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' }; const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' };
for (const e2 of EFFORTS) for (const e2 of EFFORTS) {
pop.appendChild(popRow({ const r = popRow({
icon: LV[e2.key] || 'lv1', icon: LV[e2.key] || 'lv1',
title: e2.title, title: e2.title,
desc: e2.desc, desc: e2.desc,
on: e2.key === state.effort, 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(el('div', 'pop-sep'));
pop.appendChild(popRow({ pop.appendChild(popRow({
icon: 'settings', icon: 'cpu',
title: 'provider:' + (state.provider || '-'), title: '提供商:' + (state.provider || '-'),
desc: '在设置里改连接端点', desc: (state.api_base || '') + ' · 点这里切换',
onClick: showSettings, onClick: showProviders,
})); }));
setTimeout(() => input.focus(), 0); 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) { function approvalPopover(anchor) {
openPop(anchor, (pop) => { openPop(anchor, (pop) => {
pop.appendChild(el('div', 'pop-title', '审批权限')); pop.appendChild(el('div', 'pop-title', '审批权限'));
@@ -805,7 +1060,7 @@ function approvalPopover(anchor) {
desc: a.desc, desc: a.desc,
on: a.key === (state.approval || 'auto'), on: a.key === (state.approval || 'auto'),
danger: a.key === 'yolo', danger: a.key === 'yolo',
onClick: () => post('api/config', { approval: a.key }).catch(() => {}), onClick: () => post('api/config', { approval: a.key }).catch(toastErr),
})); }));
}, { align: 'right' }); }, { align: 'right' });
} }
@@ -835,7 +1090,7 @@ function plusPopover(anchor) {
pop.appendChild(popRow({ pop.appendChild(popRow({
icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件', icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件',
on: !!state.plan_mode, 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-sep'));
pop.appendChild(el('div', 'pop-title', '工作目录')); pop.appendChild(el('div', 'pop-title', '工作目录'));
@@ -856,7 +1111,7 @@ function plusPopover(anchor) {
await loadSessions(); await loadSessions();
$('status').textContent = '工作目录已切换'; $('status').textContent = '工作目录已切换';
} catch (e) { } catch (e) {
$('status').textContent = String(e.message || e); toastErr(e);
wgo.disabled = false; wgo.disabled = false;
} }
}; };
@@ -877,11 +1132,16 @@ function usagePopover(anchor) {
}; };
kv('工作区', state.workspace || '-'); kv('工作区', state.workspace || '-');
kv('会话', state.session || '-'); 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('模型往返', String(u.requests || 0));
kv('Token', num(u.prompt_tokens) + ' / ' + num(u.completion_tokens)); kv('Token', num(u.prompt_tokens) + ' / ' + num(u.completion_tokens));
kv('费用', (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : (u.requests ? '未定价' : '-')); 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(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') })); pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') }));
pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', desc: '把旧消息收成摘要', onClick: compact })); pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', desc: '把旧消息收成摘要', onClick: compact }));
@@ -898,7 +1158,7 @@ function menuPopover(anchor) {
})); }));
pop.appendChild(el('div', 'pop-sep')); pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); 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' }); }, { align: 'right' });
} }
function showSettings() { function showSettings() {
@@ -912,10 +1172,17 @@ function showSettings() {
}; };
kv('目录', state.workspace); kv('目录', state.workspace);
kv('会话', state.session); kv('会话', state.session);
kv('模型', (state.provider ? state.provider + ' · ' : '') + state.model); kv('提供商', state.provider);
kv('协议', state.protocol); 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(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) })); 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: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) }));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
}); });
@@ -935,11 +1202,11 @@ function syncSend() {
async function compact() { async function compact() {
$('status').textContent = '压缩中…'; $('status').textContent = '压缩中…';
try { await post('api/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() { async function undo() {
try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; } 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) { async function send(text) {
const input = $('input'); const input = $('input');
@@ -959,7 +1226,7 @@ async function send(text) {
await post('api/turn', { text: body }); await post('api/turn', { text: body });
$('status').textContent = '已开始…'; $('status').textContent = '已开始…';
} catch (e) { } catch (e) {
bubble('error').body.textContent = String(e.message || e); bubble('error').body.textContent = errMsg(e);
} }
} }
function autoGrow() { function autoGrow() {
@@ -973,7 +1240,8 @@ async function upload(file) {
headers: { 'Content-Type': file.type || 'application/octet-stream' }, headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file, 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 -------------------------------------------------------------- */ /* --- layout -------------------------------------------------------------- */
@@ -1010,9 +1278,9 @@ function toggleTheme() {
/* --- wiring -------------------------------------------------------------- */ /* --- wiring -------------------------------------------------------------- */
$('btn-send').onclick = () => send(); $('btn-send').onclick = () => send();
$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {}); $('btn-stop').onclick = () => post('api/cancel', {}).catch(toastErr);
$('btn-new').onclick = async () => { $('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 loadSession();
await loadSessions(); await loadSessions();
closeDrawers(); closeDrawers();
@@ -1035,8 +1303,10 @@ $('btn-undo').onclick = undo;
$('to-bottom').onclick = () => scrollDown(true); $('to-bottom').onclick = () => scrollDown(true);
for (const b of $('seg-mode').children) { for (const b of $('seg-mode').children) {
b.onclick = async () => { b.onclick = async () => {
await post('api/config', { plan_mode: b.dataset.mode === 'plan' }); try {
$('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写'; await post('api/config', { plan_mode: b.dataset.mode === 'plan' });
$('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写';
} catch (e) { toastErr(e); }
}; };
} }
$('input').addEventListener('keydown', (e) => { $('input').addEventListener('keydown', (e) => {
@@ -1075,9 +1345,9 @@ autoGrow();
syncSend(); syncSend();
renderFilesPanel(); renderFilesPanel();
connect(); connect();
loadSession().catch((e) => { $('status').textContent = String(e.message || e); }); loadSession().catch(toastErr);
loadSessions(); 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 /* 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 */ * state is in, so a screenshot or a bookmark can land on it directly */
const wantsOpen = new URLSearchParams(location.search).get('open'); const wantsOpen = new URLSearchParams(location.search).get('open');
+1
View File
@@ -208,6 +208,7 @@
<div id="backdrop"></div> <div id="backdrop"></div>
<div id="pop" class="pop" hidden></div> <div id="pop" class="pop" hidden></div>
<div id="modal" hidden><div id="modal-box"></div></div> <div id="modal" hidden><div id="modal-box"></div></div>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script> <script>
/* Layout is decided here, before app.js runs and before the first paint: the /* Layout is decided here, before app.js runs and before the first paint: the
* rail must not flash open and then fold, and a broken script further down can * rail must not flash open and then fold, and a broken script further down can
+48 -1
View File
@@ -546,7 +546,54 @@ body.no-side #side { pointer-events: none; }
.pop-input button:active { transform: scale(.96); } .pop-input button:active { transform: scale(.96); }
.kv { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 10px; font-size: 13px; } .kv { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 10px; font-size: 13px; }
.kv span { color: var(--fg-dim); } .kv span { color: var(--fg-dim); }
.kv b { font-variant-numeric: tabular-nums; font-weight: 600; } .kv b { font-variant-numeric: tabular-nums; font-weight: 600; min-width: 0; overflow-wrap: anywhere; text-align: right; }
.pop { max-height: calc(100vh - 16px); overflow-y: auto; }
.pop:has(.pop-list) { min-width: min(360px, 94vw); max-width: min(440px, 94vw); }
.pop-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.pop-list { max-height: min(46vh, 420px); overflow-y: auto; }
.pop-list .pop-item .pt { overflow-wrap: anywhere; font-family: var(--mono); font-size: 12.5px; }
.pop-empty { padding: 10px; color: var(--fg-faint); font-size: 12.5px; }
.pop-note { padding: 2px 10px 6px; color: var(--fg-faint); font-size: 11.5px; line-height: 1.45; }
.pop-note:empty { display: none; }
.pop-note.bad { color: var(--danger); }
.pop-note.warn { color: var(--warn); }
.pop-item.dim { opacity: .55; }
button.link { color: var(--link); padding: 0 4px; font-size: inherit; text-decoration: underline; }
.ok-text { color: var(--text-success); }
.warn-text { color: var(--warn); }
/* providers and the key dialog */
#modal-box .prov {
display: flex; align-items: center; gap: 10px; padding: 9px 8px;
border-bottom: 1px solid var(--line); border-radius: 8px;
}
#modal-box .prov.on { background: var(--accent-weak); }
#modal-box .prov .pm { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
#modal-box .prov .pm span { overflow-wrap: anywhere; }
#modal-box .prov-acts button {
height: 30px; padding: 0 12px; border-radius: 9px; font-weight: 600; font-size: 13px;
border: 1px solid var(--line-strong); background: var(--bg-lift); white-space: nowrap;
}
#modal-box .prov-acts button.ok { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
#modal-box .key-input {
width: 100%; margin-top: 12px; background: var(--bg-lift); border: 1px solid var(--line);
border-radius: 10px; padding: 9px 11px; outline: none; font-family: var(--mono);
}
#modal-box .key-input:focus { border-color: var(--accent-line); }
#modal-box .key-err { min-height: 18px; margin-top: 6px; color: var(--danger); font-size: 12.5px; }
/* one line of feedback for anything that failed or needs a look */
.toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60;
max-width: min(560px, 92vw); padding: 10px 14px; border-radius: 12px;
background: var(--main-surface-tertiary); border: 1px solid var(--border-medium);
box-shadow: var(--shadow); font-size: 13px; line-height: 1.45; overflow-wrap: anywhere;
animation: fade-up 160ms var(--ease) both;
}
.toast.err { border-color: color-mix(in srgb, var(--danger) 55%, transparent); color: var(--danger); }
.toast.warn { border-color: color-mix(in srgb, var(--warn) 55%, transparent); color: var(--warn); }
.toast.ok { border-color: var(--accent-line); color: var(--accent); }
.toast[hidden] { display: none; }
/* --- right panel ---------------------------------------------------------- */ /* --- right panel ---------------------------------------------------------- */
/* the right panel mirrors the rail: fixed-width contents, clipped by the track, /* the right panel mirrors the rail: fixed-width contents, clipped by the track,