Files
Hermit/web/app.js
T
WHC 6e81513e36
ci / linux (clang) (push) Successful in 5m25s
ci / linux (gcc) (push) Failing after 34s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 10m41s
v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests
A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on
a worker thread, the hooks publish events into a ring buffer that SSE clients
read, so the engine itself needs no changes.

- src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect
- src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming,
  body caps (413), Expect: 100-continue, connection cap, exact routing
- src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML
  (tag/attribute allowlist, javascript: URLs and off-site images dropped)
- src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes,
  token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no
  client is attached
- src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI
- web/: no-build front end (transcript, approvals, todo, sessions, image
  upload, status bar)
- docs/webui.md: engine API v1 - events, requests, security model
- tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run
  driven by a recorded SSE stream (no API key needed)
- src/main.c: --web --web-port --web-host --web-token --web-dev --web-open
  and CST_WEB_PORT/HOST/TOKEN/ROOT

An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so
curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests
learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI.
2026-10-09 10:20:09 +08:00

434 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* CST-Agent WebUI client: HTTP + SSE against the engine in the same binary.
* No build step, no dependencies: the server renders Markdown, this file
* only moves messages around and draws them. */
'use strict';
/* --- token: from ?token= / #token= once, then kept in a cookie by the
* server; the query parameter is appended to every request so EventSource
* (which cannot set headers) works as well. --- */
const TOKEN = (() => {
const q = new URLSearchParams(location.search);
if (q.get('token')) return q.get('token');
const h = location.hash.replace(/^#/, '');
const hp = new URLSearchParams(h);
return hp.get('token') || '';
})();
function api(path, opt) {
if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
return fetch(path, opt);
}
async function getJSON(path) {
const r = await api(path);
if (!r.ok) throw new Error((await r.text()) || r.statusText);
return r.json();
}
async function post(path, body) {
const r = await api(path, {
method: 'POST',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await r.text();
if (!r.ok) throw new Error(text || r.statusText);
return text ? JSON.parse(text) : {};
}
const $ = (id) => document.getElementById(id);
const log = $('log');
let state = {};
let live = null; /* {body, html, think} for the streaming assistant block */
let tools = new Map();/* tool call id -> element */
let permReq = null; /* pending permission event */
let askReq = null; /* pending question */
let firstPaint = true;
/* --- transcript ---------------------------------------------------------- */
function atBottom() {
return log.scrollTop + log.clientHeight >= log.scrollHeight - 40;
}
function scrollDown(force) {
if (force || atBottom()) log.scrollTop = log.scrollHeight;
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function bubble(who, cls) {
const m = el('div', 'msg ' + (cls || ''));
m.appendChild(el('div', 'who', who));
const b = el('div', 'body');
m.appendChild(b);
log.appendChild(m);
scrollDown(true);
return { root: m, body: b };
}
function renderMessage(m) {
if (m.role === 'system') return;
if (m.role === 'tool') return; /* drawn with its call */
const cls = m.role === 'user' ? 'user' : 'assistant';
const who = m.role === 'user' ? 'You' : 'Assistant';
const b = bubble(who, cls);
if (m.role === 'assistant' && m.html) b.body.innerHTML = m.html;
else b.body.textContent = m.content || '';
if (m.reasoning) {
const d = el('details', 'think');
d.appendChild(el('summary', null, 'Thought'));
d.appendChild(el('div', null, m.reasoning));
b.root.insertBefore(d, b.body);
}
if (m.attach) {
for (const a of m.attach) {
const img = el('img');
img.alt = a.name || 'image';
img.src = 'api/attach/' + a.rel + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '');
log.insertBefore(img, null);
}
}
}
async function loadSession() {
const data = await getJSON('api/session');
log.textContent = '';
firstPaint = true;
for (const m of data.messages) renderMessage(m);
firstPaint = false;
scrollDown(true);
}
/* --- live blocks --------------------------------------------------------- */
function ensureLive() {
if (live) return live;
const b = bubble('Assistant', 'assistant');
live = { root: b.root, body: b.body, html: '', think: null };
return live;
}
function thinkBlock(text) {
const l = ensureLive();
if (!l.think) {
const d = el('details', 'think');
d.appendChild(el('summary', null, 'Thought'));
const t = el('div');
d.appendChild(t);
l.root.insertBefore(d, l.body);
l.think = t;
}
l.think.textContent += text;
return l;
}
function toolCard(ev) {
let c = tools.get(ev.id);
if (!c) {
const d = el('details', 'tool');
const s = el('summary', null, ev.name || 'tool');
d.appendChild(s);
const args = el('div', 'args', JSON.stringify(ev.args || {}, null, 0));
d.appendChild(args);
const out = el('pre');
d.appendChild(out);
log.appendChild(d);
c = { root: d, sum: s, out, open: false };
tools.set(ev.id, c);
}
return c;
}
function onTool(ev) {
const c = toolCard(ev);
if (ev.phase === 'start' || ev.phase === 'begin') {
c.sum.classList.add('spin');
c.open = true;
c.root.open = true;
} else if (ev.phase === 'end' || ev.phase === 'done') {
c.sum.classList.remove('spin');
}
if (ev.detail && !c.out.textContent) c.out.textContent = ev.detail;
scrollDown(false);
}
function onToolResult(ev) {
const c = toolCard(ev);
c.sum.classList.remove('spin');
c.out.textContent = (ev.out || '') + (ev.truncated ? '\n… (截断)' : '');
if (ev.exit) {
c.sum.appendChild(el('span', ev.exit > 0 ? 'exit-bad' : '', ' exit=' + ev.exit));
}
scrollDown(false);
}
/* --- state / status ------------------------------------------------------ */
function applyState(s) {
state = s;
$('ws').textContent = s.workspace || '';
$('ws').title = s.workspace || '';
$('model').textContent = (s.provider ? s.provider + ':' : '') + (s.model || '-');
const pm = $('planmode');
pm.textContent = s.plan_mode ? 'Plan' : 'Build';
pm.classList.toggle('on', !!s.plan_mode);
$('approval').textContent = s.approval || 'auto';
$('ctx').textContent = s.context_tokens ? (s.context_tokens + ' tok') : '';
const u = s.usage || {};
$('usage').textContent = u.requests
? `${u.requests} req · ${u.prompt_tokens}/${u.completion_tokens} tok` +
(u.priced && u.cost ? ` · ${u.currency}${u.cost.toFixed(4)}` : '')
: '';
const busy = !!s.status_busy;
$('btn-stop').hidden = !busy;
$('btn-send').hidden = busy;
$('status').textContent = busy ? '运行中…' : '就绪';
const p = $('pending');
p.hidden = !(s.pending_images && s.pending_images.length);
p.textContent = (s.pending_images || []).join(' ');
const t = $('todo');
t.hidden = !s.todo;
if (s.todo) {
t.textContent = '';
t.appendChild(el('h4', null, '任务'));
t.appendChild(el('div', null, s.todo));
}
}
/* --- SSE ----------------------------------------------------------------- */
function connect() {
const url = 'api/events' + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '');
const es = new EventSource(url);
es.addEventListener('state', (e) => applyState(JSON.parse(e.data)));
es.addEventListener('status', (e) => { $('status').textContent = JSON.parse(e.data).message; });
es.addEventListener('assistant_html', (e) => {
const l = ensureLive();
l.html += JSON.parse(e.data).text;
l.body.innerHTML = l.html;
scrollDown(false);
});
es.addEventListener('assistant_delta', () => {});
es.addEventListener('think_delta', (e) => {
thinkBlock(JSON.parse(e.data).text);
scrollDown(false);
});
es.addEventListener('tool', (e) => onTool(JSON.parse(e.data)));
es.addEventListener('tool_result', (e) => onToolResult(JSON.parse(e.data)));
es.addEventListener('todo', (e) => applyState(Object.assign({}, state, { todo: JSON.parse(e.data).text })));
es.addEventListener('session', () => loadSession());
es.addEventListener('permission', (e) => showPermission(JSON.parse(e.data)));
es.addEventListener('permission_done', () => closeModal());
es.addEventListener('ask_user', (e) => showAsk(JSON.parse(e.data)));
es.addEventListener('turn_end', (e) => {
const d = JSON.parse(e.data);
live = null;
if (d.rc) bubble('Error', 'assistant').body.textContent = d.error || ('rc=' + d.rc);
else loadSession(); /* replace the live view with the stored messages */
});
es.onerror = () => { $('status').textContent = '连接中断,重连中…'; };
}
/* --- modals -------------------------------------------------------------- */
function openModal(node) {
const box = $('modal-box');
box.textContent = '';
box.appendChild(node);
$('modal').hidden = false;
}
function closeModal() {
$('modal').hidden = true;
$('modal-box').textContent = '';
permReq = askReq = null;
}
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !$('modal').hidden) closeModal();
});
function showPermission(ev) {
permReq = ev;
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, (ev.sandbox ? '沙箱拦截:要在沙箱外重跑?' : '允许 ' + ev.tool + '?')));
if (ev.preview) {
const pre = el('pre');
pre.textContent = ev.preview;
box.appendChild(pre);
} else if (ev.detail) {
const pre = el('pre');
pre.textContent = ev.detail;
box.appendChild(pre);
}
const row = el('div', 'buttons');
const answer = (n) => { post('api/permission', { id: ev.id, answer: n }).catch(() => {}); closeModal(); };
row.appendChild(Object.assign(el('button', 'danger', '拒绝'), { onclick: () => answer(0) }));
row.appendChild(Object.assign(el('button', 'ok', '允许一次'), { onclick: () => answer(1) }));
row.appendChild(Object.assign(el('button', null, '总是允许(工具)'), { onclick: () => answer(2) }));
if (ev.tool === 'run_shell')
row.appendChild(Object.assign(el('button', null, '总是允许(该前缀)'), { onclick: () => answer(3) }));
box.appendChild(row);
openModal(box);
}
function showAsk(ev) {
askReq = ev;
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, 'Agent 提问'));
const pre = el('pre');
pre.textContent = ev.question || '';
box.appendChild(pre);
const row = el('div', 'buttons');
const send = (text, dismiss) => {
post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(() => {});
closeModal();
};
for (const opt of ev.options || []) {
row.appendChild(Object.assign(el('button', null, opt), { onclick: () => send(opt, false) }));
}
const free = el('input');
free.placeholder = '自定义回答';
free.style.cssText = 'flex:1;min-width:220px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit';
row.appendChild(free);
row.appendChild(Object.assign(el('button', 'ok', '回答'), { onclick: () => send(free.value, false) }));
row.appendChild(Object.assign(el('button', 'danger', '忽略'), { onclick: () => send('', true) }));
box.appendChild(row);
openModal(box);
setTimeout(() => free.focus(), 0);
}
async function showSessions() {
const data = await getJSON('api/sessions');
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '会话'));
for (const s of data.sessions) {
const row = el('div', 'sess');
row.appendChild(el('span', 'id', (s.id === data.current ? '* ' : ' ') + s.id.slice(0, 12)));
row.appendChild(el('span', 't', s.title || '(空)'));
row.appendChild(el('span', 'id', s.messages + ' msgs'));
row.appendChild(Object.assign(el('button', null, '打开'), {
onclick: async () => {
await post('api/sessions/switch', { id: s.id });
closeModal();
await loadSession();
},
}));
box.appendChild(row);
}
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', 'ok', '新建会话'), {
onclick: async () => { await post('api/sessions/new', {}); closeModal(); await loadSession(); },
}));
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
function showModel() {
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '模型与模式'));
const mk = (label, value, apply) => {
const row = el('div', 'buttons');
row.appendChild(el('span', 'muted', label));
const input = el('input');
input.value = value || '';
input.style.cssText = 'flex:1;min-width:200px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit';
row.appendChild(input);
row.appendChild(Object.assign(el('button', 'ok', '应用'), {
onclick: async () => { await post('api/config', apply(input.value)); },
}));
box.appendChild(row);
};
mk('模型', state.model, (v) => ({ model: v }));
mk('provider', state.provider, (v) => ({ provider: v }));
const row = el('div', 'buttons');
row.appendChild(el('span', 'muted', '推理强度'));
for (const level of ['low', 'medium', 'high', 'max']) {
row.appendChild(Object.assign(el('button', level === state.effort ? 'ok' : '', level), {
onclick: () => post('api/config', { effort: level }),
}));
}
box.appendChild(row);
const row2 = el('div', 'buttons');
row2.appendChild(el('span', 'muted', '审批'));
for (const mode of ['ask', 'auto', 'yolo']) {
row2.appendChild(Object.assign(el('button', mode === state.approval ? 'ok' : '', mode), {
onclick: () => post('api/config', { approval: mode }),
}));
}
row2.appendChild(Object.assign(el('button', state.plan_mode ? 'ok' : '', state.plan_mode ? 'Plan' : 'Build'), {
onclick: () => post('api/config', { plan_mode: !state.plan_mode }),
}));
box.appendChild(row2);
const row3 = el('div', 'buttons');
row3.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row3);
openModal(box);
}
/* --- composer ------------------------------------------------------------ */
async function send() {
const input = $('input');
const text = input.value.trim();
if (!text || state.status_busy) return;
input.value = '';
bubble('You', 'user').body.textContent = text;
try {
await post('api/turn', { text });
} catch (e) {
bubble('Error', 'assistant').body.textContent = String(e.message || e);
}
}
$('btn-send').onclick = send;
$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {});
$('btn-sessions').onclick = () => showSessions().catch((e) => alert(e.message));
$('btn-model').onclick = showModel;
$('btn-undo').onclick = () => post('api/undo', {}).catch((e) => alert(e.message));
$('btn-compact').onclick = () => post('api/compact', {}).catch((e) => alert(e.message));
$('btn-clear').onclick = () => { log.textContent = ''; };
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
send();
}
});
$('input').addEventListener('input', (e) => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 240) + 'px';
});
async function upload(file) {
const r = await api('api/attach?name=' + encodeURIComponent(file.name), {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!r.ok) alert(await r.text());
}
$('file').addEventListener('change', async (e) => {
for (const f of e.target.files) await upload(f);
e.target.value = '';
});
document.addEventListener('paste', async (e) => {
for (const item of e.clipboardData.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
e.preventDefault();
await upload(item.getAsFile());
}
}
});
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', async (e) => {
if (!e.dataTransfer.files.length) return;
e.preventDefault();
for (const f of e.dataTransfer.files) await upload(f);
});
/* --- go ------------------------------------------------------------------ */
connect();
loadSession().catch(() => {});
getJSON('api/state').then(applyState).catch(() => {});