Files
Hermit/web/app.js
T
WHC e238f5f37d
ci / linux (clang) (push) Successful in 5m45s
ci / linux (gcc) (push) Successful in 6m46s
ci / musl (push) Successful in 7m9s
ci / windows-cross (push) Successful in 11m52s
v0.8.2: build fixes, WebUI security and correctness
Build: <fcntl.h> in tui.c, format-truncation in http_srv.c and /web.

WebUI:
- refuse cross-site requests (Origin must be the page itself) in the
  default no-token mode: any page could POST approval=yolo and a prompt
- session/workspace/undo/compact/attach/model changes are 409 while a
  turn runs (use-after-free, messages landing in the wrong session)
- ask_user returned "dismissed" at once instead of waiting
- cancel and shutdown wake a turn parked on a permission or question;
  SIGTERM mid-turn no longer hangs or crashes
- a page opened while a question is pending still gets it;
  Last-Event-ID resumes the stream; event seq matches the SSE id
- token mode: page assets load without the token, images via cookie
- --web-open works, --web-port/--web-token are validated
- frontend: gate keys ignored while typing, clear-images button,
  reasoning no longer wiped while streaming

HTTP server: auth and size cap before reading the body, strict
Content-Length, NUL in url_decode, 413 on keep-alive, idle timeout,
absolute-form only without a leading '/', case-insensitive Connection,
header deadline, 400 for long request lines, unlink before close,
poll() instead of select(), SO_EXCLUSIVEADDRUSE on Windows.

Markdown sanitizer escapes quotes in attribute values.

/web: passes this session's provider/model/base/effort/approval (key
via the environment), keeps output off the TUI, shows the real URL and
keeps CURRENT on the TUI's session.

Tests: scripts/e2e_webui_ask.py, scripts/e2e_webui_http.py (run from
e2e_webui.sh), sanitizer quote test.
2026-10-09 13:51:34 +08:00

