2813 lines
97 KiB
C
2813 lines
97 KiB
C
/* WebUI: the HTTP + SSE front end (see include/cst_webui.h).
|
||
*
|
||
* Threading: every HTTP connection runs on its own thread (src/http_srv.c),
|
||
* a turn runs on its own worker thread, and the agent itself is only ever
|
||
* touched from the turn thread or while holding w->mu. The hooks the agent
|
||
* calls live on the turn thread: they append to the event ring, wake the
|
||
* event-stream threads and, for permission/ask_user, block until the
|
||
* browser answers (or the timeout denies it). Nothing in the engine was
|
||
* changed to make this work - the hooks are the interface the TUI uses too. */
|
||
|
||
#ifndef _DEFAULT_SOURCE
|
||
#define _DEFAULT_SOURCE
|
||
#endif
|
||
|
||
#include "cst_agent.h"
|
||
#include "cst_attach.h"
|
||
#include "cst_ext.h"
|
||
#include "cst_http.h"
|
||
#include "cst_mcp.h"
|
||
#include "cst_models.h"
|
||
#include "cst_perms.h"
|
||
#include "cst_plugins.h"
|
||
#include "cst_provider_switch.h"
|
||
#include "cst_sandbox.h"
|
||
#include "cst_skills.h"
|
||
#include "cst_subagent.h"
|
||
#include "cst_runtime.h"
|
||
#include "cst_worktree.h"
|
||
#include "cst_tools.h"
|
||
#include "cst_util.h"
|
||
#include "cst_web_md.h"
|
||
#include "cst_webui.h"
|
||
#include "cJSON.h"
|
||
#include "mm_tool.h"
|
||
#include "os_compat.h"
|
||
|
||
#include <pthread.h>
|
||
#include <signal.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <time.h>
|
||
|
||
#define WUI_RING 2000
|
||
#define WUI_TOOL_OUT 32768
|
||
#define WUI_ASK_MAX 1024
|
||
|
||
typedef struct {
|
||
const char *name;
|
||
const char *ctype;
|
||
const unsigned char *data;
|
||
size_t len;
|
||
} wui_asset_t;
|
||
|
||
#include "web_assets.inc"
|
||
|
||
typedef struct wui_sse {
|
||
struct wui *w;
|
||
cst_http_conn_t *c;
|
||
uint64_t cursor;
|
||
pthread_mutex_t mu;
|
||
pthread_cond_t cv;
|
||
int closed;
|
||
struct wui_sse *next;
|
||
} wui_sse_t;
|
||
|
||
typedef struct wui {
|
||
cst_agent_t *agent;
|
||
cst_http_server_t *srv;
|
||
char host[64];
|
||
char token[64];
|
||
int need_auth;
|
||
const char *root; /* --web-dev: serve assets from disk */
|
||
int perm_timeout_s;
|
||
int show_thinking; /* settings.json: reasoning starts expanded */
|
||
|
||
pthread_mutex_t mu;
|
||
pthread_cond_t cv; /* permission / ask_user answers */
|
||
cst_web_md_t *md; /* live markdown for the running turn */
|
||
/* event ring */
|
||
uint64_t seq;
|
||
struct {
|
||
uint64_t seq;
|
||
char *frame;
|
||
} ring[WUI_RING];
|
||
wui_sse_t *subs;
|
||
|
||
int busy;
|
||
int op_busy; /* a session/workspace/undo/compact/attach call runs */
|
||
int turn_live; /* the turn thread still uses w; cleared as it exits */
|
||
uint64_t req_id; /* permission/ask id counter */
|
||
uint64_t perm_id;
|
||
int perm_active, perm_answer;
|
||
uint64_t ask_id;
|
||
int ask_active, ask_rc; /* ask_rc: 0 pending, 1 answered, -1 dismissed */
|
||
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;
|
||
|
||
/* --- events --------------------------------------------------------------- */
|
||
|
||
static uint64_t wui_first_seq(const wui_t *w) {
|
||
return w->seq > WUI_RING ? w->seq - WUI_RING + 1 : 1;
|
||
}
|
||
|
||
/* Owns data (a cJSON object). Returns the event's seq, 0 when dropped. */
|
||
static uint64_t wui_emit(wui_t *w, const char *type, cJSON *data) {
|
||
if (!data)
|
||
data = cJSON_CreateObject();
|
||
if (!data)
|
||
return 0;
|
||
cJSON_AddStringToObject(data, "type", type);
|
||
/* the seq in the payload must match the SSE id, so it is taken under mu */
|
||
pthread_mutex_lock(&w->mu);
|
||
cJSON_AddNumberToObject(data, "seq", (double)(w->seq + 1));
|
||
char *json = cJSON_PrintUnformatted(data);
|
||
cJSON_Delete(data);
|
||
cst_buf_t f = {0};
|
||
if (json)
|
||
cst_buf_printf(&f, "event: %s\ndata: %s\n\n", type, json);
|
||
free(json);
|
||
if (!f.buf) {
|
||
pthread_mutex_unlock(&w->mu);
|
||
cst_buf_free(&f);
|
||
return 0;
|
||
}
|
||
uint64_t seq = ++w->seq;
|
||
int slot = (int)(seq % WUI_RING);
|
||
free(w->ring[slot].frame);
|
||
w->ring[slot].frame = f.buf; /* stolen */
|
||
w->ring[slot].seq = seq;
|
||
f.buf = NULL;
|
||
for (wui_sse_t *s = w->subs; s; s = s->next)
|
||
pthread_cond_broadcast(&s->cv);
|
||
pthread_mutex_unlock(&w->mu);
|
||
cst_buf_free(&f);
|
||
return seq;
|
||
}
|
||
|
||
static void wui_set_pending(wui_t *w, uint64_t seq) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->pending_seq = seq;
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void wui_emit_str(wui_t *w, const char *type, const char *key,
|
||
const char *val) {
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
return;
|
||
cJSON_AddStringToObject(o, key, val ? val : "");
|
||
wui_emit(w, type, o);
|
||
}
|
||
|
||
static void wui_emit_text(wui_t *w, const char *type, const char *text,
|
||
size_t len) {
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
return;
|
||
char *s = malloc(len + 1);
|
||
if (!s) {
|
||
cJSON_Delete(o);
|
||
return;
|
||
}
|
||
memcpy(s, text, len);
|
||
s[len] = 0;
|
||
cJSON_AddStringToObject(o, "text", s);
|
||
free(s);
|
||
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();
|
||
if (!o || !cfg) {
|
||
cJSON_Delete(o);
|
||
return NULL;
|
||
}
|
||
char sid[128] = "";
|
||
cst_agent_session_id(w->agent, sid, sizeof(sid));
|
||
cst_usage_t sess = {0}, turn = {0};
|
||
cst_agent_usage(w->agent, &sess, &turn);
|
||
cJSON_AddStringToObject(o, "protocol", "cst-webui/1");
|
||
cJSON_AddStringToObject(o, "workspace", cfg->workspace_root);
|
||
cJSON_AddStringToObject(o, "session", sid);
|
||
cJSON_AddStringToObject(o, "provider", cfg->provider);
|
||
cJSON_AddStringToObject(o, "model", cfg->model);
|
||
cJSON_AddStringToObject(o, "effort", cfg->effort);
|
||
cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode));
|
||
cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval));
|
||
cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol));
|
||
cJSON_AddBoolToObject(o, "plan_mode", cfg->plan_mode);
|
||
cJSON_AddBoolToObject(o, "show_thinking", w->show_thinking);
|
||
cJSON_AddBoolToObject(o, "status_busy", w->busy);
|
||
cJSON_AddStringToObject(o, "todo", cst_agent_todo(w->agent)
|
||
? cst_agent_todo(w->agent)
|
||
: "");
|
||
cJSON_AddNumberToObject(o, "context_tokens",
|
||
(double)cst_agent_last_prompt_tokens(w->agent));
|
||
cJSON_AddBoolToObject(o, "undo_available",
|
||
cst_agent_undo_available(w->agent) != 0);
|
||
cJSON *u = cJSON_AddObjectToObject(o, "usage");
|
||
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++) {
|
||
char line[160];
|
||
if (cst_agent_pending_describe(w->agent, i, line, sizeof(line)) == 0)
|
||
cJSON_AddItemToArray(imgs, cJSON_CreateString(line));
|
||
}
|
||
return o;
|
||
}
|
||
|
||
static void wui_emit_state(wui_t *w) {
|
||
cJSON *o = wui_state_new(w);
|
||
if (o)
|
||
wui_emit(w, "state", o);
|
||
}
|
||
|
||
/* --- session -------------------------------------------------------------- */
|
||
|
||
static cJSON *call_json(const ctl_tool_call_t *call);
|
||
|
||
/* A text-protocol reply keeps its call frames in content: the page gets the
|
||
* prose and the calls apart. */
|
||
typedef struct {
|
||
cst_buf_t text;
|
||
cJSON *calls;
|
||
int bad;
|
||
} wui_split_t;
|
||
|
||
static void split_ev(const ctl_event_t *ev, void *ud) {
|
||
wui_split_t *s = ud;
|
||
if (ev->type == CTL_EV_TEXT)
|
||
cst_buf_append(&s->text, ev->text, ev->text_len);
|
||
else if (ev->type == CTL_EV_CALL_READY && ev->call)
|
||
cJSON_AddItemToArray(s->calls, call_json(ev->call));
|
||
else if (ev->type == CTL_EV_ERROR)
|
||
s->bad = 1;
|
||
}
|
||
|
||
static int msg_protocol(const cst_message_t *m, cst_protocol_t *p) {
|
||
cJSON *o = m->meta_json ? cJSON_Parse(m->meta_json) : NULL;
|
||
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(o, "protocol");
|
||
int rc = cJSON_IsString(pr) ? cst_protocol_parse(pr->valuestring, p) : -1;
|
||
cJSON_Delete(o);
|
||
return rc;
|
||
}
|
||
|
||
/* Visible text (malloc'd) and calls of a text-protocol reply; NULL when the
|
||
* frames do not parse, and the raw text is shown as it is. */
|
||
static char *split_reply(const char *s, cst_protocol_t p, cJSON **calls) {
|
||
wui_split_t sp;
|
||
memset(&sp, 0, sizeof(sp));
|
||
sp.calls = cJSON_CreateArray();
|
||
if (!sp.calls)
|
||
return NULL;
|
||
size_t n = strlen(s);
|
||
if (p == CST_PROTO_XML) {
|
||
mm_scan_t *sc = mm_scan_create(split_ev, &sp);
|
||
sp.bad = !sc;
|
||
if (sc) {
|
||
mm_scan_feed(sc, s, n);
|
||
mm_scan_finish(sc);
|
||
mm_scan_destroy(sc);
|
||
}
|
||
} else {
|
||
ctl_parser_t *cp = ctl_parser_create(split_ev, &sp);
|
||
sp.bad = !cp;
|
||
if (cp) {
|
||
ctl_parser_feed(cp, s, n);
|
||
ctl_parser_finish(cp);
|
||
ctl_parser_destroy(cp);
|
||
}
|
||
}
|
||
if (sp.bad || !cJSON_GetArraySize(sp.calls)) {
|
||
cst_buf_free(&sp.text);
|
||
cJSON_Delete(sp.calls);
|
||
return NULL;
|
||
}
|
||
*calls = sp.calls;
|
||
char *t = cst_buf_steal(&sp.text);
|
||
return t ? t : strdup("");
|
||
}
|
||
|
||
/* Native tool_calls as [{id, name, args}], args parsed when they are JSON. */
|
||
static cJSON *native_calls(const char *tool_calls_json) {
|
||
cJSON *in = cJSON_Parse(tool_calls_json);
|
||
cJSON *out = cJSON_IsArray(in) ? cJSON_CreateArray() : NULL;
|
||
const cJSON *src = out ? in : NULL;
|
||
const cJSON *it;
|
||
cJSON_ArrayForEach(it, src) {
|
||
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(it, "function");
|
||
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
|
||
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(fn, "name");
|
||
const cJSON *ar = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
continue;
|
||
cJSON_AddStringToObject(o, "id", cJSON_IsString(id) ? id->valuestring : "");
|
||
cJSON_AddStringToObject(o, "name", cJSON_IsString(nm) ? nm->valuestring : "");
|
||
cJSON *args = cJSON_IsString(ar) ? cJSON_Parse(ar->valuestring) : NULL;
|
||
if (cJSON_IsObject(args))
|
||
cJSON_AddItemToObject(o, "args", args);
|
||
else {
|
||
cJSON_Delete(args);
|
||
cJSON_AddItemToObject(o, "args", cJSON_CreateObject());
|
||
}
|
||
cJSON_AddItemToArray(out, o);
|
||
}
|
||
cJSON_Delete(in);
|
||
return out;
|
||
}
|
||
|
||
/* Body and exit code of a stored tool result: "[tool NAME id=ID exit=N]\n"
|
||
* (native) or "result ID exit=N LEN\n...\nend" (text protocols). */
|
||
static void tool_out_view(const char *s, const char **body, size_t *len,
|
||
int *exit_code) {
|
||
size_t n = strlen(s);
|
||
*exit_code = 0;
|
||
int framed = strncmp(s, "result ", 7) == 0;
|
||
if (framed || strncmp(s, "[tool ", 6) == 0) {
|
||
const char *nl = strchr(s, '\n');
|
||
const char *ex = strstr(s, " exit=");
|
||
if (ex && (!nl || ex < nl))
|
||
*exit_code = atoi(ex + 6);
|
||
if (nl) {
|
||
n -= (size_t)(nl + 1 - s);
|
||
s = nl + 1;
|
||
} else {
|
||
n = 0;
|
||
}
|
||
if (framed && n >= 4 && strcmp(s + n - 4, "\nend") == 0)
|
||
n -= 4;
|
||
else if (framed && n == 3 && strcmp(s, "end") == 0)
|
||
n = 0;
|
||
}
|
||
*body = s;
|
||
*len = n;
|
||
}
|
||
|
||
static cJSON *msg_json(const cst_message_t *m) {
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
return NULL;
|
||
const char *role = "user";
|
||
switch (m->role) {
|
||
case CST_ROLE_SYSTEM: role = "system"; break;
|
||
case CST_ROLE_ASSISTANT: role = "assistant"; break;
|
||
case CST_ROLE_TOOL: role = "tool"; break;
|
||
default: role = "user"; break;
|
||
}
|
||
cJSON_AddStringToObject(o, "role", role);
|
||
cJSON_AddStringToObject(o, "content", m->content ? m->content : "");
|
||
cJSON *calls = NULL;
|
||
char *prose = NULL;
|
||
cst_protocol_t proto;
|
||
if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json &&
|
||
m->tool_calls_json[0])
|
||
calls = native_calls(m->tool_calls_json);
|
||
else if (m->role == CST_ROLE_ASSISTANT && m->content && m->content[0] &&
|
||
msg_protocol(m, &proto) == 0 && cst_protocol_is_text(proto))
|
||
prose = split_reply(m->content, proto, &calls);
|
||
if (prose)
|
||
cJSON_AddStringToObject(o, "text", prose);
|
||
if (calls)
|
||
cJSON_AddItemToObject(o, "calls", calls);
|
||
const char *shown = prose ? prose : m->content;
|
||
if (m->role == CST_ROLE_ASSISTANT && shown && shown[0]) {
|
||
char *html = cst_web_md_html(shown, strlen(shown));
|
||
if (html) {
|
||
cJSON_AddStringToObject(o, "html", html);
|
||
free(html);
|
||
}
|
||
}
|
||
free(prose);
|
||
if (cst_message_is_tool_result(m)) {
|
||
const char *body;
|
||
size_t blen;
|
||
int ex;
|
||
tool_out_view(m->content ? m->content : "", &body, &blen, &ex);
|
||
size_t cap = blen > 32 * 1024 ? cst_utf8_floor(body, 32 * 1024) : blen;
|
||
char *out = malloc(cap + 1);
|
||
if (out) {
|
||
memcpy(out, body, cap);
|
||
out[cap] = 0;
|
||
cJSON_AddStringToObject(o, "out", out);
|
||
free(out);
|
||
}
|
||
cJSON_AddBoolToObject(o, "truncated", cap < blen);
|
||
cJSON_AddNumberToObject(o, "exit", ex);
|
||
}
|
||
if (m->reasoning && m->reasoning[0])
|
||
cJSON_AddStringToObject(o, "reasoning", m->reasoning);
|
||
if (cst_message_is_note(m))
|
||
cJSON_AddBoolToObject(o, "note", 1);
|
||
if (cst_message_is_tool_result(m))
|
||
cJSON_AddBoolToObject(o, "tool_result", 1);
|
||
if (m->tool_call_id[0])
|
||
cJSON_AddStringToObject(o, "tool_call_id", m->tool_call_id);
|
||
if (m->tool_name[0])
|
||
cJSON_AddStringToObject(o, "tool_name", m->tool_name);
|
||
if (m->tool_calls_json && m->tool_calls_json[0]) {
|
||
cJSON *tc = cJSON_Parse(m->tool_calls_json);
|
||
if (tc) {
|
||
cJSON_AddItemToObject(o, "tool_calls", tc);
|
||
}
|
||
}
|
||
if (m->nattach > 0) {
|
||
cJSON *arr = cJSON_AddArrayToObject(o, "attach");
|
||
for (int i = 0; arr && i < m->nattach; i++) {
|
||
cJSON *a = cJSON_CreateObject();
|
||
if (!a)
|
||
continue;
|
||
cJSON_AddStringToObject(a, "name", m->attach[i].name);
|
||
cJSON_AddStringToObject(a, "mime", m->attach[i].mime);
|
||
cJSON_AddStringToObject(a, "rel", m->attach[i].rel);
|
||
cJSON_AddNumberToObject(a, "bytes", (double)m->attach[i].bytes);
|
||
cJSON_AddItemToArray(arr, a);
|
||
}
|
||
}
|
||
return o;
|
||
}
|
||
|
||
static char *wui_session_json(wui_t *w, int from) {
|
||
cst_session_t *s = cst_agent_session(w->agent);
|
||
if (!s)
|
||
return NULL;
|
||
cJSON *root = cJSON_CreateObject();
|
||
if (!root)
|
||
return NULL;
|
||
cJSON *arr = cJSON_AddArrayToObject(root, "messages");
|
||
cst_session_lock(s);
|
||
int n = cst_session_count(s);
|
||
for (int i = from; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!m)
|
||
continue;
|
||
cJSON *j = msg_json(m);
|
||
if (j && arr)
|
||
cJSON_AddItemToArray(arr, j);
|
||
else
|
||
cJSON_Delete(j);
|
||
}
|
||
cst_session_unlock(s);
|
||
char *out = cJSON_PrintUnformatted(root);
|
||
cJSON_Delete(root);
|
||
return out;
|
||
}
|
||
|
||
/* --- hooks --------------------------------------------------------------- */
|
||
|
||
static int wui_md_emit(const char *html, size_t len, void *ud) {
|
||
wui_t *w = ud;
|
||
if (len)
|
||
wui_emit_text(w, "assistant_html", html, len);
|
||
return 0;
|
||
}
|
||
|
||
static void on_status(const char *msg, void *ud) {
|
||
wui_emit_str((wui_t *)ud, "status", "message", msg);
|
||
}
|
||
|
||
static void on_assistant_delta(const char *text, size_t len, void *ud) {
|
||
wui_t *w = ud;
|
||
if (!w->md)
|
||
w->md = cst_web_md_new(wui_md_emit, w);
|
||
if (w->md)
|
||
cst_web_md_feed(w->md, 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_stream((wui_t *)ud, 0, "think_delta", text, len);
|
||
}
|
||
|
||
static cJSON *call_json(const ctl_tool_call_t *call) {
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
return NULL;
|
||
cJSON_AddStringToObject(o, "id", call->id);
|
||
cJSON_AddStringToObject(o, "name", call->name);
|
||
cJSON *args = cJSON_AddObjectToObject(o, "args");
|
||
for (int i = 0; args && i < call->nargs; i++) {
|
||
cJSON *v = cJSON_CreateString(call->args[i].value ? call->args[i].value : "");
|
||
if (v)
|
||
cJSON_AddItemToObject(args, call->args[i].key, v);
|
||
}
|
||
return o;
|
||
}
|
||
|
||
static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
|
||
const char *detail, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *o = call ? call_json(call) : cJSON_CreateObject();
|
||
if (!o)
|
||
return;
|
||
cJSON_AddStringToObject(o, "phase", phase ? phase : "");
|
||
if (detail)
|
||
cJSON_AddStringToObject(o, "detail", detail);
|
||
wui_emit(w, "tool", o);
|
||
}
|
||
|
||
static void on_tool_result(const ctl_tool_call_t *call, const char *out,
|
||
size_t len, int exit_code, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *o = call ? call_json(call) : cJSON_CreateObject();
|
||
if (!o)
|
||
return;
|
||
size_t shown = len > WUI_TOOL_OUT ? WUI_TOOL_OUT : len;
|
||
char *s = malloc(shown + 1);
|
||
if (!s) {
|
||
cJSON_Delete(o);
|
||
return;
|
||
}
|
||
memcpy(s, out, shown);
|
||
s[shown] = 0;
|
||
cJSON_AddStringToObject(o, "out", s);
|
||
cJSON_AddBoolToObject(o, "truncated", len > shown);
|
||
cJSON_AddNumberToObject(o, "bytes", (double)len);
|
||
free(s);
|
||
cJSON_AddNumberToObject(o, "exit", exit_code);
|
||
wui_emit(w, "tool_result", o);
|
||
}
|
||
|
||
static void on_todo(const char *text, void *ud) {
|
||
wui_emit_str((wui_t *)ud, "todo", "text", text ? text : "");
|
||
}
|
||
|
||
static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
|
||
const char *preview) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->req_id++;
|
||
uint64_t id = w->req_id;
|
||
w->perm_id = id;
|
||
w->perm_active = 1;
|
||
w->perm_answer = -1;
|
||
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);
|
||
cJSON_AddStringToObject(o, "tool", tool ? tool : "tool");
|
||
cJSON_AddStringToObject(o, "detail", detail ? detail : "");
|
||
cJSON_AddStringToObject(o, "preview", preview ? preview : "");
|
||
cJSON_AddBoolToObject(o, "sandbox", 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 answer = CST_PERM_DENY;
|
||
struct timespec deadline;
|
||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||
deadline.tv_sec += timeout;
|
||
pthread_mutex_lock(&w->mu);
|
||
for (;;) {
|
||
if (w->perm_active && w->perm_id == id && w->perm_answer >= 0) {
|
||
answer = w->perm_answer;
|
||
w->perm_active = 0;
|
||
break;
|
||
}
|
||
if (w->stopping)
|
||
break;
|
||
int rc = pthread_cond_timedwait(&w->cv, &w->mu, &deadline);
|
||
if (rc != 0) {
|
||
w->perm_active = 0;
|
||
break;
|
||
}
|
||
}
|
||
w->perm_active = 0;
|
||
w->pending_seq = 0;
|
||
pthread_mutex_unlock(&w->mu);
|
||
cJSON *e = cJSON_CreateObject();
|
||
if (e) {
|
||
cJSON_AddNumberToObject(e, "id", (double)id);
|
||
cJSON_AddNumberToObject(e, "answer", answer);
|
||
wui_emit(w, "permission_done", e);
|
||
}
|
||
return answer;
|
||
}
|
||
|
||
static int on_permission(const char *tool_name, const char *detail,
|
||
const char *preview, void *ud) {
|
||
return wui_wait_permission((wui_t *)ud, tool_name, detail, preview);
|
||
}
|
||
|
||
static int on_ask_user(const char *question, const char *options, char *ans,
|
||
size_t ans_sz, void *ud) {
|
||
wui_t *w = ud;
|
||
pthread_mutex_lock(&w->mu);
|
||
w->req_id++;
|
||
uint64_t id = w->req_id;
|
||
w->ask_id = id;
|
||
w->ask_active = 1;
|
||
w->ask_rc = 0;
|
||
w->ask_ans[0] = 0;
|
||
int has_client = w->subs != NULL;
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (o) {
|
||
cJSON_AddNumberToObject(o, "id", (double)id);
|
||
cJSON_AddStringToObject(o, "question", question ? question : "");
|
||
cJSON *arr = cJSON_AddArrayToObject(o, "options");
|
||
for (const char *p = options; arr && p && *p;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t n = nl ? (size_t)(nl - p) : strlen(p);
|
||
char *tmp = malloc(n + 1);
|
||
if (tmp) {
|
||
memcpy(tmp, p, n);
|
||
tmp[n] = 0;
|
||
cJSON_AddItemToArray(arr, cJSON_CreateString(tmp));
|
||
free(tmp);
|
||
}
|
||
p = nl ? nl + 1 : NULL;
|
||
}
|
||
wui_set_pending(w, wui_emit(w, "ask_user", o));
|
||
}
|
||
int timeout = has_client ? 600 : 3;
|
||
int rc = -1;
|
||
struct timespec deadline;
|
||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||
deadline.tv_sec += timeout;
|
||
pthread_mutex_lock(&w->mu);
|
||
for (;;) {
|
||
if (w->ask_active && w->ask_id == id && w->ask_rc != 0) {
|
||
rc = w->ask_rc == 1 ? 0 : -1;
|
||
snprintf(ans, ans_sz, "%s", w->ask_ans);
|
||
w->ask_active = 0;
|
||
break;
|
||
}
|
||
if (w->stopping)
|
||
break;
|
||
int wrc = pthread_cond_timedwait(&w->cv, &w->mu, &deadline);
|
||
if (wrc != 0) {
|
||
w->ask_active = 0;
|
||
break;
|
||
}
|
||
}
|
||
w->ask_active = 0;
|
||
w->pending_seq = 0;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return rc;
|
||
}
|
||
|
||
/* --- routes -------------------------------------------------------------- */
|
||
|
||
static int read_file_all(const char *path, char **out, size_t *out_len) {
|
||
FILE *fp = fopen(path, "rb");
|
||
if (!fp)
|
||
return -1;
|
||
cst_buf_t b = {0};
|
||
char tmp[8192];
|
||
size_t n;
|
||
while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0) {
|
||
if (cst_buf_append(&b, tmp, n) != 0) {
|
||
fclose(fp);
|
||
cst_buf_free(&b);
|
||
return -1;
|
||
}
|
||
}
|
||
fclose(fp);
|
||
size_t total = b.len;
|
||
*out = cst_buf_steal(&b);
|
||
if (!*out)
|
||
return -1;
|
||
*out_len = total;
|
||
return 0;
|
||
}
|
||
|
||
static int wui_serve_file(cst_http_conn_t *c, wui_t *w, const char *path) {
|
||
if (!w->root || !path || !path[0] || strstr(path, ".."))
|
||
return -1;
|
||
char full[4096];
|
||
const char *rel = path[0] == '/' ? path + 1 : path;
|
||
if (!rel[0])
|
||
rel = "index.html";
|
||
if (snprintf(full, sizeof(full), "%s/%s", w->root, rel) >=
|
||
(int)sizeof(full))
|
||
return -1;
|
||
char *data = NULL;
|
||
size_t len = 0;
|
||
if (read_file_all(full, &data, &len) != 0)
|
||
return -1;
|
||
const char *ctype = "application/octet-stream";
|
||
size_t n = strlen(rel);
|
||
if (n > 5 && strcmp(rel + n - 5, ".html") == 0)
|
||
ctype = "text/html; charset=utf-8";
|
||
else if (n > 3 && strcmp(rel + n - 3, ".js") == 0)
|
||
ctype = "text/javascript; charset=utf-8";
|
||
else if (n > 4 && strcmp(rel + n - 4, ".css") == 0)
|
||
ctype = "text/css; charset=utf-8";
|
||
else if (n > 4 && strcmp(rel + n - 4, ".svg") == 0)
|
||
ctype = "image/svg+xml";
|
||
int rc = cst_http_send(c, 200, ctype, data, len);
|
||
free(data);
|
||
return rc;
|
||
}
|
||
|
||
static int wui_serve_asset(cst_http_conn_t *c, wui_t *w, const char *path) {
|
||
const char *rel = path[0] == '/' ? path + 1 : path;
|
||
if (!rel[0])
|
||
rel = "index.html";
|
||
if (w->root)
|
||
return wui_serve_file(c, w, path);
|
||
for (int i = 0; i < wui_asset_count; i++) {
|
||
if (strcmp(wui_assets[i].name, rel) == 0)
|
||
return cst_http_send(c, 200, wui_assets[i].ctype, wui_assets[i].data,
|
||
wui_assets[i].len);
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
static int wui_auth(const cst_http_req_t *req, void *ud);
|
||
|
||
static int h_asset(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
/* Stored session images: /api/attach/<rel>. The name is validated before
|
||
* it can touch the filesystem, and only the session's own directory is
|
||
* reachable. */
|
||
static const char prefix[] = "/api/attach/";
|
||
if (strncmp(req->path, prefix, sizeof(prefix) - 1) == 0) {
|
||
/* the fallback route is public (the page's own assets); images are not */
|
||
if (wui_auth(req, w) != 0)
|
||
return cst_http_send_err(c, 401, "unauthorized");
|
||
const char *rel = req->path + sizeof(prefix) - 1;
|
||
if (!cst_attach_rel_ok(rel))
|
||
return -1;
|
||
const char *sess = cst_agent_session_path(w->agent);
|
||
char dir[OS_PATH_MAX], full[OS_PATH_MAX];
|
||
if (!sess || cst_attach_dir(sess, dir, sizeof(dir)) != 0)
|
||
return -1;
|
||
if (snprintf(full, sizeof(full), "%s/%s", dir, rel) >= (int)sizeof(full))
|
||
return -1;
|
||
char *data = NULL;
|
||
size_t len = 0;
|
||
if (read_file_all(full, &data, &len) != 0)
|
||
return -1;
|
||
const char *ctype = "application/octet-stream";
|
||
size_t n = strlen(rel);
|
||
if (n > 4 && strcmp(rel + n - 4, ".png") == 0)
|
||
ctype = "image/png";
|
||
else if ((n > 4 && strcmp(rel + n - 4, ".jpg") == 0) ||
|
||
(n > 5 && strcmp(rel + n - 5, ".jpeg") == 0))
|
||
ctype = "image/jpeg";
|
||
else if (n > 4 && strcmp(rel + n - 4, ".gif") == 0)
|
||
ctype = "image/gif";
|
||
else if (n > 5 && strcmp(rel + n - 5, ".webp") == 0)
|
||
ctype = "image/webp";
|
||
else if (n > 4 && strcmp(rel + n - 4, ".bmp") == 0)
|
||
ctype = "image/bmp";
|
||
int rc = cst_http_send(c, 200, ctype, data, len);
|
||
free(data);
|
||
return rc;
|
||
}
|
||
return wui_serve_asset(c, w, req->path);
|
||
}
|
||
|
||
/* --- API handlers -------------------------------------------------------- */
|
||
|
||
static int h_state(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
pthread_mutex_lock(&w->mu);
|
||
cJSON *o = wui_state_new(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
if (!o)
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
char *json = cJSON_PrintUnformatted(o);
|
||
cJSON_Delete(o);
|
||
if (!json)
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
int rc = cst_http_send_json(c, 200, json);
|
||
free(json);
|
||
return rc;
|
||
}
|
||
|
||
static int h_session(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
char buf[32];
|
||
int from = 0;
|
||
if (cst_http_query(cst_http_request(c), "from", buf, sizeof(buf)) == 0)
|
||
from = atoi(buf);
|
||
if (from < 0)
|
||
from = 0;
|
||
char *json = wui_session_json(w, from);
|
||
if (!json)
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
int rc = cst_http_send_json(c, 200, json);
|
||
free(json);
|
||
return rc;
|
||
}
|
||
|
||
static int h_events(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
wui_sse_t s;
|
||
memset(&s, 0, sizeof(s));
|
||
s.w = w;
|
||
s.c = c;
|
||
s.cursor = 0;
|
||
char buf[32];
|
||
if (cst_http_query(req, "since", buf, sizeof(buf)) == 0)
|
||
s.cursor = (uint64_t)strtoull(buf, NULL, 10);
|
||
else if (req->last_event_id)
|
||
s.cursor = (uint64_t)strtoull(req->last_event_id, NULL, 10);
|
||
pthread_mutex_init(&s.mu, NULL);
|
||
pthread_cond_init(&s.cv, NULL);
|
||
pthread_mutex_lock(&w->mu);
|
||
/* 0 = a fresh client wants new events only; past the head = ids from an
|
||
* earlier server process */
|
||
if (s.cursor == 0 || s.cursor > w->seq) {
|
||
s.cursor = w->seq;
|
||
/* a page opened while a question waits must still get to answer it */
|
||
if (w->pending_seq && w->pending_seq <= w->seq)
|
||
s.cursor = w->pending_seq - 1;
|
||
}
|
||
if (s.cursor < wui_first_seq(w) - 1)
|
||
s.cursor = wui_first_seq(w) - 1; /* too far behind: resume at the head */
|
||
s.next = w->subs;
|
||
w->subs = &s;
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
if (cst_http_stream_begin(c, "text/event-stream; charset=utf-8") != 0) {
|
||
goto done;
|
||
}
|
||
cst_http_stream_write(c, ": cst-agent webui\n\n", 19);
|
||
for (;;) {
|
||
if (cst_http_stopping(w->srv) || w->stopping || s.closed)
|
||
break;
|
||
cst_buf_t out = {0};
|
||
pthread_mutex_lock(&w->mu);
|
||
uint64_t last = w->seq;
|
||
uint64_t first = wui_first_seq(w);
|
||
int n = 0;
|
||
for (uint64_t q = s.cursor + 1; q <= last && n < 256; q++) {
|
||
if (q < first)
|
||
continue;
|
||
char *frame = w->ring[q % WUI_RING].frame;
|
||
if (!frame)
|
||
continue;
|
||
cst_buf_printf(&out, "id: %llu\n%s", (unsigned long long)q, frame);
|
||
n++;
|
||
s.cursor = q;
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
if (n == 0) {
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_REALTIME, &ts);
|
||
ts.tv_sec += 1;
|
||
pthread_mutex_lock(&s.mu);
|
||
if (!s.closed)
|
||
pthread_cond_timedwait(&s.cv, &s.mu, &ts);
|
||
pthread_mutex_unlock(&s.mu);
|
||
cst_buf_free(&out);
|
||
if (cst_http_stream_write(c, ": ping\n\n", 8) != 0)
|
||
break;
|
||
continue;
|
||
}
|
||
int rc = cst_http_stream_write(c, out.buf, out.len);
|
||
cst_buf_free(&out);
|
||
if (rc != 0)
|
||
break;
|
||
}
|
||
done:
|
||
pthread_mutex_lock(&w->mu);
|
||
wui_sse_t **pp = &w->subs;
|
||
while (*pp && *pp != &s)
|
||
pp = &(*pp)->next;
|
||
if (*pp == &s)
|
||
*pp = s.next;
|
||
pthread_mutex_unlock(&w->mu);
|
||
pthread_mutex_destroy(&s.mu);
|
||
pthread_cond_destroy(&s.cv);
|
||
return 0;
|
||
}
|
||
|
||
typedef struct {
|
||
wui_t *w;
|
||
char *prompt;
|
||
} wui_turn_t;
|
||
|
||
static void wui_end_turn(wui_t *w, int rc, const char *err) {
|
||
/* Flush the markdown *outside* the lock: flushing emits the last HTML
|
||
* chunks, and wui_emit takes w->mu itself. */
|
||
pthread_mutex_lock(&w->mu);
|
||
cst_web_md_t *md = w->md;
|
||
w->md = NULL;
|
||
w->busy = 0;
|
||
pthread_mutex_unlock(&w->mu);
|
||
if (md) {
|
||
cst_web_md_finish(md);
|
||
cst_web_md_free(md);
|
||
}
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (o) {
|
||
cJSON_AddNumberToObject(o, "rc", rc);
|
||
cJSON_AddStringToObject(o, "error", err ? err : "");
|
||
wui_emit(w, "turn_end", o);
|
||
}
|
||
wui_emit_state(w);
|
||
}
|
||
|
||
static void *wui_turn_main(void *arg) {
|
||
wui_turn_t *t = arg;
|
||
wui_t *w = t->w;
|
||
cst_agent_hooks_t hooks;
|
||
memset(&hooks, 0, sizeof(hooks));
|
||
hooks.on_status = on_status;
|
||
hooks.on_assistant_delta = on_assistant_delta;
|
||
hooks.on_think_delta = on_think_delta;
|
||
hooks.on_tool_event = on_tool_event;
|
||
hooks.on_tool_result = on_tool_result;
|
||
hooks.on_permission = on_permission;
|
||
hooks.on_ask_user = on_ask_user;
|
||
hooks.on_todo = on_todo;
|
||
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);
|
||
pthread_mutex_lock(&w->mu);
|
||
w->turn_live = 0;
|
||
pthread_cond_broadcast(&w->cv);
|
||
pthread_mutex_unlock(&w->mu);
|
||
return NULL;
|
||
}
|
||
|
||
/* claimed: the caller holds op_busy (wui_claim); it passes to the turn, and is
|
||
* released either way. */
|
||
static int wui_start_turn(wui_t *w, const char *text, int claimed, char *err,
|
||
size_t err_sz) {
|
||
pthread_mutex_lock(&w->mu);
|
||
if (claimed)
|
||
w->op_busy = 0;
|
||
else if (w->busy || w->op_busy) {
|
||
pthread_mutex_unlock(&w->mu);
|
||
snprintf(err, err_sz, w->busy ? "a turn is already running"
|
||
: "another change is running");
|
||
return -1;
|
||
}
|
||
w->busy = 1;
|
||
if (w->md) {
|
||
cst_web_md_free(w->md);
|
||
w->md = NULL;
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
wui_turn_t *t = calloc(1, sizeof(*t));
|
||
if (!t) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->busy = 0;
|
||
pthread_mutex_unlock(&w->mu);
|
||
snprintf(err, err_sz, "out of memory");
|
||
return -1;
|
||
}
|
||
t->w = w;
|
||
t->prompt = strdup(text ? text : "");
|
||
if (!t->prompt) {
|
||
free(t);
|
||
pthread_mutex_lock(&w->mu);
|
||
w->busy = 0;
|
||
pthread_mutex_unlock(&w->mu);
|
||
snprintf(err, err_sz, "out of memory");
|
||
return -1;
|
||
}
|
||
pthread_t th;
|
||
pthread_mutex_lock(&w->mu);
|
||
w->turn_live = 1;
|
||
pthread_mutex_unlock(&w->mu);
|
||
if (pthread_create(&th, NULL, wui_turn_main, t) != 0) {
|
||
free(t->prompt);
|
||
free(t);
|
||
pthread_mutex_lock(&w->mu);
|
||
w->busy = 0;
|
||
w->turn_live = 0;
|
||
pthread_mutex_unlock(&w->mu);
|
||
snprintf(err, err_sz, "cannot start the turn thread");
|
||
return -1;
|
||
}
|
||
pthread_detach(th);
|
||
wui_emit_state(w);
|
||
return 0;
|
||
}
|
||
|
||
static int h_turn(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL;
|
||
const cJSON *text = body ? cJSON_GetObjectItem(body, "text") : NULL;
|
||
if (!body || !cJSON_IsString(text)) {
|
||
cJSON_Delete(body);
|
||
return cst_http_send_err(c, 400, "expected {\"text\": \"...\"}");
|
||
}
|
||
char err[256] = "";
|
||
int rc = wui_start_turn(w, text->valuestring, 0, err, sizeof(err));
|
||
cJSON_Delete(body);
|
||
if (rc != 0)
|
||
return cst_http_send_err(c, 409, err);
|
||
return cst_http_send_str(c, 202, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
/* The agent is not locked: switching sessions, the workspace, undo, compact
|
||
* and the pending images all change what a running turn reads. These run
|
||
* one at a time and never during a turn. */
|
||
static int wui_claim(wui_t *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
int ok = !w->busy && !w->op_busy;
|
||
if (ok)
|
||
w->op_busy = 1;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return ok ? 0 : -1;
|
||
}
|
||
|
||
static void wui_release(wui_t *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->op_busy = 0;
|
||
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; \
|
||
if (wui_claim(w) != 0) \
|
||
return cst_http_send_err(c, 409, "busy: wait for the running turn"); \
|
||
int rc = name##_x(c, ud); \
|
||
wui_release(w); \
|
||
return rc; \
|
||
}
|
||
|
||
static int h_cancel(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cst_agent_request_cancel(w->agent);
|
||
/* a turn parked on the gate or a question would not see the flag */
|
||
pthread_mutex_lock(&w->mu);
|
||
if (w->perm_active && w->perm_answer < 0)
|
||
w->perm_answer = CST_PERM_DENY;
|
||
if (w->ask_active && w->ask_rc == 0)
|
||
w->ask_rc = -1;
|
||
pthread_cond_broadcast(&w->cv);
|
||
pthread_mutex_unlock(&w->mu);
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
static int h_permission(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL;
|
||
const cJSON *id = body ? cJSON_GetObjectItem(body, "id") : NULL;
|
||
const cJSON *ans = body ? cJSON_GetObjectItem(body, "answer") : NULL;
|
||
int ok = 0;
|
||
if (cJSON_IsNumber(id) && cJSON_IsNumber(ans)) {
|
||
int answer = (int)ans->valuedouble;
|
||
if (answer >= CST_PERM_DENY && answer <= CST_PERM_ALWAYS_PREFIX) {
|
||
pthread_mutex_lock(&w->mu);
|
||
if (w->perm_active && w->perm_id == (uint64_t)id->valuedouble) {
|
||
w->perm_answer = answer;
|
||
pthread_cond_broadcast(&w->cv);
|
||
ok = 1;
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
}
|
||
cJSON_Delete(body);
|
||
if (!ok)
|
||
return cst_http_send_err(c, 409, "no such permission request");
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
static int h_ask_user(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL;
|
||
const cJSON *id = body ? cJSON_GetObjectItem(body, "id") : NULL;
|
||
const cJSON *text = body ? cJSON_GetObjectItem(body, "text") : NULL;
|
||
const cJSON *dismissed = body ? cJSON_GetObjectItem(body, "dismiss") : NULL;
|
||
int ok = 0;
|
||
if (cJSON_IsNumber(id)) {
|
||
pthread_mutex_lock(&w->mu);
|
||
if (w->ask_active && w->ask_id == (uint64_t)id->valuedouble) {
|
||
snprintf(w->ask_ans, sizeof(w->ask_ans), "%s",
|
||
cJSON_IsString(text) ? text->valuestring : "");
|
||
w->ask_rc = (dismissed && cJSON_IsTrue(dismissed)) ? -1 : 1;
|
||
pthread_cond_broadcast(&w->cv);
|
||
ok = 1;
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
cJSON_Delete(body);
|
||
if (!ok)
|
||
return cst_http_send_err(c, 409, "no such question");
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
static int h_sessions(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
|
||
char (*ids)[128] = malloc(200 * sizeof(*ids)); /* handlers run in parallel */
|
||
if (!ids)
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
int n = cst_agent_store_list(cfg->workspace_root, ids, 200);
|
||
cJSON *root = cJSON_CreateObject();
|
||
if (!root) {
|
||
free(ids);
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
}
|
||
char cur[128] = "";
|
||
cst_agent_store_get_current(cfg->workspace_root, cur, sizeof(cur));
|
||
cJSON_AddStringToObject(root, "current", cur);
|
||
cJSON *arr = cJSON_AddArrayToObject(root, "sessions");
|
||
for (int i = 0; arr && i < n; i++) {
|
||
cst_session_info_t in;
|
||
if (cst_agent_store_info(cfg->workspace_root, ids[i], &in) != 0)
|
||
continue;
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
continue;
|
||
cJSON_AddStringToObject(o, "id", in.id);
|
||
cJSON_AddStringToObject(o, "title",
|
||
in.title[0] ? in.title : in.first_user);
|
||
cJSON_AddNumberToObject(o, "messages", in.messages);
|
||
cJSON_AddNumberToObject(o, "mtime", (double)in.mtime);
|
||
cJSON_AddItemToArray(arr, o);
|
||
}
|
||
free(ids);
|
||
char *json = cJSON_PrintUnformatted(root);
|
||
cJSON_Delete(root);
|
||
int rc = cst_http_send_json(c, 200, json);
|
||
free(json);
|
||
return rc;
|
||
}
|
||
|
||
static int h_session_new_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
char id[128] = "";
|
||
if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0)
|
||
return cst_http_send_err(c, 500, "cannot start a session");
|
||
wui_emit_str(w, "session", "id", id);
|
||
wui_emit_state(w);
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
cJSON_AddStringToObject(o, "id", id);
|
||
char *json = cJSON_PrintUnformatted(o);
|
||
cJSON_Delete(o);
|
||
int rc = cst_http_send_json(c, 200, json);
|
||
free(json);
|
||
return rc;
|
||
}
|
||
|
||
/* POST /api/workspace {"path":"/abs/dir"} - re-root the agent. The session
|
||
* store is per directory, so this always lands in a fresh session there. */
|
||
static int h_workspace_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
|
||
const cJSON *p = j ? cJSON_GetObjectItem(j, "path") : NULL;
|
||
if (!cJSON_IsString(p) || !p->valuestring[0]) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, "path is required");
|
||
}
|
||
char err[256] = "";
|
||
int rc = cst_agent_set_workspace(w->agent, p->valuestring, err, sizeof(err));
|
||
cJSON_Delete(j);
|
||
if (rc != 0)
|
||
return cst_http_send_err(c, 400, err[0] ? err : "cannot switch workspace");
|
||
/* MCP servers, plugins and the task tool are bound to a workspace */
|
||
cst_extensions_unload();
|
||
cst_extensions_load(cst_agent_config(w->agent), NULL, 0);
|
||
char id[128] = "";
|
||
if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0)
|
||
return cst_http_send_err(c, 500, "cannot start a session there");
|
||
wui_emit_str(w, "session", "id", id);
|
||
wui_emit_state(w);
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o)
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
cJSON_AddStringToObject(o, "id", id);
|
||
char *json = cJSON_PrintUnformatted(o);
|
||
cJSON_Delete(o);
|
||
rc = cst_http_send_json(c, 200, json);
|
||
free(json);
|
||
return rc;
|
||
}
|
||
|
||
static int h_session_switch_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
|
||
const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL;
|
||
int rc = -2;
|
||
if (cJSON_IsString(id))
|
||
rc = cst_agent_switch_session(w->agent, id->valuestring);
|
||
cJSON_Delete(j);
|
||
if (rc != 0)
|
||
return cst_http_send_err(c, 404, "no such session");
|
||
wui_emit_state(w);
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
/* Like wui_claim but a running turn is fine: for work that reads the session
|
||
* object (a turn only appends to it, under its lock) or the store. */
|
||
static int wui_claim_read(wui_t *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
int ok = !w->op_busy;
|
||
if (ok)
|
||
w->op_busy = 1;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return ok ? 0 : -1;
|
||
}
|
||
|
||
/* POST /api/sessions/rename {"id"?: "...", "title": "..."}; "" clears it. */
|
||
static int h_session_rename(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL;
|
||
const cJSON *title = j ? cJSON_GetObjectItem(j, "title") : NULL;
|
||
if (!cJSON_IsString(title) || (id && !cJSON_IsString(id))) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, "需要 {\"title\": \"…\", \"id\": 可选}");
|
||
}
|
||
if (wui_claim_read(w) != 0) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 409, "another change is running");
|
||
}
|
||
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
|
||
char sid[128], t[121];
|
||
if (id && id->valuestring[0])
|
||
snprintf(sid, sizeof(sid), "%s", id->valuestring);
|
||
else
|
||
cst_agent_session_id(w->agent, sid, sizeof(sid));
|
||
size_t tn = strlen(title->valuestring);
|
||
if (tn > sizeof(t) - 1)
|
||
tn = cst_utf8_floor(title->valuestring, sizeof(t) - 1);
|
||
memcpy(t, title->valuestring, tn);
|
||
t[tn] = 0;
|
||
cJSON_Delete(j);
|
||
cst_session_info_t in;
|
||
int rc = cst_agent_store_info(cfg->workspace_root, sid, &in) != 0 ? 404
|
||
: cst_agent_store_title_set(cfg->workspace_root, sid, t) != 0 ? 500
|
||
: 200;
|
||
wui_release(w);
|
||
if (rc == 404)
|
||
return cst_http_send_err(c, 404, "no such session");
|
||
if (rc == 500)
|
||
return cst_http_send_err(c, 500, "cannot set the title");
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
/* POST /api/sessions/delete {"id": "..."}. Deleting the open session first
|
||
* moves to a new one. */
|
||
static int h_session_delete_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL;
|
||
if (!cJSON_IsString(id) || !id->valuestring[0]) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, "需要 {\"id\": \"…\"}");
|
||
}
|
||
char victim[128];
|
||
snprintf(victim, sizeof(victim), "%s", id->valuestring);
|
||
cJSON_Delete(j);
|
||
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
|
||
cst_session_info_t in;
|
||
if (cst_agent_store_info(cfg->workspace_root, victim, &in) != 0)
|
||
return cst_http_send_err(c, 404, "no such session");
|
||
char cur[128];
|
||
cst_agent_session_id(w->agent, cur, sizeof(cur));
|
||
int was_current = strcmp(cur, victim) == 0;
|
||
if (was_current) {
|
||
if (cst_agent_new_session(w->agent, cur, sizeof(cur)) != 0)
|
||
return cst_http_send_err(c, 500, "cannot start a session");
|
||
wui_emit_str(w, "session", "id", cur);
|
||
wui_emit_state(w);
|
||
}
|
||
if (cst_agent_store_delete(cfg->workspace_root, victim) != 0)
|
||
return cst_http_send_err(c, 500, "cannot delete the session");
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (o) {
|
||
cJSON_AddBoolToObject(o, "ok", 1);
|
||
cJSON_AddStringToObject(o, "current", cur);
|
||
}
|
||
return wui_send_obj(c, 200, o);
|
||
}
|
||
|
||
/* POST /api/retry {"text"?: "..."}: drop the last prompt and everything after
|
||
* it, then run it again, or text in its place. File changes are kept. */
|
||
static int h_retry(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *text = j ? cJSON_GetObjectItem(j, "text") : NULL;
|
||
if (text && (!cJSON_IsString(text) || !text->valuestring[0])) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, "text 不能为空");
|
||
}
|
||
if (wui_claim(w) != 0) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 409, "busy: wait for the running turn");
|
||
}
|
||
char *prompt = cst_agent_rewind_last_turn(w->agent);
|
||
if (!prompt) {
|
||
wui_release(w);
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 409, "nothing to retry");
|
||
}
|
||
const char *use = text ? text->valuestring : prompt;
|
||
char err[256] = "";
|
||
int rc = wui_start_turn(w, use, 1, err, sizeof(err));
|
||
cJSON *o = rc == 0 ? cJSON_CreateObject() : NULL;
|
||
if (o) {
|
||
cJSON_AddBoolToObject(o, "ok", 1);
|
||
cJSON_AddStringToObject(o, "text", use);
|
||
}
|
||
free(prompt);
|
||
cJSON_Delete(j);
|
||
if (rc != 0) {
|
||
cJSON_Delete(o);
|
||
return cst_http_send_err(c, 500, err);
|
||
}
|
||
return wui_send_obj(c, 202, o);
|
||
}
|
||
|
||
/* GET /api/export: the open session as Markdown. */
|
||
static int h_export(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim_read(w) != 0)
|
||
return cst_http_send_err(c, 409, "another change is running");
|
||
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(w->agent, sid, sizeof(sid));
|
||
cst_session_info_t in;
|
||
const char *title = sid;
|
||
if (cst_agent_store_info(cfg->workspace_root, sid, &in) == 0)
|
||
title = in.title[0] ? in.title : in.first_user[0] ? in.first_user : sid;
|
||
char *md = cst_session_export_md(cst_agent_session(w->agent), title, sid,
|
||
cfg->model);
|
||
wui_release(w);
|
||
if (!md)
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
int rc = cst_http_send_str(c, 200, "text/markdown; charset=utf-8", md);
|
||
free(md);
|
||
return rc;
|
||
}
|
||
|
||
static int h_config(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
|
||
if (!j || !cfg) {
|
||
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 cJSON *think = cJSON_GetObjectItem(j, "show_thinking");
|
||
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 && think && !cJSON_IsBool(think))
|
||
bad = "show_thinking 必须是 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 = model || effort;
|
||
if (claimed && wui_claim(w) != 0) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 409, "正在运行,等这一轮结束再改模型");
|
||
}
|
||
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 (plan)
|
||
cfg->plan_mode = cJSON_IsTrue(plan) ? 1 : 0;
|
||
if (think) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->show_thinking = cJSON_IsTrue(think) ? 1 : 0;
|
||
pthread_mutex_unlock(&w->mu);
|
||
cst_settings_remember_thinking(cJSON_IsTrue(think));
|
||
}
|
||
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 wui_send_obj(c, 200, o);
|
||
}
|
||
|
||
/* GET /api/diff[?path=rel]: git status and the working-tree diff. git runs
|
||
* without a shell, external diff drivers or textconv, for at most 10 s. */
|
||
#define WUI_DIFF_CAP (512u * 1024u)
|
||
static int h_diff(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
char path[1024] = "";
|
||
cst_http_query(cst_http_request(c), "path", path, sizeof(path));
|
||
char ws[sizeof(((cst_agent_config_t *)0)->workspace_root)];
|
||
pthread_mutex_lock(&w->mu);
|
||
int ok = !w->op_busy;
|
||
if (ok)
|
||
snprintf(ws, sizeof(ws), "%s", cst_agent_config(w->agent)->workspace_root);
|
||
pthread_mutex_unlock(&w->mu);
|
||
if (!ok)
|
||
return cst_http_send_err(c, 409, "another change is running");
|
||
os_set_run_timeout(10);
|
||
os_run_result_t st, df;
|
||
memset(&st, 0, sizeof(st));
|
||
memset(&df, 0, sizeof(df));
|
||
const char *sv[] = {"git", "status", "--short", "--untracked-files=all", NULL};
|
||
int git = os_run_capture(ws, sv, 64 * 1024, &st) == 0 && st.exit_code == 0;
|
||
const char *dv[] = {"git", "--no-pager", "diff", "--no-color",
|
||
"--no-ext-diff", "--no-textconv", "--",
|
||
path[0] ? path : NULL, NULL};
|
||
if (git)
|
||
os_run_capture(ws, dv, WUI_DIFF_CAP, &df);
|
||
os_set_run_timeout(0);
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (o) {
|
||
cJSON_AddBoolToObject(o, "git", git);
|
||
cJSON_AddStringToObject(o, "path", path);
|
||
if (git) {
|
||
cJSON *files = cJSON_AddArrayToObject(o, "files");
|
||
const char *s = st.out ? st.out : "";
|
||
while (files && *s) {
|
||
size_t n = strcspn(s, "\n");
|
||
if (n > 3) {
|
||
char code[3] = {s[0], s[1], 0}, name[1024];
|
||
snprintf(name, sizeof(name), "%.*s", (int)(n - 3), s + 3);
|
||
cJSON *f = cJSON_CreateObject();
|
||
if (f) {
|
||
cJSON_AddStringToObject(f, "status", code);
|
||
cJSON_AddStringToObject(f, "path", name);
|
||
cJSON_AddItemToArray(files, f);
|
||
}
|
||
}
|
||
s += n + (s[n] == '\n');
|
||
}
|
||
size_t dl = df.out ? cst_utf8_floor(df.out, df.out_len) : 0;
|
||
char *d = malloc(dl + 1);
|
||
if (d) {
|
||
memcpy(d, df.out ? df.out : "", dl);
|
||
d[dl] = 0;
|
||
cJSON_AddStringToObject(o, "diff", d);
|
||
free(d);
|
||
}
|
||
cJSON_AddBoolToObject(o, "truncated", df.out_len >= WUI_DIFF_CAP);
|
||
if (df.exit_code != 0 && df.exit_code != 1)
|
||
cJSON_AddNumberToObject(o, "exit", df.exit_code);
|
||
}
|
||
}
|
||
os_run_result_free(&st);
|
||
os_run_result_free(&df);
|
||
return wui_send_obj(c, 200, o);
|
||
}
|
||
|
||
static const char *const wui_perm_names[] = {"read", "write", "shell",
|
||
"net", "ask", "meta"};
|
||
|
||
/* GET /api/settings: what POST /api/settings can change, plus the sandbox. */
|
||
static int h_settings_get(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");
|
||
/* held to the end: an extensions reload frees the tools listed below */
|
||
if (wui_claim_read(w) != 0) {
|
||
free(cfg);
|
||
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
|
||
}
|
||
*cfg = *cst_agent_config(w->agent);
|
||
memset(cfg->api_key, 0, sizeof(cfg->api_key));
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (!o) {
|
||
wui_release(w);
|
||
free(cfg);
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
}
|
||
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(cfg->protocol));
|
||
cJSON *ps = cJSON_AddArrayToObject(o, "protocols");
|
||
for (int p = CST_PROTO_NATIVE_EOS; ps && p <= CST_PROTO_XML; p++)
|
||
cJSON_AddItemToArray(ps, cJSON_CreateString(cst_protocol_name(p)));
|
||
cJSON_AddNumberToObject(o, "max_steps", cfg->max_steps);
|
||
cJSON_AddStringToObject(o, "tool_profile",
|
||
cst_tool_profile_name(cfg->tool_profile));
|
||
cJSON *pr = cJSON_AddArrayToObject(o, "profiles");
|
||
for (int p = CST_TOOLS_DEFAULT; pr && p <= CST_TOOLS_MINIMAL; p++)
|
||
cJSON_AddItemToArray(pr, cJSON_CreateString(cst_tool_profile_name(p)));
|
||
cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode));
|
||
cJSON *md = cJSON_AddArrayToObject(o, "modes");
|
||
if (md) {
|
||
cJSON_AddItemToArray(md, cJSON_CreateString("coding"));
|
||
cJSON_AddItemToArray(md, cJSON_CreateString("general"));
|
||
}
|
||
cJSON *cf = cJSON_AddObjectToObject(o, "confirm");
|
||
if (cf) {
|
||
cJSON_AddBoolToObject(cf, "shell", cfg->confirm_shell);
|
||
cJSON_AddBoolToObject(cf, "write", cfg->confirm_write);
|
||
cJSON_AddBoolToObject(cf, "net", cfg->confirm_net);
|
||
}
|
||
cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval));
|
||
cJSON *tools = cJSON_AddArrayToObject(o, "tools");
|
||
for (int i = 0; tools && i < cst_tool_count(); i++) {
|
||
const cst_tool_def_t *d = cst_tool_at(i);
|
||
cJSON *t = d ? cJSON_CreateObject() : NULL;
|
||
if (!t)
|
||
continue;
|
||
const char *desc = d->desc ? d->desc : "";
|
||
char first[160];
|
||
size_t dl = strcspn(desc, ".\n");
|
||
dl = cst_utf8_floor(desc, dl < sizeof(first) - 1 ? dl : sizeof(first) - 1);
|
||
memcpy(first, desc, dl);
|
||
first[dl] = 0;
|
||
cJSON_AddStringToObject(t, "name", d->name);
|
||
cJSON_AddStringToObject(t, "perm", (int)d->perm < 6 ? wui_perm_names[d->perm]
|
||
: "?");
|
||
cJSON_AddStringToObject(t, "perm_name", cst_tool_perm_name(d));
|
||
cJSON_AddBoolToObject(t, "on", cst_tool_active(cfg, d));
|
||
cJSON_AddBoolToObject(t, "off", cst_tool_disabled(cfg, d->name));
|
||
cJSON_AddStringToObject(t, "desc", first);
|
||
if (d->platforms) {
|
||
cJSON *pl = cJSON_AddArrayToObject(t, "platforms");
|
||
for (int k = 0; pl && d->platforms[k]; k++)
|
||
cJSON_AddItemToArray(pl, cJSON_CreateString(d->platforms[k]));
|
||
}
|
||
char why[160] = "";
|
||
if (cst_tool_available(d, why, sizeof(why)) != 0) {
|
||
cJSON_AddBoolToObject(t, "available", 0);
|
||
cJSON_AddStringToObject(t, "why", why);
|
||
} else {
|
||
cJSON_AddBoolToObject(t, "available", 1);
|
||
}
|
||
if (d->params[0].name) {
|
||
cJSON *pa = cJSON_AddArrayToObject(t, "params");
|
||
for (int k = 0; pa && k < CST_TOOL_MAX_PARAMS && d->params[k].name; k++) {
|
||
cJSON *p = cJSON_CreateObject();
|
||
cJSON_AddStringToObject(p, "name", d->params[k].name);
|
||
cJSON_AddStringToObject(p, "type", d->params[k].type);
|
||
cJSON_AddBoolToObject(p, "required", d->params[k].required);
|
||
cJSON_AddItemToArray(pa, p);
|
||
}
|
||
}
|
||
if (d->owner)
|
||
cJSON_AddStringToObject(t, "owner", d->owner);
|
||
cJSON_AddItemToArray(tools, t);
|
||
}
|
||
wui_release(w);
|
||
int sk = cst_sandbox_probe();
|
||
cJSON *sb = cJSON_AddObjectToObject(o, "sandbox");
|
||
if (sb) {
|
||
cJSON_AddStringToObject(sb, "kind", cst_sandbox_name(sk));
|
||
cJSON_AddBoolToObject(sb, "active", sk != CST_SANDBOX_NONE);
|
||
char *l = cst_sandbox_labels();
|
||
if (l)
|
||
cJSON_AddStringToObject(sb, "labels", l);
|
||
free(l);
|
||
}
|
||
free(cfg);
|
||
return wui_send_obj(c, 200, o);
|
||
}
|
||
|
||
/* POST /api/settings {protocol?, max_steps?, tool_profile?,
|
||
* tools?: {name: on}, confirm?: {shell?, write?, net?}}. All checked before
|
||
* any of it applies; this run only, like the TUI commands. */
|
||
static int h_settings_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);
|
||
if (!cJSON_IsObject(j)) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, "expected a JSON object");
|
||
}
|
||
const cJSON *proto = cJSON_GetObjectItem(j, "protocol");
|
||
const cJSON *steps = cJSON_GetObjectItem(j, "max_steps");
|
||
const cJSON *prof = cJSON_GetObjectItem(j, "tool_profile");
|
||
const cJSON *mode = cJSON_GetObjectItem(j, "mode");
|
||
const cJSON *tools = cJSON_GetObjectItem(j, "tools");
|
||
const cJSON *conf = cJSON_GetObjectItem(j, "confirm");
|
||
cst_protocol_t p = cfg->protocol;
|
||
int profile = cfg->tool_profile;
|
||
char bad[200] = "";
|
||
if (proto && (!cJSON_IsString(proto) ||
|
||
cst_protocol_parse(proto->valuestring, &p) != 0))
|
||
snprintf(bad, sizeof(bad), "未知的工具协议");
|
||
else if (steps && (!cJSON_IsNumber(steps) || steps->valuedouble < 1 ||
|
||
steps->valuedouble > 1000))
|
||
snprintf(bad, sizeof(bad), "max_steps 要在 1 到 1000 之间");
|
||
else if (prof && (!cJSON_IsString(prof) ||
|
||
cst_tool_profile_parse(prof->valuestring, &profile) != 0))
|
||
snprintf(bad, sizeof(bad), "未知的工具集(default 或 minimal)");
|
||
else if (mode && (!cJSON_IsString(mode) ||
|
||
!cst_mode_valid(mode->valuestring)))
|
||
snprintf(bad, sizeof(bad), "工作模式只能是 coding / general");
|
||
else if (tools && !cJSON_IsObject(tools))
|
||
snprintf(bad, sizeof(bad), "tools 要是 {\"名字\": true/false}");
|
||
else if (conf && !cJSON_IsObject(conf))
|
||
snprintf(bad, sizeof(bad), "confirm 要是 {shell, write, net}");
|
||
for (const cJSON *t = tools ? tools->child : NULL; !bad[0] && t; t = t->next)
|
||
if (!cJSON_IsBool(t) || cst_tool_index(t->string) < 0)
|
||
snprintf(bad, sizeof(bad), "未知的工具或取值:%.80s", t->string);
|
||
for (const cJSON *t = conf ? conf->child : NULL; !bad[0] && t; t = t->next)
|
||
if (!cJSON_IsBool(t) || (strcmp(t->string, "shell") != 0 &&
|
||
strcmp(t->string, "write") != 0 &&
|
||
strcmp(t->string, "net") != 0))
|
||
snprintf(bad, sizeof(bad), "未知的确认项:%.80s", t->string);
|
||
if (bad[0]) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, bad);
|
||
}
|
||
cfg->protocol = p;
|
||
cfg->tool_profile = profile;
|
||
if (mode) {
|
||
snprintf(cfg->mode, sizeof(cfg->mode), "%s",
|
||
cst_mode_name(mode->valuestring));
|
||
cst_settings_remember_mode(cfg->mode);
|
||
}
|
||
if (steps)
|
||
cfg->max_steps = (int)steps->valuedouble;
|
||
for (const cJSON *t = tools ? tools->child : NULL; t; t = t->next)
|
||
cst_tool_set_disabled(cfg, t->string, cJSON_IsFalse(t));
|
||
for (const cJSON *t = conf ? conf->child : NULL; t; t = t->next) {
|
||
int on = cJSON_IsTrue(t);
|
||
if (strcmp(t->string, "shell") == 0)
|
||
cfg->confirm_shell = on;
|
||
else if (strcmp(t->string, "write") == 0)
|
||
cfg->confirm_write = on;
|
||
else
|
||
cfg->confirm_net = on;
|
||
}
|
||
cJSON_Delete(j);
|
||
wui_emit_state(w);
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
/* GET /api/permissions: the saved always-allow rules, read from disk (the
|
||
* agent's copy is the turn's to change). */
|
||
static int h_perms_get(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
char ws[sizeof(((cst_agent_config_t *)0)->workspace_root)];
|
||
pthread_mutex_lock(&w->mu);
|
||
int ok = !w->op_busy;
|
||
if (ok)
|
||
snprintf(ws, sizeof(ws), "%s", cst_agent_config(w->agent)->workspace_root);
|
||
pthread_mutex_unlock(&w->mu);
|
||
if (!ok)
|
||
return cst_http_send_err(c, 409, "another change is running");
|
||
cst_perms_t *p = cst_perms_open(ws);
|
||
char *d = p ? cst_perms_describe(p) : NULL;
|
||
cst_perms_close(p);
|
||
char path[sizeof(ws) + 32], *raw = NULL;
|
||
size_t rn = 0;
|
||
snprintf(path, sizeof(path), "%s/.agent/permissions.json", ws);
|
||
cJSON *saved = read_file_all(path, &raw, &rn) == 0
|
||
? cJSON_ParseWithLength(raw, rn)
|
||
: NULL;
|
||
free(raw);
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (o) {
|
||
cJSON_AddStringToObject(o, "rules", d ? d : "");
|
||
cJSON_AddStringToObject(o, "file", ".agent/permissions.json");
|
||
static const char *const keys[] = {"tools", "shell_prefixes"};
|
||
for (int k = 0; k < 2; k++) {
|
||
cJSON *arr = cJSON_AddArrayToObject(o, keys[k]);
|
||
const cJSON *it;
|
||
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(saved, keys[k]))
|
||
if (arr && cJSON_IsString(it))
|
||
cJSON_AddItemToArray(arr, cJSON_CreateString(it->valuestring));
|
||
}
|
||
}
|
||
cJSON_Delete(saved);
|
||
free(d);
|
||
return wui_send_obj(c, 200, o);
|
||
}
|
||
|
||
/* DELETE /api/permissions: forget every saved rule. */
|
||
static int h_perms_reset_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (cst_perms_reset(cst_agent_perms(w->agent)) != 0)
|
||
return cst_http_send_err(c, 500, "cannot reset the rules");
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"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) {
|
||
wui_t *w = ud;
|
||
char report[2048] = "";
|
||
if (cst_agent_undo(w->agent, report, sizeof(report)) != 0)
|
||
return cst_http_send_err(c, 409, "nothing to undo");
|
||
wui_emit_str(w, "status", "message", report);
|
||
wui_emit_state(w);
|
||
return cst_http_send_str(c, 200, "text/plain; charset=utf-8", report);
|
||
}
|
||
|
||
static int h_compact_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
char err[256] = "";
|
||
if (cst_agent_compact(w->agent, err, sizeof(err)) != 0)
|
||
return cst_http_send_err(c, 500, err);
|
||
wui_emit_state(w);
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
static int h_attach_upload_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
const cst_http_req_t *req = cst_http_request(c);
|
||
char name[256] = "pasted.png";
|
||
cst_http_query(req, "name", name, sizeof(name));
|
||
if (req->body_len == 0)
|
||
return cst_http_send_err(c, 400, "empty body");
|
||
char err[256] = "";
|
||
if (cst_agent_attach_bytes(w->agent, name, req->body, req->body_len, err,
|
||
sizeof(err)) != 0)
|
||
return cst_http_send_err(c, 400, err[0] ? err : "cannot attach the image");
|
||
wui_emit_state(w);
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
static int h_attach_clear_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cst_agent_pending_clear(w->agent);
|
||
wui_emit_state(w);
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
/* --- /init, /skill and extensions ------------------------------------------ */
|
||
|
||
static int wui_turn_reply(cst_http_conn_t *c, const char *text) {
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (o) {
|
||
cJSON_AddBoolToObject(o, "ok", 1);
|
||
cJSON_AddStringToObject(o, "text", text);
|
||
}
|
||
return wui_send_obj(c, 202, o);
|
||
}
|
||
|
||
/* POST /api/init: a turn that has the agent write AGENTS.md. */
|
||
static int h_init(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
char err[256] = "";
|
||
if (wui_start_turn(w, cst_init_prompt(), 0, err, sizeof(err)) != 0)
|
||
return cst_http_send_err(c, 409, err);
|
||
return wui_turn_reply(c, cst_init_prompt());
|
||
}
|
||
|
||
/* POST /api/skill {"name": "...", "task"?: "..."}: a turn with the skill's
|
||
* instructions inlined before the task. */
|
||
static int h_skill(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *name = j ? cJSON_GetObjectItem(j, "name") : NULL;
|
||
const cJSON *task = j ? cJSON_GetObjectItem(j, "task") : NULL;
|
||
if (!cJSON_IsString(name) || !name->valuestring[0] ||
|
||
strlen(name->valuestring) > 63 || (task && !cJSON_IsString(task))) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, "需要 {\"name\": \"skill 名\", \"task\": 可选}");
|
||
}
|
||
if (wui_claim(w) != 0) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 409, "busy: wait for the running turn");
|
||
}
|
||
char *p = cst_skill_prompt(cst_agent_config(w->agent)->workspace_root,
|
||
name->valuestring, task ? task->valuestring : "");
|
||
cJSON_Delete(j);
|
||
if (!p) {
|
||
wui_release(w);
|
||
return cst_http_send_err(c, 404, "no such skill");
|
||
}
|
||
char err[256] = "";
|
||
int rc = wui_start_turn(w, p, 1, err, sizeof(err));
|
||
rc = rc == 0 ? wui_turn_reply(c, p) : cst_http_send_err(c, 500, err);
|
||
free(p);
|
||
return rc;
|
||
}
|
||
|
||
static const char *wui_mcp_state(int st) {
|
||
switch (st) {
|
||
case CST_MCP_STARTING: return "starting";
|
||
case CST_MCP_READY: return "ready";
|
||
case CST_MCP_FAILED: return "failed";
|
||
case CST_MCP_EXITED: return "exited";
|
||
default: return "off";
|
||
}
|
||
}
|
||
|
||
#define WUI_RUNS_MAX 64
|
||
|
||
/* GET /api/extensions: MCP servers, plugins, skills, sub-agent roles and the
|
||
* open session's sub-agent runs. */
|
||
static int h_extensions(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim_read(w) != 0)
|
||
return cst_http_send_err(c, 409, "another change is running");
|
||
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
|
||
const char *ws = cfg->workspace_root;
|
||
char sid[128];
|
||
cst_agent_session_id(w->agent, sid, sizeof(sid));
|
||
cJSON *o = cJSON_CreateObject();
|
||
cst_mcp_info_t *mi = calloc(CST_MCP_MAX, sizeof(*mi));
|
||
cst_plugin_info_t *pi = calloc(CST_PLUGINS_MAX, sizeof(*pi));
|
||
cst_skill_t *sk = calloc(CST_SKILLS_MAX, sizeof(*sk));
|
||
cst_agent_role_t *ro = calloc(CST_ROLES_MAX, sizeof(*ro));
|
||
cst_subagent_rec_t *ru = calloc(WUI_RUNS_MAX, sizeof(*ru));
|
||
if (!o || !mi || !pi || !sk || !ro || !ru) {
|
||
wui_release(w);
|
||
cJSON_Delete(o);
|
||
free(mi);
|
||
free(pi);
|
||
free(sk);
|
||
free(ro);
|
||
free(ru);
|
||
return cst_http_send_err(c, 500, "out of memory");
|
||
}
|
||
cJSON_AddBoolToObject(o, "enabled", cfg->tool_profile != CST_TOOLS_MINIMAL);
|
||
cJSON_AddBoolToObject(o, "trusted", cst_settings_trusted(ws));
|
||
cJSON_AddStringToObject(o, "report", cst_extensions_report());
|
||
cJSON_AddBoolToObject(o, "task_tool", cst_tool_find("task") != NULL);
|
||
|
||
cJSON *a = cJSON_AddArrayToObject(o, "mcp");
|
||
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
||
for (int i = 0; a && i < n; i++) {
|
||
cJSON *e = cJSON_CreateObject();
|
||
if (!e)
|
||
continue;
|
||
cJSON_AddStringToObject(e, "name", mi[i].name);
|
||
cJSON_AddStringToObject(e, "source", mi[i].source);
|
||
cJSON_AddStringToObject(e, "command", mi[i].command);
|
||
cJSON_AddStringToObject(e, "state", wui_mcp_state(mi[i].state));
|
||
cJSON_AddNumberToObject(e, "tools", mi[i].ntools);
|
||
cJSON_AddStringToObject(e, "error", mi[i].error);
|
||
cJSON_AddItemToArray(a, e);
|
||
}
|
||
a = cJSON_AddArrayToObject(o, "plugins");
|
||
n = cst_plugins_info(pi, CST_PLUGINS_MAX);
|
||
for (int i = 0; a && i < n; i++) {
|
||
cJSON *e = cJSON_CreateObject();
|
||
if (!e)
|
||
continue;
|
||
cJSON_AddStringToObject(e, "name", pi[i].name);
|
||
cJSON_AddStringToObject(e, "source", pi[i].source);
|
||
cJSON_AddStringToObject(e, "desc", pi[i].desc);
|
||
cJSON_AddNumberToObject(e, "tools", pi[i].ntools);
|
||
cJSON_AddNumberToObject(e, "mcp", pi[i].nmcp);
|
||
cJSON_AddBoolToObject(e, "skills", pi[i].nskills_root);
|
||
cJSON_AddStringToObject(e, "error", pi[i].error);
|
||
cJSON_AddItemToArray(a, e);
|
||
}
|
||
a = cJSON_AddArrayToObject(o, "skills");
|
||
n = cst_skills_list(ws, sk, CST_SKILLS_MAX);
|
||
for (int i = 0; a && i < n; i++) {
|
||
cJSON *e = cJSON_CreateObject();
|
||
if (!e)
|
||
continue;
|
||
cJSON_AddStringToObject(e, "name", sk[i].name);
|
||
cJSON_AddStringToObject(e, "source", sk[i].source);
|
||
cJSON_AddStringToObject(e, "desc", sk[i].desc);
|
||
cJSON_AddItemToArray(a, e);
|
||
}
|
||
a = cJSON_AddArrayToObject(o, "roles");
|
||
n = cst_roles_list(ws, ro, CST_ROLES_MAX);
|
||
for (int i = 0; a && i < n; i++) {
|
||
cJSON *e = cJSON_CreateObject();
|
||
if (!e)
|
||
continue;
|
||
cJSON_AddStringToObject(e, "name", ro[i].name);
|
||
cJSON_AddStringToObject(e, "source", ro[i].source);
|
||
cJSON_AddStringToObject(e, "desc", ro[i].desc);
|
||
cJSON_AddStringToObject(e, "model", ro[i].model);
|
||
cJSON_AddStringToObject(e, "profile", ro[i].profile);
|
||
cJSON_AddBoolToObject(e, "read_only", ro[i].read_only);
|
||
cJSON_AddItemToArray(a, e);
|
||
}
|
||
cst_roles_free(ro, n);
|
||
char *budget_json = cst_workers_budget_json(cfg);
|
||
cJSON *budget = budget_json ? cJSON_Parse(budget_json) : NULL;
|
||
free(budget_json);
|
||
if (budget) cJSON_AddItemToObject(o, "worker_budget", budget);
|
||
a = cJSON_AddArrayToObject(o, "runs");
|
||
n = cst_subagent_list(ws, sid, ru, WUI_RUNS_MAX);
|
||
for (int i = 0; a && i < n; i++) {
|
||
cJSON *e = cJSON_CreateObject();
|
||
if (!e)
|
||
continue;
|
||
cJSON_AddStringToObject(e, "file", ru[i].file);
|
||
cJSON_AddStringToObject(e, "role", ru[i].role);
|
||
cJSON_AddStringToObject(e, "desc", ru[i].desc);
|
||
cJSON_AddStringToObject(e, "status", ru[i].status);
|
||
cJSON_AddStringToObject(e, "provider", ru[i].provider);
|
||
cJSON_AddStringToObject(e, "model", ru[i].model);
|
||
cJSON_AddNumberToObject(e, "requests", ru[i].requests);
|
||
cJSON_AddNumberToObject(e, "max_steps", ru[i].max_steps);
|
||
cJSON_AddNumberToObject(e, "prompt_tokens", ru[i].prompt_tokens);
|
||
if (ru[i].cache_known) {
|
||
cJSON_AddNumberToObject(e, "cached_tokens", ru[i].cached_tokens);
|
||
cJSON_AddNumberToObject(e, "cache_write_tokens", ru[i].cache_write_tokens);
|
||
} else {
|
||
cJSON_AddNullToObject(e, "cached_tokens");
|
||
cJSON_AddNullToObject(e, "cache_write_tokens");
|
||
}
|
||
cJSON_AddNumberToObject(e, "output_tokens", ru[i].output_tokens);
|
||
if (ru[i].priced) {
|
||
cJSON_AddNumberToObject(e, "cost", ru[i].cost);
|
||
cJSON_AddStringToObject(e, "currency", ru[i].currency);
|
||
}
|
||
cJSON_AddNumberToObject(e, "steps", ru[i].steps);
|
||
cJSON_AddNumberToObject(e, "tools", ru[i].tools);
|
||
cJSON_AddNumberToObject(e, "ms", ru[i].ms);
|
||
cJSON_AddItemToArray(a, e);
|
||
}
|
||
wui_release(w);
|
||
free(mi);
|
||
free(pi);
|
||
free(sk);
|
||
free(ro);
|
||
free(ru);
|
||
return wui_send_obj(c, 200, o);
|
||
}
|
||
|
||
static int wui_ext_reply(wui_t *w, cst_http_conn_t *c) {
|
||
char rep[256];
|
||
cst_extensions_unload();
|
||
cst_extensions_load(cst_agent_config(w->agent), rep, sizeof(rep));
|
||
cJSON *o = cJSON_CreateObject();
|
||
if (o) {
|
||
cJSON_AddBoolToObject(o, "ok", 1);
|
||
cJSON_AddBoolToObject(
|
||
o, "trusted",
|
||
cst_settings_trusted(cst_agent_config(w->agent)->workspace_root));
|
||
cJSON_AddStringToObject(o, "report", rep);
|
||
}
|
||
return wui_send_obj(c, 200, o);
|
||
}
|
||
|
||
/* POST /api/extensions/reload: stop MCP servers and plugins, read them and
|
||
* the skills again. */
|
||
static int h_ext_reload_x(cst_http_conn_t *c, void *ud) {
|
||
return wui_ext_reply(ud, c);
|
||
}
|
||
|
||
static int h_workers_get(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim_read(w) != 0)
|
||
return cst_http_send_err(c, 409, "another change is running");
|
||
char *s = cst_workers_config_get(cst_agent_config(w->agent)->workspace_root);
|
||
cJSON *j = s ? cJSON_Parse(s) : NULL;
|
||
free(s);
|
||
wui_release(w);
|
||
if (!j) return cst_http_send_err(c, 400, "invalid worker configuration");
|
||
return wui_send_obj(c, 200, j);
|
||
}
|
||
static int h_workers_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *j = wui_body(c);
|
||
char *s = j ? cJSON_PrintUnformatted(j) : NULL;
|
||
char err[256] = "expected JSON configuration";
|
||
int rc = s ? cst_workers_config_set(
|
||
cst_agent_config(w->agent)->workspace_root, s, err, sizeof(err)) : -1;
|
||
free(s); cJSON_Delete(j);
|
||
if (rc != 0) return cst_http_send_err(c, 400, err);
|
||
cst_workers_config_apply(cst_agent_config_mut(w->agent));
|
||
return cst_http_send_str(c, 200, "application/json", "{\"ok\":true}");
|
||
}
|
||
static int h_worker_cancel(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim_read(w) != 0)
|
||
return cst_http_send_err(c, 409, "another change is running");
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *id = cJSON_GetObjectItemCaseSensitive(j, "worker_id");
|
||
char sid[128];
|
||
cst_agent_session_id(w->agent, sid, sizeof(sid));
|
||
int rc = cJSON_IsString(id) ? cst_worker_cancel(
|
||
cst_agent_config(w->agent)->workspace_root, sid, id->valuestring) : -1;
|
||
cJSON_Delete(j);
|
||
wui_release(w);
|
||
if (rc != 0) return cst_http_send_err(c, 404, "running worker not found");
|
||
return cst_http_send_str(c, 200, "application/json", "{\"ok\":true}");
|
||
}
|
||
|
||
static int h_task_get(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim_read(w)) return cst_http_send_err(c, 409, "session is changing");
|
||
char *s = cst_runtime_json(cst_agent_config(w->agent)->runtime);
|
||
cJSON *j = s ? cJSON_Parse(s) : NULL; free(s); wui_release(w);
|
||
return wui_send_obj(c, 200, j ? j : cJSON_CreateObject());
|
||
}
|
||
static int h_task_control(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim_read(w)) return cst_http_send_err(c, 409, "session is changing");
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *msg = cJSON_GetObjectItemCaseSensitive(j, "message");
|
||
const cJSON *ro = cJSON_GetObjectItemCaseSensitive(j, "read_only");
|
||
const cJSON *delegate = cJSON_GetObjectItemCaseSensitive(j, "delegation");
|
||
const cJSON *paused = cJSON_GetObjectItemCaseSensitive(j, "paused");
|
||
int valid = cJSON_IsObject(j) && (!msg || cJSON_IsString(msg)) && (!ro || cJSON_IsBool(ro)) && (!delegate || cJSON_IsBool(delegate)) && (!paused || cJSON_IsBool(paused));
|
||
int rc = -1;
|
||
if (valid) {
|
||
pthread_mutex_lock(&w->mu); int running = w->busy; pthread_mutex_unlock(&w->mu);
|
||
if (msg && msg->valuestring[0] && !running) {
|
||
if (ro || delegate || paused) cst_agent_control(w->agent, ro ? cJSON_IsTrue(ro) : -1, delegate ? cJSON_IsTrue(delegate) : -1, paused ? cJSON_IsTrue(paused) : -1);
|
||
char error[256]; rc = wui_start_turn(w, msg->valuestring, 1, error, sizeof(error));
|
||
} else if (msg && msg->valuestring[0]) rc = cst_agent_steer(w->agent, msg->valuestring, ro ? cJSON_IsTrue(ro) : -1, delegate ? cJSON_IsTrue(delegate) : -1);
|
||
else rc = cst_agent_control(w->agent, ro ? cJSON_IsTrue(ro) : -1, delegate ? cJSON_IsTrue(delegate) : -1, paused ? cJSON_IsTrue(paused) : -1);
|
||
}
|
||
cJSON_Delete(j); wui_release(w);
|
||
if (rc) return cst_http_send_err(c, 400, "expected message and/or boolean task controls");
|
||
return cst_http_send_str(c, 202, "application/json", "{\"ok\":true}");
|
||
}
|
||
static int h_worktree(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim(w)) return cst_http_send_err(c, 409, "wait for the active task");
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *path = cJSON_GetObjectItemCaseSensitive(j, "worktree");
|
||
int integrate = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j, "integrate"));
|
||
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
|
||
int code = 1; char *text = NULL;
|
||
if (cJSON_IsString(path) && (!integrate || cst_runtime_check(cfg->runtime, cst_runtime_revision(cfg->runtime), 1, 0)) && !cst_runtime_jobs_pending(cfg->runtime))
|
||
text = cst_worktree_diff(cfg->workspace_root, path->valuestring, integrate, &code);
|
||
cJSON_Delete(j); wui_release(w);
|
||
cJSON *result = cJSON_CreateObject(); cJSON_AddBoolToObject(result, "ok", !code);
|
||
cJSON_AddStringToObject(result, "output", text ? text : "task is read-only, jobs are active, or worktree is missing"); free(text);
|
||
return wui_send_obj(c, code ? 409 : 200, result);
|
||
}
|
||
static int h_job_cancel(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
if (wui_claim_read(w)) return cst_http_send_err(c, 409, "session is changing");
|
||
cJSON *j = wui_body(c); const cJSON *id = cJSON_GetObjectItemCaseSensitive(j, "job_id");
|
||
const cJSON *force = cJSON_GetObjectItemCaseSensitive(j, "force");
|
||
int rc = cJSON_IsString(id) ? cst_job_cancel(cst_agent_config(w->agent)->runtime, id->valuestring, cJSON_IsTrue(force), "user") : -1;
|
||
cJSON_Delete(j); wui_release(w);
|
||
if (rc) return cst_http_send_err(c, 404, "active job not found");
|
||
return cst_http_send_str(c, 202, "application/json", "{\"ok\":true}");
|
||
}
|
||
|
||
/* POST /api/extensions/trust {"on": bool}: whether .agent/plugins and
|
||
* .agent/mcp.json of this workspace load, then reload. */
|
||
static int h_ext_trust_x(cst_http_conn_t *c, void *ud) {
|
||
wui_t *w = ud;
|
||
cJSON *j = wui_body(c);
|
||
const cJSON *on = j ? cJSON_GetObjectItem(j, "on") : NULL;
|
||
if (!cJSON_IsBool(on)) {
|
||
cJSON_Delete(j);
|
||
return cst_http_send_err(c, 400, "需要 {\"on\": true 或 false}");
|
||
}
|
||
int want = cJSON_IsTrue(on);
|
||
cJSON_Delete(j);
|
||
if (cst_settings_trust(cst_agent_config(w->agent)->workspace_root, want) != 0)
|
||
return cst_http_send_err(c, 500, "cannot save the settings");
|
||
return wui_ext_reply(w, c);
|
||
}
|
||
|
||
WUI_EXCLUSIVE(h_session_new)
|
||
WUI_EXCLUSIVE(h_workspace)
|
||
WUI_EXCLUSIVE(h_session_switch)
|
||
WUI_EXCLUSIVE(h_session_delete)
|
||
WUI_EXCLUSIVE(h_settings)
|
||
WUI_EXCLUSIVE(h_perms_reset)
|
||
WUI_EXCLUSIVE(h_ext_reload)
|
||
WUI_EXCLUSIVE(h_workers)
|
||
WUI_EXCLUSIVE(h_ext_trust)
|
||
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;
|
||
w->stopping = 1;
|
||
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
|
||
"{\"ok\":true}");
|
||
}
|
||
|
||
/* --- auth ---------------------------------------------------------------- */
|
||
|
||
static int token_matches(const wui_t *w, const char *given) {
|
||
if (!given || !w->token[0])
|
||
return 0;
|
||
size_t a = strlen(given), b = strlen(w->token);
|
||
if (a != b)
|
||
return 0;
|
||
unsigned diff = 0;
|
||
for (size_t i = 0; i < a; i++)
|
||
diff |= (unsigned char)given[i] ^ (unsigned char)w->token[i];
|
||
return diff == 0;
|
||
}
|
||
|
||
static int cookie_token(const char *cookie, char *out, size_t out_sz) {
|
||
const char *p = cookie;
|
||
while (p && *p) {
|
||
while (*p == ' ' || *p == ';')
|
||
p++;
|
||
if (strncmp(p, "cst_token=", 10) == 0) {
|
||
const char *v = p + 10;
|
||
const char *end = strchr(v, ';');
|
||
size_t n = end ? (size_t)(end - v) : strlen(v);
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, v, n);
|
||
out[n] = 0;
|
||
return 0;
|
||
}
|
||
p = strchr(p, ';');
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/* Host header must name this machine's loopback when no token is required:
|
||
* a DNS-rebinding page would send its own name here. */
|
||
static int host_is_loopback(const char *host) {
|
||
if (!host || !host[0])
|
||
return 0;
|
||
while (*host == ' ')
|
||
host++;
|
||
if (strncmp(host, "127.0.0.1", 9) == 0) {
|
||
char next = host[9];
|
||
return next == 0 || next == ':';
|
||
}
|
||
if (strncmp(host, "localhost", 9) == 0) {
|
||
char next = host[9];
|
||
return next == 0 || next == ':';
|
||
}
|
||
if (strncmp(host, "[::1]", 5) == 0) {
|
||
char next = host[5];
|
||
return next == 0 || next == ':';
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static int strncasecmp_ascii(const char *a, const char *b, size_t n) {
|
||
for (size_t i = 0; i < n; i++) {
|
||
char x = a[i], y = b[i];
|
||
if (x >= 'A' && x <= 'Z')
|
||
x = (char)(x - 'A' + 'a');
|
||
if (y >= 'A' && y <= 'Z')
|
||
y = (char)(y - 'A' + 'a');
|
||
if (x != y)
|
||
return x < y ? -1 : 1;
|
||
if (!x)
|
||
return 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* A browser names the page that sent a request in Origin. Without a token
|
||
* that is the only thing that tells our own page from any site the user
|
||
* visits: a no-cors POST with a text/plain body needs no preflight and
|
||
* carries Host: 127.0.0.1. So it must be this very origin. */
|
||
static int origin_is_self(const char *origin, const char *host) {
|
||
if (!origin)
|
||
return 1; /* curl and friends; browsers always send it on POST */
|
||
while (*origin == ' ')
|
||
origin++;
|
||
const char *rest = NULL;
|
||
if (strncmp(origin, "http://", 7) == 0)
|
||
rest = origin + 7;
|
||
else if (strncmp(origin, "https://", 8) == 0)
|
||
rest = origin + 8;
|
||
if (!rest || !host)
|
||
return 0;
|
||
while (*host == ' ')
|
||
host++;
|
||
size_t n = strlen(host);
|
||
while (n && (host[n - 1] == ' ' || host[n - 1] == '\t'))
|
||
n--;
|
||
return strlen(rest) == n && strncasecmp_ascii(rest, host, n) == 0;
|
||
}
|
||
|
||
static int wui_auth(const cst_http_req_t *req, void *ud) {
|
||
wui_t *w = ud;
|
||
if (!w->need_auth)
|
||
return host_is_loopback(req->host) && origin_is_self(req->origin, req->host)
|
||
? 0
|
||
: -1;
|
||
char buf[128];
|
||
if (req->authorization && strncmp(req->authorization, "Bearer ", 7) == 0 &&
|
||
token_matches(w, req->authorization + 7))
|
||
return 0;
|
||
if (req->cookie && cookie_token(req->cookie, buf, sizeof(buf)) == 0 &&
|
||
token_matches(w, buf))
|
||
return 0;
|
||
if (cst_http_query(req, "token", buf, sizeof(buf)) == 0 &&
|
||
token_matches(w, buf))
|
||
return 0;
|
||
return -1;
|
||
}
|
||
|
||
/* --- startup ------------------------------------------------------------- */
|
||
|
||
static volatile sig_atomic_t g_webui_stop;
|
||
|
||
static void webui_on_signal(int sig) {
|
||
(void)sig;
|
||
g_webui_stop = 1;
|
||
}
|
||
|
||
static void wui_make_token(char *out, size_t out_sz) {
|
||
unsigned char raw[16];
|
||
size_t got = 0;
|
||
FILE *fp = fopen("/dev/urandom", "rb");
|
||
if (fp) {
|
||
got = fread(raw, 1, sizeof(raw), fp);
|
||
fclose(fp);
|
||
}
|
||
if (got != sizeof(raw)) {
|
||
unsigned seed = (unsigned)(os_now_ms() * 1000.0) ^ (unsigned)os_getpid();
|
||
for (size_t i = 0; i < sizeof(raw); i++) {
|
||
seed = seed * 1103515245u + 12345u;
|
||
raw[i] = (unsigned char)(seed >> 16);
|
||
}
|
||
}
|
||
static const char hex[] = "0123456789abcdef";
|
||
size_t o = 0;
|
||
for (size_t i = 0; i < sizeof(raw) && o + 2 < out_sz; i++) {
|
||
out[o++] = hex[raw[i] >> 4];
|
||
out[o++] = hex[raw[i] & 15];
|
||
}
|
||
out[o] = 0;
|
||
}
|
||
|
||
static void open_browser(const char *url) {
|
||
char cmd[600];
|
||
#if defined(_WIN32)
|
||
snprintf(cmd, sizeof(cmd), "start \"\" \"%s\"", url);
|
||
#elif defined(__APPLE__)
|
||
snprintf(cmd, sizeof(cmd), "open '%s'", url);
|
||
#else
|
||
/* Termux has no xdg-open; termux-open-url hands the URL to Android. */
|
||
const char *opener =
|
||
getenv("TERMUX_VERSION") ? "termux-open-url" : "xdg-open";
|
||
snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url);
|
||
#endif
|
||
os_run_result_t r;
|
||
memset(&r, 0, sizeof(r)); /* a NULL result makes the runner refuse */
|
||
os_run_shell_c(".", cmd, 0, &r);
|
||
os_run_result_free(&r);
|
||
}
|
||
|
||
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
|
||
cst_webui_opts_t def;
|
||
if (!opts) {
|
||
memset(&def, 0, sizeof(def));
|
||
opts = &def;
|
||
}
|
||
wui_t *w = calloc(1, sizeof(*w));
|
||
if (!w)
|
||
return 1;
|
||
pthread_mutex_init(&w->mu, NULL);
|
||
w->agent = agent;
|
||
w->perm_timeout_s = opts->perm_timeout_s;
|
||
w->root = opts->root;
|
||
{
|
||
cst_settings_t st;
|
||
cst_settings_load(&st);
|
||
w->show_thinking = st.show_thinking;
|
||
}
|
||
pthread_cond_init(&w->cv, NULL);
|
||
snprintf(w->host, sizeof(w->host), "%s",
|
||
opts->host && opts->host[0] ? opts->host : "127.0.0.1");
|
||
if (opts->token && opts->token[0]) {
|
||
if (strlen(opts->token) >= sizeof(w->token)) {
|
||
fprintf(stderr, "webui: the token is longer than %d characters\n",
|
||
(int)sizeof(w->token) - 1);
|
||
pthread_cond_destroy(&w->cv);
|
||
pthread_mutex_destroy(&w->mu);
|
||
free(w);
|
||
return 1;
|
||
}
|
||
snprintf(w->token, sizeof(w->token), "%s", opts->token);
|
||
w->need_auth = 1;
|
||
} else if (strcmp(w->host, "127.0.0.1") != 0 &&
|
||
strcmp(w->host, "localhost") != 0 && strcmp(w->host, "::1") != 0) {
|
||
wui_make_token(w->token, sizeof(w->token));
|
||
w->need_auth = 1;
|
||
}
|
||
|
||
static cst_http_route_t routes[] = {
|
||
{"GET", "/", h_asset, NULL, 0, 0},
|
||
{"GET", "/api/state", h_state, NULL, 0, 0},
|
||
{"GET", "/api/task", h_task_get, NULL, 0, 0},
|
||
{"POST", "/api/task", h_task_control, NULL, 0, 0},
|
||
{"POST", "/api/jobs/cancel", h_job_cancel, NULL, 0, 0},
|
||
{"POST", "/api/worktree", h_worktree, NULL, 0, 0},
|
||
{"GET", "/api/session", h_session, NULL, 0, 0},
|
||
{"GET", "/api/events", h_events, NULL, 0, 0},
|
||
{"GET", "/api/sessions", h_sessions, NULL, 0, 0},
|
||
{"POST", "/api/sessions/new", h_session_new, NULL, 0, 0},
|
||
{"POST", "/api/sessions/switch", h_session_switch, NULL, 0, 0},
|
||
{"POST", "/api/sessions/rename", h_session_rename, NULL, 0, 0},
|
||
{"POST", "/api/sessions/delete", h_session_delete, NULL, 0, 0},
|
||
{"POST", "/api/retry", h_retry, NULL, 0, 0},
|
||
{"GET", "/api/export", h_export, NULL, 0, 0},
|
||
{"GET", "/api/diff", h_diff, NULL, 0, 0},
|
||
{"GET", "/api/settings", h_settings_get, NULL, 0, 0},
|
||
{"POST", "/api/settings", h_settings, NULL, 0, 0},
|
||
{"GET", "/api/permissions", h_perms_get, NULL, 0, 0},
|
||
{"DELETE", "/api/permissions", h_perms_reset, NULL, 0, 0},
|
||
{"POST", "/api/init", h_init, NULL, 0, 0},
|
||
{"POST", "/api/skill", h_skill, NULL, 0, 0},
|
||
{"GET", "/api/extensions", h_extensions, NULL, 0, 0},
|
||
{"POST", "/api/extensions/reload", h_ext_reload, NULL, 0, 0},
|
||
{"POST", "/api/extensions/trust", h_ext_trust, NULL, 0, 0},
|
||
{"POST", "/api/workspace", h_workspace, NULL, 0, 0},
|
||
{"POST", "/api/turn", h_turn, NULL, 0, 0},
|
||
{"POST", "/api/cancel", h_cancel, NULL, 0, 0},
|
||
{"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/workers/config", h_workers_get, NULL, 0, 0},
|
||
{"POST", "/api/workers/config", h_workers, NULL, 0, 0},
|
||
{"POST", "/api/workers/cancel", h_worker_cancel, 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},
|
||
{"DELETE", "/api/attach", h_attach_clear, NULL, 0, 0},
|
||
{"POST", "/api/quit", h_quit, NULL, 0, 0},
|
||
{"GET", "*", h_asset, NULL, 0, 0},
|
||
};
|
||
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) {
|
||
routes[i].ud = w;
|
||
routes[i].auth = w->need_auth || strcmp(routes[i].path, "/") != 0;
|
||
}
|
||
/* the shell page and its assets carry no token (app.js reads it from the
|
||
* URL and sets the cookie); h_asset checks /api/attach/ itself */
|
||
routes[0].auth = 0;
|
||
routes[sizeof(routes) / sizeof(routes[0]) - 1].auth = 0;
|
||
|
||
cst_http_opts_t ho;
|
||
memset(&ho, 0, sizeof(ho));
|
||
ho.host = w->host;
|
||
ho.port = opts->port;
|
||
ho.max_body = 1024 * 1024;
|
||
ho.max_conn = 32;
|
||
ho.routes = routes;
|
||
ho.nroutes = (int)(sizeof(routes) / sizeof(routes[0]));
|
||
ho.auth = wui_auth;
|
||
ho.auth_ud = w;
|
||
char err[256] = "";
|
||
w->srv = cst_http_start(&ho, err, sizeof(err));
|
||
if (!w->srv) {
|
||
fprintf(stderr, "webui: cannot listen on %s:%d: %s\n", w->host,
|
||
opts->port, err);
|
||
pthread_cond_destroy(&w->cv);
|
||
pthread_mutex_destroy(&w->mu);
|
||
free(w);
|
||
return 1;
|
||
}
|
||
char url[256];
|
||
snprintf(url, sizeof(url), "http://%s:%d/%s", strcmp(w->host, "0.0.0.0") == 0
|
||
? "127.0.0.1"
|
||
: w->host,
|
||
cst_http_port(w->srv),
|
||
w->need_auth ? "" : "");
|
||
fprintf(stderr, "webui: %s\n", url);
|
||
if (w->need_auth)
|
||
fprintf(stderr, "webui: token %s (append #token=%s to the URL)\n", w->token,
|
||
w->token);
|
||
fflush(stderr);
|
||
wui_emit_state(w);
|
||
if (opts->open_browser) {
|
||
char full[sizeof(url) + 80];
|
||
snprintf(full, sizeof(full), "%s%s%s", url, w->need_auth ? "#token=" : "",
|
||
w->need_auth ? w->token : "");
|
||
open_browser(full);
|
||
}
|
||
|
||
if (opts->prompt && opts->prompt[0]) {
|
||
char err2[256] = "";
|
||
if (wui_start_turn(w, opts->prompt, 0, err2, sizeof(err2)) != 0)
|
||
fprintf(stderr, "webui: %s\n", err2);
|
||
}
|
||
|
||
/* Serve until Ctrl+C, /api/quit or a signal. */
|
||
signal(SIGINT, webui_on_signal);
|
||
signal(SIGTERM, webui_on_signal);
|
||
while (!w->stopping && !g_webui_stop) {
|
||
struct timespec ts;
|
||
ts.tv_sec = 0;
|
||
ts.tv_nsec = 200 * 1000 * 1000;
|
||
nanosleep(&ts, NULL);
|
||
}
|
||
fprintf(stderr, "webui: shutting down\n");
|
||
/* Wake a turn parked on a permission/ask_user answer, cancel it and wait
|
||
* for its thread: it emits through w, and the agent is destroyed next. */
|
||
pthread_mutex_lock(&w->mu);
|
||
w->stopping = 1;
|
||
pthread_cond_broadcast(&w->cv);
|
||
int live = w->turn_live;
|
||
pthread_mutex_unlock(&w->mu);
|
||
cst_agent_request_cancel(w->agent); /* also a /api/compact in flight */
|
||
if (live) {
|
||
struct timespec deadline;
|
||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||
deadline.tv_sec += 30;
|
||
pthread_mutex_lock(&w->mu);
|
||
while (w->turn_live &&
|
||
pthread_cond_timedwait(&w->cv, &w->mu, &deadline) == 0)
|
||
;
|
||
live = w->turn_live;
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
if (live) {
|
||
/* still running (a command ignoring cancel): the caller would destroy
|
||
* the agent under it, so leave without tearing anything down */
|
||
fprintf(stderr, "webui: the running turn did not stop in 30 s\n");
|
||
fflush(NULL);
|
||
_Exit(1);
|
||
}
|
||
if (cst_http_stop(w->srv) > 0) {
|
||
/* a handler still runs and uses w and the agent: same as above */
|
||
fprintf(stderr, "webui: a request did not finish in 30 s\n");
|
||
fflush(NULL);
|
||
_Exit(1);
|
||
}
|
||
cst_http_free(w->srv);
|
||
for (int i = 0; i < WUI_RING; i++)
|
||
free(w->ring[i].frame);
|
||
if (w->md)
|
||
cst_web_md_free(w->md);
|
||
pthread_cond_destroy(&w->cv);
|
||
pthread_mutex_destroy(&w->mu);
|
||
free(w);
|
||
return 0;
|
||
}
|