Files
Hermit/web/app.js
T
WHC adf3e9f024
ci / linux (clang) (push) Failing after 31s
ci / linux (gcc) (push) Failing after 32s
ci / musl (push) Failing after 39s
ci / windows-cross (push) Canceled after 2m32s
v0.8.0: WebUI redesign, /web, workspace switch, 75% budget, no shell timeout
The front end is rebuilt on the design tokens the desktop app ships (surfaces,
color-mix borders, alpha hovers, accent): a 30-icon inline SVG sprite, dark and
light themes, layout switches as deep links (?side=0, ?panel=0, ?theme=light)
resolved before the first paint. The empty page is one line and leaves with the
first turn; the right rail keeps three cards - todo, file changes (+N -M) and
context (tokens, prompt cache hit rate). Both rails animate in and out, and a
collapsed rail widens its grid track to 0 instead of being removed, so the
transcript never slides into a zero-width column.

- src/webui.c: POST /api/workspace - re-root the agent at another directory
  (permission rules, metrics, AGENTS.md follow) and start a session there, as
  sessions live per directory; state also reports cached/cache_miss tokens
- src/agent_loop.c: cst_agent_set_workspace(), rebuilt prompt and re-measured
  budget; the auto-compact threshold is 75% of the window (was 60%)
- src/tui.c: /web forks a WebUI next to the TUI - separate process and a new
  session, because cst_webui_run() blocks and two writers corrupt one session
- src/os_compat.c, src/os_win.c: run_shell runs without a time limit unless
  CST_SHELL_TIMEOUT asks for one, so builds and long clones finish
- src/main.c, include/cst_webui.h: one CST_WEBUI_DEFAULT_PORT for --help, the
  docs and /web; Termux opens the browser through termux-open-url
- README.md, docs/webui.md, docs/env_openai.md: /web, /api/workspace, the new
  state fields, the 75% threshold and the layout deep links

tests/e2e_webui.sh green (permission round-trip, token paths, --web-dev);
make check green.
2026-10-09 11:50:13 +08:00

1064 lines
39 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= 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');
return new URLSearchParams(location.hash.replace(/^#/, '')).get('token') || '';
})();
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');
live = { root: b.root, body: b.body, 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 = () => post('api/attach', {}).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.body.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(() => {});
});
es.onerror = () => { $('status').textContent = '连接中断,重连中…'; };
}
/* --- 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);
$('input').focus();
}
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) => {
if (permReq && !$('gate').hidden) {
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);