v0.8.3: WebUI models, providers and keys; garbled thinking fixed
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.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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},
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user