/* 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
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 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 · <": 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 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);