v0.8.3: WebUI models, providers and keys; garbled thinking fixed
ci / linux (clang) (push) Successful in 5m56s
ci / linux (gcc) (push) Successful in 8m38s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 9m21s

Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

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

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

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

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
This commit is contained in:
WHC
2026-10-09 15:54:53 +08:00
parent e238f5f37d
commit 2d1052fd5f
18 changed files with 1553 additions and 182 deletions
+24 -9
View File
@@ -1282,13 +1282,22 @@ static void on_mm(const ctl_event_t *ev, void *ud) {
}
static void feed_visible(turn_t *t, const char *data, size_t len) {
if (!len || t->stop)
return;
cst_buf_append(&t->raw, data, len);
if (t->ctl)
ctl_parser_feed(t->ctl, data, len);
else if (t->mm)
mm_scan_feed(t->mm, data, len);
/* cut after each '\n' and '>': a call only becomes ready there, and
* raw_mark must not take in what follows it in the same chunk */
while (len && !t->stop) {
size_t n = 0;
while (n < len && data[n] != '\n' && data[n] != '>')
n++;
if (n < len)
n++;
cst_buf_append(&t->raw, data, n);
if (t->ctl)
ctl_parser_feed(t->ctl, data, n);
else if (t->mm)
mm_scan_feed(t->mm, data, n);
data += n;
len -= n;
}
}
static void think_out(turn_t *t, const char *s, size_t n) {
@@ -1306,10 +1315,16 @@ static void on_delta(const char *data, size_t len, void *ud) {
const char *want = t->in_think ? "</think>" : "<think>";
size_t wl = strlen(want);
if (t->taglen == 0 && c != '<') {
/* the whole run up to the next '<' in one call: byte by byte, a
* front end would get every CJK character in three invalid pieces */
size_t j = i + 1;
while (j < len && data[j] != '<')
j++;
if (t->in_think)
think_out(t, &c, 1);
think_out(t, data + i, j - i);
else
feed_visible(t, &c, 1);
feed_visible(t, data + i, j - i);
i = j - 1;
continue;
}
t->tagbuf[t->taglen++] = c;
+193
View File
@@ -0,0 +1,193 @@
#include "cst_provider_switch.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int cst_ps_fetch(const char *provider, const char *api_base, const char *key,
char ids[][128], int max, char *err, size_t err_sz) {
cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m));
if (!m) {
snprintf(err, err_sz, "out of memory");
return -1;
}
int r = cst_llm_list_models_meta(api_base, key, m, max, err, err_sz);
if (r >= 0)
cst_models_cache_save(provider, api_base, m, r);
for (int i = 0; i < r; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
return r;
}
int cst_ps_cached(const char *provider, char ids[][128], int max, int *fresh) {
*fresh = 0;
cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m));
if (!m)
return -1;
long age = 0;
int n = cst_models_cache_load(provider, m, max, &age);
for (int i = 0; i < n; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
*fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL;
return n > 0 ? n : -1;
}
const char *cst_ps_base(const cst_agent_config_t *cfg, const char *provider) {
if (cfg && strcmp(cfg->provider, provider) == 0)
return cfg->api_base;
const cst_provider_t *p = cst_provider_find(provider);
return p ? p->api_base : "";
}
int cst_ps_models(const cst_agent_config_t *cfg, int refresh,
cst_ps_model_t *out, int max, int *api_rc, char *err,
size_t err_sz) {
err[0] = 0;
*api_rc = -1;
char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids));
if (!ids) {
snprintf(err, err_sz, "out of memory");
return 0;
}
int fresh = 0;
int na = cst_ps_cached(cfg->provider, ids, CST_PS_MAX_MODELS, &fresh);
*api_rc = na;
if ((refresh || !fresh) && cfg->api_key[0]) {
char(*got)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*got));
if (got) {
int r = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, got,
CST_PS_MAX_MODELS, err, err_sz);
*api_rc = r;
if (r >= 0) {
memcpy(ids, got, (size_t)r * sizeof(*ids));
na = r;
}
free(got);
}
}
int n = 0;
for (int i = 0; i < na && n < max; i++, n++) {
snprintf(out[n].id, sizeof(out[n].id), "%s", ids[i]);
out[n].api = 1;
cst_model_info(cfg->provider, ids[i], &out[n].info);
}
char builtin[32][128];
int nb = cst_models_list(cfg->model, builtin, 32);
for (int i = 0; i < nb && n < max; i++) {
int dup = 0;
for (int k = 0; k < na && !dup; k++)
dup = strcmp(ids[k], builtin[i]) == 0;
if (dup)
continue;
memcpy(out[n].id, builtin[i], sizeof(out[n].id));
out[n].id[sizeof(out[n].id) - 1] = 0;
out[n].api = 0;
cst_model_info(cfg->provider, builtin[i], &out[n].info);
n++;
}
free(ids);
return n;
}
int cst_ps_verify_save(const char *provider, const char *api_base,
const char *key, char ids[][128], int max, int *nids,
char *err, size_t err_sz) {
err[0] = 0;
char(*own)[128] = NULL;
if (!ids) {
own = calloc(CST_PS_MAX_MODELS, sizeof(*own));
if (!own) {
snprintf(err, err_sz, "out of memory");
return CST_PS_UNVERIFIED | CST_PS_UNSAVED;
}
ids = own;
max = CST_PS_MAX_MODELS;
}
int r = cst_ps_fetch(provider, api_base, key, ids, max, err, err_sz);
free(own);
if (nids)
*nids = r;
if (r == CST_MODELS_AUTH)
return CST_MODELS_AUTH;
int flags = r < 0 ? CST_PS_UNVERIFIED : 0;
if (r >= 0)
err[0] = 0;
if (cst_creds_save(provider, key) != 0)
flags |= CST_PS_UNSAVED;
return flags;
}
void cst_ps_apply_provider(cst_agent_config_t *cfg, const cst_provider_t *p,
const char *key) {
cst_settings_t s;
cst_settings_load(&s);
const char *model = cst_settings_model(&s, p->name);
snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name);
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model);
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
cst_settings_remember(cfg);
}
int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name,
const char *key, char *err, size_t err_sz) {
err[0] = 0;
const cst_provider_t *p = name ? cst_provider_find(name) : NULL;
if (!p) {
snprintf(err, err_sz, "unknown provider '%.64s'", name ? name : "");
return CST_PS_UNKNOWN;
}
char k[512];
int flags = 0;
char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids));
int nids = -1;
if (key && key[0]) {
snprintf(k, sizeof(k), "%s", key);
flags = cst_ps_verify_save(p->name, p->api_base, k, ids,
ids ? CST_PS_MAX_MODELS : 0, &nids, err,
err_sz);
if (flags < 0) {
memset(k, 0, sizeof(k));
free(ids);
return flags;
}
} else if (cst_creds_resolve(p->name, k, sizeof(k), NULL) != 0) {
free(ids);
snprintf(err, err_sz, "%s needs an API key", p->name);
return CST_PS_NEED_KEY;
}
cst_ps_apply_provider(cfg, p, k);
memset(k, 0, sizeof(k));
if (!cfg->model[0] && ids) {
/* a router preset: the first model the API lists */
int fresh = 0;
if (nids <= 0)
nids = cst_ps_cached(p->name, ids, CST_PS_MAX_MODELS, &fresh);
if (nids <= 0) {
char e2[256];
nids = cst_ps_fetch(p->name, cfg->api_base, cfg->api_key, ids,
CST_PS_MAX_MODELS, e2, sizeof(e2));
}
if (nids > 0) {
snprintf(cfg->model, sizeof(cfg->model), "%s", ids[0]);
cst_settings_remember(cfg);
}
}
free(ids);
return flags;
}
int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err,
size_t err_sz) {
if (!key || !key[0]) {
snprintf(err, err_sz, "empty key");
return CST_PS_NEED_KEY;
}
int r = cst_ps_verify_save(cfg->provider, cfg->api_base, key, NULL, 0, NULL,
err, err_sz);
if (r >= 0)
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
return r;
}
+13 -61
View File
@@ -11,6 +11,7 @@
#include "cst_mcp.h"
#include "cst_models.h"
#include "cst_plugins.h"
#include "cst_provider_switch.h"
#include "cst_skills.h"
#include "cst_subagent.h"
#include "cst_textsel.h"
@@ -3886,46 +3887,11 @@ static void am_store(const char *provider, int r, char ids[][128],
pthread_mutex_unlock(&g_am_mu);
}
/* GET /models; a good answer also refreshes models-cache/<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) {
probe_arg_t *a = arg;
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
char err[256];
int r = fetch_models(a->provider, a->base, a->key, ids, AM_MAX, err,
int r = cst_ps_fetch(a->provider, a->base, a->key, ids, AM_MAX, err,
sizeof(err));
if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache)
am_store(a->provider, r, ids, err);
@@ -3937,23 +3903,13 @@ static void *probe_thread(void *arg) {
return NULL;
}
/* Base URL for a provider: the live config for the current one (it may be
* overridden), else the preset. */
static const char *provider_base(const tui_t *t, const char *provider) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (cfg && strcmp(cfg->provider, provider) == 0)
return cfg->api_base;
const cst_provider_t *p = cst_provider_find(provider);
return p ? p->api_base : "";
}
static void start_probe(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
return;
static char ids[AM_MAX][128];
int fresh = 0;
int nc = cached_models(cfg->provider, ids, AM_MAX, &fresh);
int nc = cst_ps_cached(cfg->provider, ids, AM_MAX, &fresh);
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu);
@@ -4039,13 +3995,14 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
return 0;
char dir[1024] = "the config directory";
os_config_dir(dir, sizeof(dir));
const char *base = provider_base(t, provider);
const char *base = cst_ps_base(cst_agent_config(t->agent), provider);
char title[128], hint[1300];
snprintf(title, sizeof(title), "API key for %s", provider);
snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you "
"can read it)",
base, dir);
char note[300] = "";
int flags;
for (;;) {
if (secret_input_run(t, title, hint, out, n) != 0)
return -1;
@@ -4053,8 +4010,10 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
draw_status(t);
static char ids[AM_MAX][128];
char err[256];
int r = fetch_models(provider, base, out, ids, AM_MAX, err, sizeof(err));
if (r == CST_MODELS_AUTH) {
int r = -1;
flags = cst_ps_verify_save(provider, base, out, ids, AM_MAX, &r, err,
sizeof(err));
if (flags == CST_MODELS_AUTH) {
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
"another one",
base, err);
@@ -4064,13 +4023,13 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider);
pthread_mutex_unlock(&g_am_mu);
am_store(provider, r, ids, err);
if (r < 0)
if (flags & CST_PS_UNVERIFIED)
snprintf(note, sizeof(note), "could not verify the key (%.200s); "
"saved anyway",
err);
break;
}
if (cst_creds_save(provider, out) != 0)
if (flags & CST_PS_UNSAVED)
set_status(t, "warning: could not save the key; it is used for this run "
"only");
else if (note[0])
@@ -4302,7 +4261,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
set_status(t, "fetching the model list…");
draw_status(t);
char err[256];
na = fetch_models(cfg->provider, cfg->api_base, cfg->api_key, names,
na = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, names,
AM_MAX, err, sizeof(err));
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
@@ -4436,15 +4395,8 @@ static void cmd_provider(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(provider unchanged: %s needs an API key)", p->name);
return;
}
cst_settings_t s;
cst_settings_load(&s);
const char *model = cst_settings_model(&s, p->name);
snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name);
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model);
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
cst_ps_apply_provider(cfg, p, key);
memset(key, 0, sizeof(key));
cst_settings_remember(cfg);
push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base,
cfg->model);
if (am_models(p->name, NULL, 0) < 0)
+20
View File
@@ -99,6 +99,26 @@ size_t cst_utf8_floor(const char *s, size_t n) {
return i - 1; /* sequence incomplete: cut before lead byte */
}
char *cst_utf8_hold_feed(cst_utf8_hold_t *h, const char *s, size_t n,
size_t *out_len) {
size_t have = h->n;
char *all = malloc(have + n + 1);
if (!all)
return NULL;
memcpy(all, h->b, have);
if (n)
memcpy(all + have, s, n);
size_t len = have + n;
size_t cut = cst_utf8_floor(all, len);
if (len - cut > sizeof(h->b))
cut = len;
h->n = len - cut;
memcpy(h->b, all + cut, h->n);
all[cut] = 0;
*out_len = cut;
return all;
}
static size_t utf8_seq_ok(const unsigned char *p, size_t left) {
unsigned char c = p[0];
size_t need;
+395 -39
View File
@@ -15,6 +15,9 @@
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_http.h"
#include "cst_models.h"
#include "cst_provider_switch.h"
#include "cst_tools.h"
#include "cst_util.h"
#include "cst_web_md.h"
#include "cst_webui.h"
@@ -82,6 +85,10 @@ typedef struct wui {
uint64_t pending_seq; /* event of the open permission/ask_user, 0 = none */
char ask_ans[WUI_ASK_MAX];
/* turn thread only: the tail of a UTF-8 sequence split across deltas,
* [0] think_delta, [1] assistant_delta */
cst_utf8_hold_t carry[2];
volatile int stopping;
} wui_t;
@@ -157,8 +164,79 @@ static void wui_emit_text(wui_t *w, const char *type, const char *text,
wui_emit(w, type, o);
}
/* A JSON string with half a character turns into U+FFFD in the browser:
* hold an incomplete trailing sequence until the next delta completes it. */
static void wui_emit_stream(wui_t *w, int k, const char *type,
const char *text, size_t len) {
size_t n = 0;
char *s = cst_utf8_hold_feed(&w->carry[k], text, len, &n);
if (s && n)
wui_emit_text(w, type, s, n);
free(s);
}
static void wui_reset_carry(wui_t *w) {
w->carry[0].n = w->carry[1].n = 0;
}
/* --- state --------------------------------------------------------------- */
/* Whether provider has a key and where it comes from: "saved", the env
* variable's name, or "session" (given for this run only). The key itself
* never leaves this function. */
static int key_source(const cst_agent_config_t *cfg, const char *provider,
char *from, size_t from_sz) {
char k[512];
const char *src = NULL;
int found = cst_creds_resolve(provider, k, sizeof(k), &src) == 0;
int cur = cfg && strcmp(cfg->provider, provider) == 0;
int has = cur ? cfg->api_key[0] != 0 : found;
if (!has)
from[0] = 0;
else if (cur && (!found || strcmp(k, cfg->api_key) != 0))
snprintf(from, from_sz, "session");
else
snprintf(from, from_sz, "%s", src ? src : "saved");
memset(k, 0, sizeof(k));
return has;
}
static void model_info_json(cJSON *o, const char *provider,
const char *model) {
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
char label[48];
cst_model_info_label(&mi, label, sizeof(label));
cJSON_AddStringToObject(o, "label", label);
cJSON_AddNumberToObject(o, "context", (double)mi.context);
if (mi.vision < 0)
cJSON_AddNullToObject(o, "vision");
else
cJSON_AddBoolToObject(o, "vision", mi.vision == 1);
cJSON_AddBoolToObject(o, "reasoning", mi.reasoning != 0);
cJSON_AddStringToObject(o, "source", mi.source);
if (mi.has_price) {
cJSON *p = cJSON_AddObjectToObject(o, "price");
if (p) {
cJSON_AddStringToObject(p, "currency", mi.price.currency);
cJSON_AddNumberToObject(p, "miss", mi.price.miss);
cJSON_AddNumberToObject(p, "hit", mi.price.hit);
cJSON_AddNumberToObject(p, "out", mi.price.out);
}
}
}
static void usage_json(cJSON *u, const cst_usage_t *s) {
cJSON_AddNumberToObject(u, "requests", s->requests);
cJSON_AddNumberToObject(u, "prompt_tokens", (double)s->prompt);
cJSON_AddNumberToObject(u, "completion_tokens", (double)s->out);
cJSON_AddNumberToObject(u, "cached_tokens", (double)s->cached);
cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)s->miss);
cJSON_AddNumberToObject(u, "cost", s->cost);
cJSON_AddBoolToObject(u, "priced", s->priced != 0);
cJSON_AddStringToObject(u, "currency", s->currency);
}
static cJSON *wui_state_new(wui_t *w) {
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cJSON *o = cJSON_CreateObject();
@@ -188,16 +266,29 @@ static cJSON *wui_state_new(wui_t *w) {
cJSON_AddBoolToObject(o, "undo_available",
cst_agent_undo_available(w->agent) != 0);
cJSON *u = cJSON_AddObjectToObject(o, "usage");
if (u) {
cJSON_AddNumberToObject(u, "requests", sess.requests);
cJSON_AddNumberToObject(u, "prompt_tokens", (double)sess.prompt);
cJSON_AddNumberToObject(u, "completion_tokens", (double)sess.out);
cJSON_AddNumberToObject(u, "cached_tokens", (double)sess.cached);
cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)sess.miss);
cJSON_AddNumberToObject(u, "cost", sess.cost);
cJSON_AddBoolToObject(u, "priced", sess.priced != 0);
cJSON_AddStringToObject(u, "currency", sess.currency);
}
if (u)
usage_json(u, &sess);
u = cJSON_AddObjectToObject(o, "turn_usage");
if (u)
usage_json(u, &turn);
cJSON_AddStringToObject(o, "api_base", cfg->api_base);
char from[64];
cJSON_AddBoolToObject(o, "key_set",
key_source(cfg, cfg->provider, from, sizeof(from)));
cJSON_AddStringToObject(o, "key_from", from);
cJSON *mi = cJSON_AddObjectToObject(o, "model_info");
if (mi)
model_info_json(mi, cfg->provider, cfg->model);
cJSON_AddBoolToObject(o, "effort_applies",
cst_effort_wire(cfg->provider, cfg->model,
cfg->effort) != NULL);
/* 1 yes, 0 no, -1 not known */
cJSON_AddNumberToObject(o, "sees_images", cst_agent_sees_images(w->agent));
cJSON_AddNumberToObject(o, "max_steps", cfg->max_steps);
cJSON_AddStringToObject(o, "tool_profile",
cst_tool_profile_name(cfg->tool_profile));
cJSON_AddNumberToObject(o, "perm_timeout",
w->perm_timeout_s > 0 ? w->perm_timeout_s : 600);
cJSON *imgs = cJSON_AddArrayToObject(o, "pending_images");
int n = cst_agent_pending_count(w->agent);
for (int i = 0; i < n && imgs; i++) {
@@ -309,11 +400,11 @@ static void on_assistant_delta(const char *text, size_t len, void *ud) {
w->md = cst_web_md_new(wui_md_emit, w);
if (w->md)
cst_web_md_feed(w->md, text, len);
wui_emit_text(w, "assistant_delta", text, len);
wui_emit_stream(w, 1, "assistant_delta", text, len);
}
static void on_think_delta(const char *text, size_t len, void *ud) {
wui_emit_text((wui_t *)ud, "think_delta", text, len);
wui_emit_stream((wui_t *)ud, 0, "think_delta", text, len);
}
static cJSON *call_json(const ctl_tool_call_t *call) {
@@ -380,6 +471,10 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
int has_client = w->subs != NULL;
pthread_mutex_unlock(&w->mu);
int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600;
/* Nobody is watching: do not hang the turn for ten minutes. */
if (!has_client)
timeout = 3;
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddNumberToObject(o, "id", (double)id);
@@ -390,12 +485,9 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
strncmp(detail, CST_PERM_SANDBOX,
strlen(CST_PERM_SANDBOX)) ==
0);
cJSON_AddNumberToObject(o, "timeout", timeout);
wui_set_pending(w, wui_emit(w, "permission", o));
}
int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600;
/* Nobody is watching: do not hang the turn for ten minutes. */
if (!has_client)
timeout = 3;
int answer = CST_PERM_DENY;
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
@@ -765,7 +857,9 @@ static void *wui_turn_main(void *arg) {
hooks.userdata = w;
char err[512];
err[0] = 0;
wui_reset_carry(w);
int rc = cst_agent_run_turn_ex(w->agent, t->prompt, &hooks, err, sizeof(err));
wui_reset_carry(w); /* a dangling partial character is dropped */
wui_end_turn(w, rc, err);
free(t->prompt);
free(t);
@@ -863,6 +957,46 @@ static void wui_release(wui_t *w) {
pthread_mutex_unlock(&w->mu);
}
/* A copy of the config for a slow read (a model list fetch). Changes only
* write it after claiming under mu, so none is half done while mu is held
* and op_busy is clear; a running turn does not write these fields. */
static int wui_cfg_snapshot(wui_t *w, cst_agent_config_t *out) {
pthread_mutex_lock(&w->mu);
int ok = !w->op_busy;
if (ok)
*out = *cst_agent_config(w->agent);
pthread_mutex_unlock(&w->mu);
return ok ? 0 : -1;
}
static int wui_send_obj(cst_http_conn_t *c, int status, cJSON *o) {
char *json = o ? cJSON_PrintUnformatted(o) : NULL;
cJSON_Delete(o);
if (!json)
return cst_http_send_err(c, 500, "out of memory");
int rc = cst_http_send_json(c, status, json);
free(json);
return rc;
}
static cJSON *wui_body(cst_http_conn_t *c) {
const cst_http_req_t *req = cst_http_request(c);
return req->body_len ? cJSON_ParseWithLength(req->body, req->body_len)
: NULL;
}
static int wui_model_ok(const cJSON *m) {
if (!cJSON_IsString(m))
return 0;
size_t n = strlen(m->valuestring);
if (n == 0 || n > 127)
return 0;
for (size_t i = 0; i < n; i++)
if ((unsigned char)m->valuestring[i] < 0x21)
return 0;
return 1;
}
#define WUI_EXCLUSIVE(name) \
static int name(cst_http_conn_t *c, void *ud) { \
wui_t *w = ud; \
@@ -1053,40 +1187,256 @@ static int h_config(cst_http_conn_t *c, void *ud) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "expected a JSON object");
}
/* everything is checked before anything changes */
const cJSON *model = cJSON_GetObjectItem(j, "model");
const cJSON *effort = cJSON_GetObjectItem(j, "effort");
const cJSON *approval = cJSON_GetObjectItem(j, "approval");
const cJSON *plan = cJSON_GetObjectItem(j, "plan_mode");
const char *bad = NULL;
if (cJSON_GetObjectItem(j, "provider"))
bad = "切换提供商请用 POST /api/provider";
else if (model && !wui_model_ok(model))
bad = "模型名无效(1-127 个可见字符)";
else if (effort && (!cJSON_IsString(effort) ||
!cst_effort_valid(effort->valuestring)))
bad = "推理强度只能是 low / medium / high / max";
int a = 0;
if (!bad && approval && (!cJSON_IsString(approval) ||
cst_approval_parse(approval->valuestring, &a) != 0))
bad = "审批模式只能是 ask / auto / yolo";
if (!bad && plan && !cJSON_IsBool(plan))
bad = "plan_mode 必须是 true 或 false";
if (bad) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, bad);
}
/* the strings are read by the turn as it builds each request; approval
* and plan mode may change mid-turn (say, to yolo at a pending gate) */
int claimed = cJSON_GetObjectItem(j, "model") ||
cJSON_GetObjectItem(j, "provider") ||
cJSON_GetObjectItem(j, "effort");
int claimed = model || effort;
if (claimed && wui_claim(w) != 0) {
cJSON_Delete(j);
return cst_http_send_err(c, 409, "busy: wait for the running turn");
return cst_http_send_err(c, 409, "正在运行,等这一轮结束再改模型");
}
const cJSON *item;
if ((item = cJSON_GetObjectItem(j, "model")) && cJSON_IsString(item))
snprintf(cfg->model, sizeof(cfg->model), "%s", item->valuestring);
if ((item = cJSON_GetObjectItem(j, "provider")) && cJSON_IsString(item))
snprintf(cfg->provider, sizeof(cfg->provider), "%s", item->valuestring);
if ((item = cJSON_GetObjectItem(j, "effort")) && cJSON_IsString(item) &&
cst_effort_valid(item->valuestring))
snprintf(cfg->effort, sizeof(cfg->effort), "%s", item->valuestring);
if ((item = cJSON_GetObjectItem(j, "approval")) && cJSON_IsString(item)) {
int a = 0;
if (cst_approval_parse(item->valuestring, &a) == 0) {
cfg->approval = a;
cfg->auto_approve = a == CST_APPROVAL_YOLO;
}
if (model)
snprintf(cfg->model, sizeof(cfg->model), "%s", model->valuestring);
if (effort)
snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort->valuestring);
if (claimed)
cst_settings_remember(cfg);
if (approval) {
cfg->approval = a;
cfg->auto_approve = a == CST_APPROVAL_YOLO;
if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this run only */
cst_settings_remember_approval(a);
}
if ((item = cJSON_GetObjectItem(j, "plan_mode")) && cJSON_IsBool(item)) {
cfg->plan_mode = cJSON_IsTrue(item) ? 1 : 0;
cfg->read_only = cfg->plan_mode;
if (plan)
cfg->plan_mode = cJSON_IsTrue(plan) ? 1 : 0;
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
if (effort && !cst_effort_wire(cfg->provider, cfg->model, cfg->effort))
cJSON_AddStringToObject(o, "warning", "当前模型不接受推理强度参数:已保存,"
"换到支持的模型时生效");
}
cJSON_Delete(j);
if (claimed)
wui_release(w);
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
return wui_send_obj(c, 200, o);
}
/* GET /api/providers: the presets, whether each has a key and from where.
* Never the key itself. */
static int h_providers(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = malloc(sizeof(*cfg));
if (!cfg)
return cst_http_send_err(c, 500, "out of memory");
if (wui_cfg_snapshot(w, cfg) != 0) {
free(cfg);
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
}
cJSON *o = cJSON_CreateObject();
if (!o) {
free(cfg);
return cst_http_send_err(c, 500, "out of memory");
}
cJSON_AddStringToObject(o, "current", cfg->provider);
cJSON *arr = cJSON_AddArrayToObject(o, "providers");
int n = cst_provider_count();
for (int i = 0; arr && i < n; i++) {
const cst_provider_t *p = cst_provider_at(i);
cJSON *e = cJSON_CreateObject();
if (!p || !e) {
cJSON_Delete(e);
continue;
}
int cur = strcmp(p->name, cfg->provider) == 0;
cJSON_AddStringToObject(e, "name", p->name);
cJSON_AddStringToObject(e, "api_base", cur ? cfg->api_base : p->api_base);
cJSON_AddStringToObject(e, "model", cur ? cfg->model
: (p->model ? p->model : ""));
char from[64];
cJSON_AddBoolToObject(e, "has_key",
key_source(cfg, p->name, from, sizeof(from)));
cJSON_AddStringToObject(e, "key_from", from);
cJSON_AddStringToObject(e, "key_env", p->key_env ? p->key_env : "");
cJSON_AddStringToObject(e, "source", cst_provider_source(p));
cJSON_AddBoolToObject(e, "current", cur);
cJSON_AddItemToArray(arr, e);
}
memset(cfg->api_key, 0, sizeof(cfg->api_key));
free(cfg);
return wui_send_obj(c, 200, o);
}
/* GET /api/models[?refresh=1]: the picker list for the current provider.
* A rejected key is a 401 that still carries the built-in names. */
static int h_models(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
char rf[8] = "";
cst_http_query(req, "refresh", rf, sizeof(rf));
cst_agent_config_t *snap = malloc(sizeof(*snap));
int max = CST_PS_MAX_MODELS + 32;
cst_ps_model_t *list = calloc((size_t)max, sizeof(*list));
if (!snap || !list) {
free(snap);
free(list);
return cst_http_send_err(c, 500, "out of memory");
}
if (wui_cfg_snapshot(w, snap) != 0) {
free(snap);
free(list);
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
}
int api_rc = -1;
char err[256] = "";
int n = cst_ps_models(snap, rf[0] == '1', list, max, &api_rc, err,
sizeof(err));
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddStringToObject(o, "provider", snap->provider);
cJSON_AddStringToObject(o, "current", snap->model);
cJSON_AddStringToObject(o, "api_base", snap->api_base);
cJSON_AddBoolToObject(o, "key_set", snap->api_key[0] != 0);
cJSON_AddNumberToObject(o, "api_count", api_rc >= 0 ? api_rc : -1);
if (api_rc == CST_MODELS_AUTH) {
char msg[400];
snprintf(msg, sizeof(msg), "%s 拒绝了当前 API key(%.200s),请换一个",
snap->provider, err);
cJSON_AddStringToObject(o, "error", msg);
cJSON_AddBoolToObject(o, "auth", 1);
} else if (api_rc < 0 && err[0]) {
char msg[400];
snprintf(msg, sizeof(msg), "拉取模型列表失败:%.300s", err);
cJSON_AddStringToObject(o, "api_error", msg);
}
cJSON *arr = cJSON_AddArrayToObject(o, "models");
for (int i = 0; arr && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "id", list[i].id);
cJSON_AddBoolToObject(e, "api", list[i].api);
model_info_json(e, snap->provider, list[i].id);
cJSON_AddItemToArray(arr, e);
}
}
memset(snap->api_key, 0, sizeof(snap->api_key));
free(snap);
free(list);
return wui_send_obj(c, api_rc == CST_MODELS_AUTH ? 401 : 200, o);
}
/* The answer to a key check / provider switch, in the browser's words. */
static int ps_reply(wui_t *w, cst_http_conn_t *c, int r, const char *who,
const char *err) {
char msg[512];
if (r == CST_PS_UNKNOWN) {
snprintf(msg, sizeof(msg), "没有这个提供商:%.64s", who);
return cst_http_send_err(c, 404, msg);
}
if (r == CST_MODELS_AUTH) {
snprintf(msg, sizeof(msg), "%.64s 拒绝了这个 API key(%.300s)", who, err);
return cst_http_send_err(c, 401, msg);
}
if (r < 0) {
cJSON *o = cJSON_CreateObject();
if (o) {
snprintf(msg, sizeof(msg), "%.64s 需要 API key", who);
cJSON_AddStringToObject(o, "error", msg);
cJSON_AddBoolToObject(o, "need_key", 1);
}
return wui_send_obj(c, 400, o);
}
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
cJSON_AddStringToObject(o, "provider", cfg->provider);
cJSON_AddStringToObject(o, "model", cfg->model);
msg[0] = 0;
if (r & CST_PS_UNSAVED)
snprintf(msg, sizeof(msg), "key 没能写进 credentials 文件,只在本次运行有效");
else if (r & CST_PS_UNVERIFIED)
snprintf(msg, sizeof(msg), "没能验证 key(%.300s),已先保存", err);
if (msg[0])
cJSON_AddStringToObject(o, "warning", msg);
}
wui_emit_state(w);
return wui_send_obj(c, 200, o);
}
/* Wipe a secret out of a parsed body before it is freed. */
static void wipe_str(const cJSON *s) {
if (cJSON_IsString(s) && s->valuestring)
memset(s->valuestring, 0, strlen(s->valuestring));
}
/* POST /api/provider {"name": "...", "key": "..."?} */
static int h_provider_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
cJSON *j = wui_body(c);
const cJSON *name = j ? cJSON_GetObjectItem(j, "name") : NULL;
const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL;
if (!cJSON_IsString(name) || (key && !cJSON_IsString(key))) {
wipe_str(key);
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"name\": \"提供商\", \"key\": 可选}");
}
char who[64], err[256] = "";
snprintf(who, sizeof(who), "%s", name->valuestring);
int r = cst_ps_switch_provider(cfg, who, key ? key->valuestring : NULL, err,
sizeof(err));
wipe_str(key);
cJSON_Delete(j);
if (r >= 0) {
char line[256];
snprintf(line, sizeof(line), "提供商 → %s · 模型 %s", cfg->provider,
cfg->model[0] ? cfg->model : "(未选)");
wui_emit_str(w, "status", "message", line);
}
return ps_reply(w, c, r, who, err);
}
/* POST /api/key {"key": "..."}: replace the current provider's key. */
static int h_key_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
cJSON *j = wui_body(c);
const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL;
if (!cJSON_IsString(key) || !key->valuestring[0]) {
wipe_str(key);
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"key\": \"...\"}");
}
char err[256] = "";
int r = cst_ps_set_key(cfg, key->valuestring, err, sizeof(err));
wipe_str(key);
cJSON_Delete(j);
return ps_reply(w, c, r, cfg->provider, err);
}
static int h_undo_x(cst_http_conn_t *c, void *ud) {
@@ -1140,6 +1490,8 @@ WUI_EXCLUSIVE(h_undo)
WUI_EXCLUSIVE(h_compact)
WUI_EXCLUSIVE(h_attach_upload)
WUI_EXCLUSIVE(h_attach_clear)
WUI_EXCLUSIVE(h_provider)
WUI_EXCLUSIVE(h_key)
static int h_quit(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
@@ -1360,6 +1712,10 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
{"POST", "/api/permission", h_permission, NULL, 0, 0},
{"POST", "/api/ask_user", h_ask_user, NULL, 0, 0},
{"POST", "/api/config", h_config, NULL, 0, 0},
{"GET", "/api/providers", h_providers, NULL, 0, 0},
{"GET", "/api/models", h_models, NULL, 0, 0},
{"POST", "/api/provider", h_provider, NULL, 0, 0},
{"POST", "/api/key", h_key, NULL, 0, 0},
{"POST", "/api/undo", h_undo, NULL, 0, 0},
{"POST", "/api/compact", h_compact, NULL, 0, 0},
{"POST", "/api/attach", h_attach_upload, NULL, 0, 32u * 1024u * 1024u},