1085 lines
40 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 to sanitized
* HTML; this file lays out the conversation, the tool lines, the approval
* gate and the anchored popovers. Protocol: docs/webui.md. */
'use strict';
/* --- token: from ?token= / #token=; the query parameter is appended to
* every request so EventSource (which cannot set headers) works as well,
* and a same-site cookie carries it for the <img> tags in rendered
* Markdown. --- */
const TOKEN = (() => {
const q = new URLSearchParams(location.search);
if (q.get('token')) return q.get('token');
return new URLSearchParams(location.hash.replace(/^#/, '')).get('token') || '';
})();
if (TOKEN) document.cookie = 'cst_token=' + encodeURIComponent(TOKEN) + '; path=/; SameSite=Strict';
const qs = (extra) => {
const p = new URLSearchParams(extra || {});
if (TOKEN) p.set('token', TOKEN);
const s = p.toString();
return s ? '?' + s : '';
};
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 el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
};
const attachURL = (rel) => 'api/attach/' + rel + qs();
const fmtSecs = (ms) => {
const s = ms / 1000;
if (s < 60) return s.toFixed(1) + ' 秒';
const m = Math.floor(s / 60);
return m + ' 分 ' + Math.round(s - m * 60) + ' 秒';
};
const num = (n) => (n || 0).toLocaleString();
const log = $('log');
const scroll = $('scroll');
let state = {};
let live = null; /* streaming assistant block */
let steps = null; /* this turn's tool list */
let stepIndex = new Map(); /* tool call id -> step row */
let files = new Map(); /* this turn's file changes: path -> {add, del} */
let permReq = null;
let askReq = null;
let busy = false;
let turnStart = 0;
/* --- anchored popovers ---------------------------------------------------
* The pattern the reference apps use: a small sheet under the control that
* owns the setting, each row = icon + title + one line, a check on the
* current value. Shift+Tab and the arrow keys work, Escape closes. */
let popAnchor = null;
let popOpenedAt = 0;
/* icons all come from the sprite in index.html: one 24px grid, one stroke
* weight, round caps. A mixed bag of unicode glyphs never reads as a family. */
const SVGNS = 'http://www.w3.org/2000/svg';
function icon(name, cls) {
/* an unknown name is just an old text glyph: keep it, do not blank it out */
if (!document.getElementById('i-' + name)) return el('span', cls || '', name);
const s = document.createElementNS(SVGNS, 'svg');
s.setAttribute('class', 'i' + (cls ? ' ' + cls : ''));
const u = document.createElementNS(SVGNS, 'use');
u.setAttribute('href', '#i-' + name);
s.appendChild(u);
return s;
}
function setIcon(btn, name) {
const old = btn.querySelector('svg.i');
if (old) old.replaceWith(icon(name));
}
function closePop() {
const p = $('pop');
p.hidden = true;
p.textContent = '';
popAnchor = null;
}
function popRow(o) {
const b = el('button', 'pop-item' + (o.on ? ' on' : '') + (o.danger ? ' danger' : ''));
b.appendChild(o.icon ? icon(o.icon, 'pi') : el('span', 'pi'));
const m = el('span', 'pm');
m.appendChild(el('span', 'pt', o.title));
if (o.desc) m.appendChild(el('span', 'pd', o.desc));
b.appendChild(m);
if (o.on) b.appendChild(el('span', 'pk', '✓'));
b.onclick = () => { const fn = o.onClick; closePop(); if (fn) fn(); };
return b;
}
function positionPop(pop, anchor, align) {
const a = anchor.getBoundingClientRect();
pop.style.visibility = 'hidden';
pop.style.left = '0px';
pop.style.top = '0px';
const p = pop.getBoundingClientRect();
let left = align === 'right' ? a.right - p.width : a.left;
left = Math.max(8, Math.min(left, window.innerWidth - p.width - 8));
let top = a.bottom + 8;
let origin = 'top';
if (top + p.height > window.innerHeight - 8) {
top = Math.max(8, a.top - p.height - 8);
origin = 'bottom';
}
pop.style.left = left + 'px';
pop.style.top = top + 'px';
pop.style.transformOrigin = (align === 'right' ? '100%' : '0') + ' ' + (origin === 'bottom' ? '100%' : '0');
pop.style.visibility = 'visible';
}
function openPop(anchor, build, opt) {
const pop = $('pop');
if (popAnchor === anchor) return closePop(); /* second tap closes */
closePop();
pop.textContent = '';
build(pop, closePop);
pop.hidden = false;
positionPop(pop, anchor, (opt && opt.align) || 'left');
popAnchor = anchor;
popOpenedAt = Date.now();
}
document.addEventListener('click', (e) => {
if ($('pop').hidden) return;
/* the click lands on a child of the anchor button, so compare containers */
if ($('pop').contains(e.target) || (popAnchor && popAnchor.contains(e.target))) return;
closePop();
});
/* a phone browser fires resize while the address bar settles right after the
* popover opened; that is not the user dismissing it */
window.addEventListener('resize', () => {
if (Date.now() - popOpenedAt > 700) closePop();
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !$('pop').hidden) closePop();
});
/* --- transcript ---------------------------------------------------------- */
function atBottom() {
return scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 60;
}
function scrollDown(force) {
if (force || atBottom()) scroll.scrollTop = scroll.scrollHeight;
updateJump();
}
function updateJump() {
$('to-bottom').hidden = atBottom();
}
function showEmpty(show) {
$('empty').classList.toggle('gone', !show);
}
function bubble(role, cls) {
const m = el('div', 'msg ' + (cls || role));
/* no avatar per message: the assistant reads as plain text in the column,
* the way the reference transcript does; only errors get a mark */
const g = el('div', 'gutter', role === 'error' ? '!' : '');
const b = el('div', 'body');
m.appendChild(g);
m.appendChild(b);
log.appendChild(m);
showEmpty(false);
scrollDown(true);
return { root: m, body: b };
}
function copyButton(text) {
const b = el('button', null, '复制');
b.onclick = async () => {
try {
await navigator.clipboard.writeText(text);
b.textContent = '已复制';
setTimeout(() => { b.textContent = '复制'; }, 1200);
} catch { b.textContent = '复制失败'; }
};
return b;
}
function attachStrip(atts) {
const strip = el('div', 'attach-strip');
for (const a of atts) {
const img = el('img');
img.alt = a.name || 'image';
img.loading = 'lazy';
img.src = attachURL(a.rel);
strip.appendChild(img);
}
return strip;
}
/* reasoning belongs inside .body: as a sibling it becomes a second flex item
* and squeezes the answer into a narrow column */
function thinkInto(body, text, open) {
let d = body.querySelector(':scope > details.think');
let t;
if (!d) {
d = el('details', 'think');
d.appendChild(el('summary', null, '思考过程'));
t = el('div');
d.appendChild(t);
body.insertBefore(d, body.firstChild);
} else {
t = d.querySelector('div');
}
if (open) d.open = true;
t.textContent += text;
return d;
}
function actionsFor(root, text) {
const row = el('div', 'msg-actions');
row.appendChild(copyButton(text));
root.appendChild(row);
return row;
}
function renderMessage(m) {
if (m.role === 'system' || m.role === 'tool') return;
const isUser = m.role === 'user';
const b = bubble(isUser ? 'user' : 'assistant');
if (!isUser && m.html) b.body.innerHTML = m.html;
else b.body.textContent = m.content || '';
if (m.reasoning) thinkInto(b.body, m.reasoning, false);
if (m.attach && m.attach.length) b.body.insertBefore(attachStrip(m.attach), b.body.firstChild);
if (!isUser) actionsFor(b.root, m.content || '');
}
async function loadSession() {
const data = await getJSON('api/session');
log.textContent = '';
stepIndex = new Map();
files = new Map();
renderFilesPanel();
steps = null;
live = null;
for (const m of data.messages) renderMessage(m);
showEmpty(log.children.length === 0);
const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user');
document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent';
scrollDown(true);
updateJump();
}
/* --- live blocks --------------------------------------------------------- */
function ensureLive() {
if (live) return live;
const b = bubble('assistant');
b.body.classList.add('live');
/* the answer gets its own box: rewriting body.innerHTML would drop the
* reasoning <details> that thinkInto keeps at the top of body */
const out = el('div', 'live-html');
b.body.appendChild(out);
live = { root: b.root, body: b.body, out, html: '' };
return live;
}
function ensureSteps() {
if (steps) return steps;
const d = el('details', 'turn-steps');
d.open = true;
const s = el('summary', 'steps-sum', '本轮工具调用');
d.appendChild(s);
const box = el('div', 'steps-box');
d.appendChild(box);
log.appendChild(d);
steps = { root: d, sum: s, box, count: 0 };
return steps;
}
function phaseText(p) {
return { open: '准备中', ready: '待执行', run: '执行中', done: '完成' }[p] || p || '';
}
function argsLine(args) {
if (!args) return '';
return Object.entries(args)
.map(([k, v]) => (typeof v === 'string' ? v : JSON.stringify(v)))
.join(' ');
}
/* one line per call, like "已执行 python · <<PY>": open it for the details */
function stepFor(ev) {
let st = stepIndex.get(ev.id);
if (st) return st;
const c = ensureSteps();
const node = el('details', 'step-line run');
const sum = el('summary');
const name = el('span', 'name', ev.name || 'tool');
const args = el('span', 'args');
const phase = el('span', 'phase', '准备中');
sum.appendChild(name);
sum.appendChild(args);
sum.appendChild(phase);
const body = el('div', 'step-body');
node.appendChild(sum);
node.appendChild(body);
c.box.appendChild(node);
st = { root: node, sum, name, args, phase, body, t0: Date.now(), bad: false };
stepIndex.set(ev.id, st);
c.count++;
c.sum.textContent = '本轮工具调用 · ' + c.count + ' 次';
return st;
}
/* --- the run panel answers one question: which files moved, and by how many
* lines. Everything else about a call stays in the transcript where it
* happened, so the panel stays readable no matter how long a turn runs. */
function linesOf(s) {
return (typeof s === 'string' && s !== '') ? s.split('\n').length : 0;
}
function patchStat(text) {
let add = 0, del = 0;
for (const ln of String(text).split('\n')) {
if (ln.startsWith('+++') || ln.startsWith('---')) continue;
if (ln.charAt(0) === '+') add++;
else if (ln.charAt(0) === '-') del++;
}
return { add, del };
}
function statFromArgs(name, args) {
const a = args || {}, n = String(name || '');
const body = a.patch ?? a.diff ?? a.input;
if (/patch|diff/i.test(n) && typeof body === 'string') return patchStat(body);
if (/write|create/i.test(n) && typeof a.content === 'string')
return { add: linesOf(a.content), del: 0 };
if (/edit|replace|rename/i.test(n)) {
const old = a.old_string ?? a.old ?? a.find ?? a.old_text;
const nw = a.new_string ?? a.new ?? a.replace ?? a.new_text;
if (typeof nw === 'string' || typeof old === 'string')
return { add: linesOf(nw), del: linesOf(old) };
}
return null;
}
function noteFileChange(ev) {
const st = statFromArgs(ev.name, ev.args);
if (!st) return;
const a = ev.args || {};
const path = String(a.path || a.file_path || a.file || a.filename || '补丁');
const cur = files.get(path) || { add: 0, del: 0 };
cur.add += st.add;
cur.del += st.del;
files.set(path, cur);
renderFilesPanel();
}
function renderFilesPanel() {
const box = $('files-body');
if (!box) return;
box.textContent = '';
box.className = '';
if ($('files-count')) $('files-count').textContent = String(files.size);
if (!files.size) {
box.className = 'muted';
box.textContent = '本轮还没有改动文件。';
return;
}
for (const [p, v] of files) {
const row = el('div', 'fstat');
row.appendChild(icon('file', 'fimg'));
const nm = el('span', 'p', p);
nm.title = p;
row.appendChild(nm);
row.appendChild(el('span', 'a', '+' + v.add));
row.appendChild(el('span', 'd', '−' + v.del));
box.appendChild(row);
}
}
function onTool(ev) {
const st = stepFor(ev);
const a = argsLine(ev.args);
if (a) st.args.textContent = a;
if (ev.detail && !st.args.textContent) st.args.textContent = ev.detail;
st.phase.textContent = phaseText(ev.phase);
st.root.classList.toggle('run', ev.phase !== 'done');
if (ev.phase === 'done') {
st.root.classList.remove('run');
st.root.classList.add('done');
st.phase.textContent = '完成 · ' + ((Date.now() - st.t0) / 1000).toFixed(1) + 's';
}
scrollDown(false);
}
function onToolResult(ev) {
const st = stepFor(ev);
st.root.classList.remove('run');
st.bad = !!ev.exit;
st.root.classList.toggle('bad', st.bad);
st.phase.textContent = st.bad ? ('失败 exit=' + ev.exit) : '完成';
st.body.textContent = '';
if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent));
if (ev.out) st.body.appendChild(el('pre', 'step-out', ev.out + (ev.truncated ? '\n… (截断)' : '')));
if (!st.bad) noteFileChange(ev);
scrollDown(false);
}
function turnMeta(ms) {
const wrap = el('div', 'turn-meta');
const d = el('details');
d.appendChild(el('summary', null, '用时 ' + fmtSecs(ms)));
d.appendChild(el('div', 'muted tiny', '从发出去到最后一个工具结束;模型延迟和工具耗时都算在里面。'));
wrap.appendChild(d);
log.appendChild(wrap);
}
/* --- model checklist ----------------------------------------------------- */
function todoRows(text) {
const rows = [];
for (const raw of String(text || '').split('\n')) {
let line = raw.replace(/\s+$/, '');
if (!line.trim()) continue;
let done = false, doing = false;
line = line.replace(/^\s*[-*+]\s*/, '').replace(/^\s*\d+[.)]\s*/, '');
if (/^\[[xX]\]/.test(line)) { done = true; line = line.replace(/^\[[xX]\]\s*/, ''); }
else line = line.replace(/^\[( |>)\]\s*/, '');
if (/^(✔|✅|\[done\])/i.test(line)) { done = true; line = line.replace(/^(✔|✅|\[done\])\s*/i, ''); }
else if (/^(▶|➡|\[now\]|\[doing\])/i.test(line)) { doing = true; line = line.replace(/^(▶|➡|\[now\]|\[doing\])\s*/i, ''); }
if (!line.trim()) continue;
rows.push({ text: line, done, doing });
}
return rows;
}
function renderTodo(text) {
const body = $('todo-body');
body.textContent = '';
const rows = todoRows(text);
if (!rows.length) {
body.className = 'muted';
body.textContent = '模型还没有给出任务清单。';
return;
}
body.className = '';
const ul = el('ul', 'todo-list');
for (const r of rows) {
const li = el('li', r.done ? 'done' : r.doing ? 'doing' : '');
li.appendChild(el('span', 'boxmark', r.done ? '☑' : r.doing ? '▣' : '☐'));
li.appendChild(el('span', null, r.text));
ul.appendChild(li);
}
body.appendChild(ul);
}
/* --- state --------------------------------------------------------------- */
const APPROVALS = [
{ key: 'ask', icon: 'shield', title: '每次询问', desc: '动文件或跑命令之前都要你点一下' },
{ key: 'auto', icon: 'bolt', title: '自动', desc: '直接执行,只有危险命令才问你' },
{ key: 'yolo', icon: 'alert', title: '完全放行', desc: '不再询问,风险自负' },
];
const EFFORTS = [
{ key: 'low', title: '低', desc: '最快,适合小改动' },
{ key: 'medium', title: '中', desc: '默认平衡' },
{ key: 'high', title: '高', desc: '多想一点,慢一些' },
{ key: 'max', title: '极高', desc: '最慢最贵,难题再用' },
];
function applyState(s) {
state = Object.assign({}, state, s);
const model = (s.provider ? s.provider + ' · ' : '') + (s.model || '-');
$('chip-model').querySelector('.lbl').textContent = model;
$('chip-model').title = model + (s.effort ? ' · ' + s.effort : '');
const ap = $('chip-approval');
const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto'));
ap.querySelector('.lbl').textContent = (cur ? cur.title : s.approval || 'auto');
ap.classList.toggle('warn', s.approval === 'ask');
ap.title = '审批权限:' + (cur ? cur.title : s.approval);
for (const b of $('seg-mode').children)
b.classList.toggle('on', (b.dataset.mode === 'plan') === !!s.plan_mode);
$('foot-ws').textContent = s.workspace || 'workspace';
$('foot-session').textContent = (s.session || '-').slice(0, 20);
const u = s.usage || {};
const money = (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : '';
/* the panel carries two numbers only: what the context costs, and how much
* of it the provider already had cached */
const hit = Math.max(0, u.cached_tokens || 0);
const miss = Math.max(0, u.cache_miss_tokens || 0);
$('kv-ctx').textContent = num(s.context_tokens) + ' tok';
$('kv-cache').textContent = (hit + miss) ? Math.round((hit / (hit + miss)) * 100) + '%' : '-';
$('kv-cache').title = (hit + miss)
? num(hit) + ' 命中 / ' + num(miss) + ' 未命中' : '还没有缓存数据';
$('cost').textContent = money;
$('usage').textContent = u.requests ? `${u.requests} 往返 · ${num(u.prompt_tokens)}/${num(u.completion_tokens)} tok` : '';
$('btn-undo').disabled = !s.undo_available;
busy = !!s.status_busy;
$('btn-stop').hidden = !busy;
syncSend();
if (!busy && !permReq) $('status').textContent = '就绪';
const p = $('pending');
const n = (s.pending_images || []).length;
p.hidden = !n;
if (n) {
p.textContent = '';
for (const line of s.pending_images) {
const chip = el('span', 'chip');
chip.appendChild(el('span', null, line));
const x = el('button', 'icon tiny');
x.appendChild(icon('close'));
x.title = '清空待发图片';
x.onclick = () => api('api/attach', { method: 'DELETE' }).catch(() => {});
chip.appendChild(x);
p.appendChild(chip);
}
}
renderTodo(s.todo);
}
/* --- SSE ----------------------------------------------------------------- */
function connect() {
const es = new EventSource('api/events' + qs());
/* a turn can also start outside this page (the CLI, another client), so the
* clock starts on the first sign of activity, not only on send() */
const markBusy = () => { if (!turnStart) turnStart = Date.now(); };
const on = (name, fn) => es.addEventListener(name, (e) => {
try { fn(JSON.parse(e.data)); } catch (err) { console.error(name, err); }
});
on('state', applyState);
on('status', (d) => { markBusy(); if (d.message) $('status').textContent = d.message; });
on('assistant_html', (d) => {
markBusy();
const l = ensureLive();
l.html += d.text || '';
l.out.innerHTML = l.html;
scrollDown(false);
});
on('think_delta', (d) => {
markBusy();
thinkInto(ensureLive().body, d.text || '', true);
scrollDown(false);
});
on('tool', (ev) => { markBusy(); onTool(ev); });
on('tool_result', onToolResult);
on('todo', (d) => renderTodo(d.text));
on('session', (d) => {
closePop();
live = null; steps = null; stepIndex = new Map();
loadSession().then(loadSessions).catch(() => {});
closeDrawers();
if (d && d.id) $('status').textContent = '已切换会话';
});
on('permission', showGate);
on('permission_done', hideGate);
on('ask_user', showAsk);
on('turn_end', (d) => {
if (turnStart) { turnMeta(Date.now() - turnStart); turnStart = 0; }
if (live) live.body.classList.remove('live');
live = null; steps = null; stepIndex = new Map();
if (d && d.rc) bubble('error').body.textContent = d.error || ('rc=' + d.rc);
loadSession().then(loadSessions).catch(() => {});
});
/* the browser resumes from Last-Event-ID; a restarted server has none of
* those events, so its state (and maybe session) is fetched afresh */
let dropped = false;
es.onerror = () => { dropped = true; $('status').textContent = '连接中断,重连中…'; };
es.onopen = () => {
if (!dropped) return;
dropped = false;
if (!busy) $('status').textContent = '就绪';
const was = state.session;
getJSON('api/state').then((s) => {
applyState(s);
if (s.session !== was) loadSession().then(loadSessions).catch(() => {});
}).catch(() => {});
};
}
/* --- approval gate ------------------------------------------------------- */
const GATE_KEYS = [
{ v: 1, label: '允许一次', key: '1', cls: 'ok' },
{ v: 2, label: '总是允许这个工具', key: '2', cls: '' },
{ v: 3, label: '总是允许这个前缀', key: '3', cls: '' },
{ v: 0, label: '拒绝', key: '4', cls: 'danger' },
];
let gateTimer = null;
function showGate(ev) {
permReq = ev;
$('gate').hidden = false;
$('gate-title').textContent = ev.sandbox
? '沙箱拦下了这一步,要在沙箱外重跑吗?'
: '允许 ' + (ev.tool || '工具') + ' 运行吗?';
$('gate-detail').textContent = ev.preview || ev.detail || '';
const row = $('gate-buttons');
row.textContent = '';
for (const a of GATE_KEYS) {
const b = el('button', a.cls, a.label + ' (' + a.key + ')');
b.onclick = () => answerGate(a.v);
row.appendChild(b);
}
$('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。';
$('status').textContent = '等待你的许可…';
let left = 600;
clearInterval(gateTimer);
const tick = () => {
$('gate-timer').textContent = left >= 0 ? left + 's' : '';
if (left-- <= 0) clearInterval(gateTimer);
};
tick();
gateTimer = setInterval(tick, 1000);
scrollDown(true);
}
function hideGate() {
clearInterval(gateTimer);
gateTimer = null;
permReq = null;
$('gate').hidden = true;
$('gate-timer').textContent = '';
if (!busy) $('status').textContent = '就绪';
}
async function answerGate(value) {
if (!permReq) return;
const id = permReq.id;
hideGate();
try {
await post('api/permission', { id, answer: value });
$('status').textContent = value ? '已允许,继续执行…' : '已拒绝';
} catch (e) {
$('status').textContent = '回答失败:' + (e.message || e);
}
}
window.addEventListener('keydown', (e) => {
/* typing "3" or "y" into the box must not grant a permission */
const typing = e.isComposing || e.ctrlKey || e.metaKey || e.altKey ||
(e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)));
if (permReq && !$('gate').hidden && !typing) {
if (e.key === '1' || e.key.toLowerCase() === 'y') answerGate(1);
else if (e.key === '2') answerGate(2);
else if (e.key === '3') answerGate(3);
else if (e.key === '4' || e.key.toLowerCase() === 'n') answerGate(0);
}
if (e.key === 'Escape') {
if (!$('modal').hidden) return closeModal();
if (!$('pop').hidden) return closePop();
if (busy) return void post('api/cancel', {}).catch(() => {});
}
if (e.ctrlKey && e.key.toLowerCase() === 'b') { e.preventDefault(); toggleSide(); }
if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); }
if (e.ctrlKey && e.key.toLowerCase() === 'k') { e.preventDefault(); showSessions(); }
});
/* --- modal sheet (session list) ------------------------------------------ */
function openModal(node) {
const box = $('modal-box');
box.textContent = '';
box.appendChild(node);
$('modal').hidden = false;
}
function closeModal() {
$('modal').hidden = true;
$('modal-box').textContent = '';
askReq = null;
}
function showAsk(ev) {
askReq = ev;
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, ev.question || '模型想确认一件事'));
if (ev.detail && ev.detail !== ev.question) box.appendChild(el('pre', 'step-out', ev.detail));
const send = (text, dismiss) => {
post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(() => {});
closeModal();
};
const row = el('div', 'buttons');
for (const opt of ev.options || [])
row.appendChild(Object.assign(el('button', 'ok', opt), { onclick: () => send(opt, false) }));
const free = el('input');
free.placeholder = '自定义回答';
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);
}
/* --- sessions ------------------------------------------------------------ */
let sessionsCache = [];
function timeLabel(id) {
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/.exec(id || '');
return m ? `${+m[2]}/${+m[3]} ${m[4]}:${m[5]}` : '';
}
async function loadSessions() {
let data;
try { data = await getJSON('api/sessions'); } catch { return; }
sessionsCache = data.sessions || [];
const box = $('sessions-list');
box.textContent = '';
for (const s of sessionsCache.slice(0, 60)) {
const b = el('button', 'sess-item' + (s.id === data.current ? ' on' : ''));
const t = el('span', 't');
const ts = el('span', 'ts', s.title || '(空会话)');
t.appendChild(ts);
b.appendChild(t);
b.appendChild(el('span', 'n', s.messages + ' 条 · ' + (timeLabel(s.id) || '')));
b.title = s.id + ' · ' + s.messages + ' 条消息';
b.onclick = async () => {
if (s.id === (state.session || '')) { closeDrawers(); return; }
await post('api/sessions/switch', { id: s.id });
await loadSession();
await loadSessions();
closeDrawers();
};
box.appendChild(b);
}
if (!sessionsCache.length) box.appendChild(el('div', 'muted tiny', '还没有历史会话'));
/* measure after layout: only rows whose title really overflows get the scroll */
requestAnimationFrame(() => {
for (const t of box.querySelectorAll('.t')) {
const ts = t.firstChild;
if (ts && ts.scrollWidth > t.clientWidth + 2) {
t.classList.add('mq');
t.style.setProperty('--mq', (ts.scrollWidth - t.clientWidth + 6) + 'px');
}
}
});
}
async function showSessions() {
await loadSessions();
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '会话'));
for (const s of sessionsCache) {
const row = el('div', 'sess');
row.appendChild(el('span', 'id', (s.id === state.session ? '● ' : ' ') + s.id.slice(0, 12)));
row.appendChild(el('span', 't', s.title || '(空会话)'));
row.appendChild(el('span', 'id', s.messages + ' 条'));
row.appendChild(Object.assign(el('button', null, '打开'), {
onclick: async () => {
await post('api/sessions/switch', { id: s.id });
closeModal();
await loadSession();
await loadSessions();
},
}));
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();
await loadSessions();
},
}));
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
/* --- pickers as popovers ------------------------------------------------- */
function modelPopover(anchor) {
openPop(anchor, (pop) => {
pop.appendChild(el('div', 'pop-title', '模型'));
const row = el('div', 'pop-input');
const input = el('input');
input.value = state.model || '';
input.placeholder = '模型名';
row.appendChild(input);
const apply = el('button', null, '应用');
apply.onclick = () => { post('api/config', { model: input.value }).catch(() => {}); };
row.appendChild(apply);
pop.appendChild(row);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') apply.click(); });
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(el('div', 'pop-title', '推理强度'));
const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' };
for (const e2 of EFFORTS)
pop.appendChild(popRow({
icon: LV[e2.key] || 'lv1',
title: e2.title,
desc: e2.desc,
on: e2.key === state.effort,
onClick: () => post('api/config', { effort: e2.key }).catch(() => {}),
}));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({
icon: 'settings',
title: 'provider:' + (state.provider || '-'),
desc: '在设置里改连接端点',
onClick: showSettings,
}));
setTimeout(() => input.focus(), 0);
});
}
function approvalPopover(anchor) {
openPop(anchor, (pop) => {
pop.appendChild(el('div', 'pop-title', '审批权限'));
for (const a of APPROVALS)
pop.appendChild(popRow({
icon: a.icon,
title: a.title,
desc: a.desc,
on: a.key === (state.approval || 'auto'),
danger: a.key === 'yolo',
onClick: () => post('api/config', { approval: a.key }).catch(() => {}),
}));
}, { align: 'right' });
}
function plusPopover(anchor) {
openPop(anchor, (pop) => {
pop.appendChild(popRow({
icon: 'image', title: '上传图片', desc: '也可以直接粘贴或拖进来',
onClick: () => $('file').click(),
}));
pop.appendChild(popRow({
icon: 'clipboard', title: '粘贴剪贴板', desc: '从剪贴板里取一张图',
onClick: async () => {
try {
const items = await navigator.clipboard.read();
for (const it of items) {
const type = it.types.find((t) => t.startsWith('image/'));
if (!type) continue;
const blob = await it.getType(type);
await upload(new File([blob], 'clipboard.png', { type }));
return;
}
$('status').textContent = '剪贴板里没有图片';
} catch { $('status').textContent = '浏览器不允许读剪贴板,请直接粘贴'; }
},
}));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({
icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件',
on: !!state.plan_mode,
onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(() => {}),
}));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(el('div', 'pop-title', '工作目录'));
const wrow = el('div', 'pop-input');
const wpath = el('input');
wpath.value = state.workspace || '';
wpath.placeholder = '/绝对路径/到项目';
wpath.spellcheck = false;
const wgo = el('button', null, '切换');
wgo.onclick = async () => {
const p = wpath.value.trim();
if (!p) return;
wgo.disabled = true;
try {
await post('api/workspace', { path: p });
closePop();
await loadSession();
await loadSessions();
$('status').textContent = '工作目录已切换';
} catch (e) {
$('status').textContent = String(e.message || e);
wgo.disabled = false;
}
};
wrow.appendChild(wpath);
wrow.appendChild(wgo);
pop.appendChild(wrow);
});
}
function usagePopover(anchor) {
const u = state.usage || {};
openPop(anchor, (pop) => {
pop.appendChild(el('div', 'pop-title', '状态'));
const kv = (k, v) => {
const row = el('div', 'kv');
row.appendChild(el('span', null, k));
row.appendChild(el('b', null, v));
pop.appendChild(row);
};
kv('工作区', state.workspace || '-');
kv('会话', state.session || '-');
kv('上下文占用', num(state.context_tokens) + ' tok');
kv('模型往返', String(u.requests || 0));
kv('Token', num(u.prompt_tokens) + ' / ' + num(u.completion_tokens));
kv('费用', (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : (u.requests ? '未定价' : '-'));
kv('协议', state.protocol || '-');
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') }));
pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', desc: '把旧消息收成摘要', onClick: compact }));
}, { align: 'right' });
}
function menuPopover(anchor) {
openPop(anchor, (pop) => {
pop.appendChild(popRow({ icon: 'list', title: '会话列表', desc: 'Ctrl+K', onClick: showSessions }));
pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') }));
pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', onClick: compact }));
pop.appendChild(popRow({
icon: 'undo', title: '撤销上一次写入', desc: '把文件恢复到工具改写之前',
onClick: undo,
}));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).catch(() => {}) }));
}, { align: 'right' });
}
function showSettings() {
openPop($('btn-settings'), (pop) => {
pop.appendChild(el('div', 'pop-title', '工作区'));
const kv = (k, v) => {
const row = el('div', 'kv');
row.appendChild(el('span', null, k));
row.appendChild(el('b', null, v || '-'));
pop.appendChild(row);
};
kv('目录', state.workspace);
kv('会话', state.session);
kv('模型', (state.provider ? state.provider + ' · ' : '') + state.model);
kv('协议', state.protocol);
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) }));
pop.appendChild(popRow({ icon: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) }));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
});
}
async function copyText(t) {
try {
await navigator.clipboard.writeText(t);
$('status').textContent = '已复制:' + t;
} catch { $('status').textContent = '复制失败'; }
}
/* --- composer ------------------------------------------------------------ */
function syncSend() {
$('btn-send').disabled = busy || !$('input').value.trim();
}
async function compact() {
$('status').textContent = '压缩中…';
try { await post('api/compact', {}); $('status').textContent = '上下文已压缩'; }
catch (e) { $('status').textContent = '压缩失败:' + (e.message || e); }
}
async function undo() {
try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; }
catch (e) { $('status').textContent = '撤销失败:' + (e.message || e); }
}
async function send(text) {
const input = $('input');
const body = (text !== undefined ? text : input.value).trim();
if (!body || busy) return;
if (text === undefined) input.value = '';
autoGrow();
syncSend();
bubble('user').body.textContent = body;
steps = null;
stepIndex = new Map();
files = new Map();
live = null;
renderFilesPanel();
turnStart = Date.now();
try {
await post('api/turn', { text: body });
$('status').textContent = '已开始…';
} catch (e) {
bubble('error').body.textContent = String(e.message || e);
}
}
function autoGrow() {
const t = $('input');
t.style.height = 'auto';
t.style.height = Math.min(t.scrollHeight, 240) + 'px';
}
async function upload(file) {
const r = await api('api/attach?name=' + encodeURIComponent(file.name || 'pasted.png'), {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!r.ok) $('status').textContent = '上传失败:' + (await r.text());
}
/* --- layout -------------------------------------------------------------- */
function toggleSide() {
if (window.innerWidth <= 900) {
const open = document.body.classList.toggle('side-open');
document.body.classList.toggle('drawer-open', open);
document.body.classList.remove('panel-open');
return;
}
localStorage.setItem('cst.side', document.body.classList.toggle('no-side') ? '0' : '1');
}
function togglePanel() {
if (window.innerWidth <= 900) {
const open = document.body.classList.toggle('panel-open');
document.body.classList.toggle('drawer-open', open);
document.body.classList.remove('side-open');
return;
}
localStorage.setItem('cst.panel', document.body.classList.toggle('no-panel') ? '0' : '1');
}
function closeDrawers() {
document.body.classList.remove('side-open', 'panel-open', 'drawer-open');
}
function applyTheme(t) {
document.documentElement.dataset.theme = t;
localStorage.setItem('cst.theme', t);
}
function toggleTheme() {
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
}
/* --- wiring -------------------------------------------------------------- */
$('btn-send').onclick = () => send();
$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {});
$('btn-new').onclick = async () => {
try { await post('api/sessions/new', {}); } catch (e) { $('status').textContent = String(e.message || e); }
await loadSession();
await loadSessions();
closeDrawers();
};
$('btn-refresh-sessions').onclick = () => loadSessions();
$('chip-model').onclick = () => modelPopover($('chip-model'));
$('chip-approval').onclick = () => approvalPopover($('chip-approval'));
$('btn-plus').onclick = () => plusPopover($('btn-plus'));
$('cost').onclick = () => usagePopover($('cost'));
$('btn-menu').onclick = () => menuPopover($('btn-menu'));
$('usage').onclick = () => usagePopover($('cost'));
$('btn-settings').onclick = showSettings;
$('btn-collapse').onclick = toggleSide;
$('btn-side').onclick = toggleSide;
$('btn-drawer').onclick = toggleSide;
$('btn-panel').onclick = togglePanel;
$('btn-panel-close').onclick = togglePanel;
$('btn-theme').onclick = toggleTheme;
$('btn-undo').onclick = undo;
$('to-bottom').onclick = () => scrollDown(true);
for (const b of $('seg-mode').children) {
b.onclick = async () => {
await post('api/config', { plan_mode: b.dataset.mode === 'plan' });
$('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写';
};
}
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
});
$('input').addEventListener('input', () => { autoGrow(); syncSend(); });
$('scroll').addEventListener('scroll', updateJump);
$('scroll').addEventListener('wheel', closePop, { passive: true });
$('file').addEventListener('change', async (e) => {
for (const f of e.target.files) await upload(f);
e.target.value = '';
});
document.addEventListener('paste', async (e) => {
const items = (e.clipboardData || {}).items || [];
for (const item of 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);
});
$('backdrop').onclick = closeDrawers;
/* --- go ------------------------------------------------------------------ */
applyTheme(localStorage.getItem('cst.theme') || 'dark');
/* the rail/panel/theme classes are already on <body> from the inline script in
* index.html, so nothing here has to re-derive them */
autoGrow();
syncSend();
renderFilesPanel();
connect();
loadSession().catch((e) => { $('status').textContent = String(e.message || e); });
loadSessions();
getJSON('api/state').then(applyState).catch(() => {});
/* deep link: ?open=chip-approval / ?open=cost opens that popover once the
* state is in, so a screenshot or a bookmark can land on it directly */
const wantsOpen = new URLSearchParams(location.search).get('open');
if (wantsOpen) setTimeout(() => { const a = $(wantsOpen); if (a) a.click(); }, 900);