/* 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);
}
/* the server answers errors as {"error": "..."}; status and body ride along
* so a caller can tell "needs a key" from "rejected key" */
class ApiError extends Error {
constructor(msg, status, data) { super(msg); this.status = status; this.data = data || {}; }
}
async function readReply(r) {
const text = await r.text();
let data = null;
try { data = text ? JSON.parse(text) : {}; } catch { data = null; }
if (!r.ok) throw new ApiError(errorText(r.status, (data && data.error) || text || r.statusText), r.status, data);
return data === null ? text : data;
}
async function getJSON(path) {
return readReply(await api(path));
}
async function post(path, body, method) {
const r = await api(path, {
method: method || 'POST',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return readReply(r);
}
/* engine messages that are still English, in the words the page uses */
const ERR_ZH = [
[/^busy: wait for the running turn/, '正在运行,等这一轮结束再试'],
[/^a turn is already running/, '已经有一轮在运行'],
[/^another change is running/, '另一项修改正在进行,稍后再试'],
[/^nothing to undo/, '没有可以撤销的写入'],
[/^no such session/, '找不到这个会话'],
[/^no such permission request/, '这个许可请求已经结束'],
[/^no such question/, '这个问题已经结束'],
[/^unauthorized|^forbidden/i, '没有权限:请带上 token 打开页面'],
[/^out of memory/, '服务器内存不足'],
[/^path is required/, '需要填写路径'],
[/^empty body/, '内容为空'],
];
function errorText(status, msg) {
msg = String(msg || '');
for (const [re, zh] of ERR_ZH) if (re.test(msg)) return zh;
if (status === 401 && !msg) return '没有权限:请带上 token 打开页面';
return msg || ('HTTP ' + status);
}
const errMsg = (e) => (e && e.message) || String(e);
let toastTimer = null;
function toast(msg, kind) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = String(msg);
t.className = 'toast ' + (kind || 'err');
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, kind === 'ok' ? 2500 : 6000);
}
const toastErr = (e) => toast(errMsg(e), 'err');
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 : '') +
(s.key_set === false ? ' · 还没有 API key' : '');
$('chip-model').classList.toggle('warn', s.key_set === false);
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', undefined, 'DELETE').catch(toastErr);
chip.appendChild(x);
p.appendChild(chip);
}
if (s.sees_images === 0)
p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
}
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(toastErr);
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(toastErr);
});
/* 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(toastErr);
}).catch(toastErr);
};
}
/* --- 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 = ev.timeout || state.perm_timeout || 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) {
toast('回答失败:' + errMsg(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(toastErr);
}
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(toastErr);
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 (e) { toastErr(e); 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; }
try {
await post('api/sessions/switch', { id: s.id });
await loadSession();
await loadSessions();
closeDrawers();
} catch (e) { toastErr(e); }
};
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 () => {
try {
await post('api/sessions/switch', { id: s.id });
closeModal();
await loadSession();
await loadSessions();
} catch (e) { toastErr(e); }
},
}));
box.appendChild(row);
}
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', 'ok', '新建会话'), {
onclick: async () => {
try {
await post('api/sessions/new', {});
closeModal();
await loadSession();
await loadSessions();
} catch (e) { toastErr(e); }
},
}));
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
/* --- pickers as popovers ------------------------------------------------- */
/* "1M" / "200K" for a context window in tokens */
function fmtCtx(n) {
if (!n) return '';
if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + 'M';
return Math.round(n / 1000) + 'K';
}
function modelDesc(m) {
const parts = [];
if (m.context) parts.push(fmtCtx(m.context) + ' 上下文');
if (m.vision === true) parts.push('识图');
else if (m.vision === false) parts.push('纯文本');
if (m.reasoning) parts.push('推理强度');
if (m.price) parts.push((m.price.currency || '') + m.price.miss + '/' + m.price.out + ' 每百万');
parts.push(m.api ? '提供商列出' : '内置');
return parts.join(' · ');
}
let modelsCache = null; /* {provider, data} of the last GET api/models */
async function fetchModels(refresh) {
const r = await api('api/models' + (refresh ? '?refresh=1' : ''));
let d = null;
try { d = await readReply(r); } catch (e) {
/* 401 = the key was rejected: the body still has the built-in list */
if (e.status !== 401) throw e;
d = e.data || {};
}
modelsCache = { provider: d.provider, data: d };
return d;
}
function setModel(id) {
post('api/config', { model: id })
.then(() => { toast('模型 → ' + id, 'ok'); })
.catch(toastErr);
}
function modelPopover(anchor) {
openPop(anchor, (pop) => {
const head = el('div', 'pop-title pop-head');
head.appendChild(el('span', null, '模型 · ' + (state.provider || '-')));
const rb = el('button', 'icon tiny');
rb.title = '从提供商重新拉取模型列表';
rb.appendChild(icon('refresh'));
head.appendChild(rb);
pop.appendChild(head);
const row = el('div', 'pop-input');
const input = el('input');
input.placeholder = '搜索,或直接输入模型名';
input.spellcheck = false;
row.appendChild(input);
pop.appendChild(row);
const note = el('div', 'pop-note');
pop.appendChild(note);
const list = el('div', 'pop-list');
pop.appendChild(list);
let data = (modelsCache && modelsCache.provider === state.provider) ? modelsCache.data : null;
let loading = false;
const pick = (id) => { closePop(); if (id && id !== state.model) setModel(id); };
const render = () => {
list.textContent = '';
const q = input.value.trim();
const ql = q.toLowerCase();
const ms = (data && data.models) || [];
const shown = ms.filter((m) => !ql || m.id.toLowerCase().includes(ql)).slice(0, 300);
for (const m of shown)
list.appendChild(popRow({ title: m.id, desc: modelDesc(m), on: m.id === state.model, onClick: () => pick(m.id) }));
if (q && !ms.some((m) => m.id === q))
list.appendChild(popRow({ icon: 'plus', title: '使用「' + q + '」', desc: '不在列表里也可以直接用', onClick: () => pick(q) }));
if (!list.children.length)
list.appendChild(el('div', 'pop-empty', loading ? '加载中…' : '没有匹配的模型'));
};
const setNote = () => {
note.textContent = '';
note.className = 'pop-note';
if (loading) { note.textContent = '正在向 ' + (state.provider || '提供商') + ' 拉取模型列表…'; return; }
if (!data) return;
if (data.auth) {
note.classList.add('bad');
note.appendChild(el('span', null, data.error || 'API key 被拒绝'));
const fix = el('button', 'link', '更换 key');
fix.onclick = (e) => { e.stopPropagation(); closePop(); changeKey(); };
note.appendChild(fix);
} else if (data.api_error) {
note.classList.add('warn');
note.textContent = data.api_error + ';下面是内置列表';
} else if (!data.key_set) {
note.classList.add('warn');
note.textContent = '还没有 API key,只能显示内置列表';
} else if (data.api_count >= 0) {
note.textContent = data.provider + ' 列出 ' + data.api_count + ' 个模型';
}
};
const load = (refresh) => {
loading = true; setNote(); render();
fetchModels(refresh)
.then((d) => { data = d; })
.catch((e) => { data = data || { models: [] }; toastErr(e); })
.finally(() => { loading = false; setNote(); render(); });
};
rb.onclick = (e) => { e.stopPropagation(); load(true); };
input.addEventListener('input', render);
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const q = input.value.trim();
const ms = (data && data.models) || [];
const hit = ms.filter((m) => m.id.toLowerCase().includes(q.toLowerCase()));
pick(hit.length === 1 ? hit[0].id : q);
});
if (data) { setNote(); render(); } else load(false);
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(el('div', 'pop-title', '推理强度'));
if (state.effort_applies === false)
pop.appendChild(el('div', 'pop-note warn', '当前模型不接受推理强度参数:可以先选好,换到支持的模型时生效'));
const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' };
for (const e2 of EFFORTS) {
const r = popRow({
icon: LV[e2.key] || 'lv1',
title: e2.title,
desc: e2.desc,
on: e2.key === state.effort,
onClick: () => post('api/config', { effort: e2.key })
.then((d) => { if (d && d.warning) toast(d.warning, 'warn'); })
.catch(toastErr),
});
if (state.effort_applies === false) r.classList.add('dim');
pop.appendChild(r);
}
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({
icon: 'cpu',
title: '提供商:' + (state.provider || '-'),
desc: (state.api_base || '') + ' · 点这里切换',
onClick: showProviders,
}));
setTimeout(() => input.focus(), 0);
});
}
/* --- providers and keys -------------------------------------------------- */
function keyFromText(p) {
if (!p.has_key) return '未设置 key';
if (p.key_from === 'saved') return 'key 已保存';
if (p.key_from === 'session') return 'key 仅本次运行';
return 'key 来自环境变量 ' + p.key_from;
}
/* one password field; submit(key) returns a promise, a rejection stays in
* the dialog so the user can paste another key */
function keyDialog(title, sub, submit) {
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, title));
if (sub) box.appendChild(el('div', 'muted tiny', sub));
box.appendChild(el('div', 'muted tiny', 'key 会先用 /models 验证,再保存到配置目录的 credentials 文件(仅你可读),不会出现在页面、日志或事件里。'));
const input = el('input', 'key-input');
input.type = 'password';
input.autocomplete = 'off';
input.placeholder = 'sk-…';
box.appendChild(input);
const err = el('div', 'key-err');
box.appendChild(err);
const row = el('div', 'buttons');
const ok = el('button', 'ok', '验证并保存');
const go = async () => {
const k = input.value.trim();
if (!k) { err.textContent = '请先粘贴 key'; return; }
ok.disabled = true;
err.textContent = '验证中…';
try {
await submit(k);
input.value = '';
closeModal();
} catch (e) {
err.textContent = errMsg(e);
ok.disabled = false;
input.select();
}
};
ok.onclick = go;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
row.appendChild(ok);
row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
setTimeout(() => input.focus(), 0);
}
function afterSwitch(r) {
modelsCache = null;
toast('已切换到 ' + r.provider + (r.model ? ' · ' + r.model : '(请选一个模型)'), 'ok');
if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200);
}
function askKeyFor(p) {
keyDialog('API key · ' + p.name, p.api_base, async (key) => {
afterSwitch(await post('api/provider', { name: p.name, key }));
});
}
async function switchProvider(p) {
if (!p.has_key) return askKeyFor(p);
try {
closeModal();
afterSwitch(await post('api/provider', { name: p.name }));
} catch (e) {
if (e.data && e.data.need_key) return askKeyFor(p);
toastErr(e);
}
}
async function showProviders() {
let d;
try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); }
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '切换提供商'));
for (const p of d.providers || []) {
const row = el('div', 'prov' + (p.current ? ' on' : ''));
const m = el('div', 'pm');
m.appendChild(el('b', null, p.name + (p.current ? ' · 当前' : '')));
m.appendChild(el('span', 'muted tiny', p.api_base));
m.appendChild(el('span', 'tiny ' + (p.has_key ? 'ok-text' : 'warn-text'),
keyFromText(p) + ' · ' + (p.model || '模型从 API 获取') + (p.source === 'user' ? ' · models.json' : '')));
row.appendChild(m);
const acts = el('div', 'prov-acts');
if (p.current)
acts.appendChild(Object.assign(el('button', null, '换 key'), { onclick: changeKey }));
else
acts.appendChild(Object.assign(el('button', 'ok', p.has_key ? '切换' : '设 key 并切换'), {
onclick: () => switchProvider(p),
}));
row.appendChild(acts);
box.appendChild(row);
}
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
function changeKey() {
keyDialog('更换 API key · ' + (state.provider || ''), state.api_base || '', async (key) => {
const r = await post('api/key', { key });
modelsCache = null;
toast('key 已验证并保存', 'ok');
if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200);
});
}
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(toastErr),
}));
}, { 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(toastErr),
}));
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) {
toastErr(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 || '-');
const mi = state.model_info || {};
kv('上下文占用', num(state.context_tokens) + (mi.context ? ' / ' + fmtCtx(mi.context) : '') + ' 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 ? '未定价' : '-'));
const t = state.turn_usage || {};
if (t.requests)
kv('上一轮', t.requests + ' 往返 · ' + num(t.prompt_tokens) + ' / ' + num(t.completion_tokens) +
((t.priced && t.cost) ? ' · ' + (t.currency || '') + t.cost.toFixed(4) : ''));
kv('工具协议', state.protocol_tools || '-');
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', {}).then(() => toast('服务已退出', 'ok')).catch(toastErr) }));
}, { 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);
kv('端点', state.api_base);
kv('API key', keyFromText({ has_key: state.key_set, key_from: state.key_from }));
kv('模型', state.model);
kv('工具协议', state.protocol_tools);
kv('工具集', state.tool_profile);
kv('每轮最多步数', String(state.max_steps || '-'));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) }));
pop.appendChild(popRow({ icon: 'refresh', title: '切换提供商', desc: '换端点,需要时输入 key', onClick: showProviders }));
pop.appendChild(popRow({ icon: 'shield', title: '更换 API key', desc: '验证后保存,只对当前提供商', onClick: changeKey }));
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 = '就绪'; toast('压缩失败:' + errMsg(e)); }
}
async function undo() {
try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; }
catch (e) { toast('撤销失败:' + errMsg(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 = errMsg(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,
});
try { await readReply(r); } catch (e) { toast('上传失败:' + errMsg(e)); return; }
if (state.sees_images === 0) toast((state.model || '当前模型') + ' 不识图,这张图不会发给模型', 'warn');
}
/* --- 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(toastErr);
$('btn-new').onclick = async () => {
try { await post('api/sessions/new', {}); } catch (e) { toastErr(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 () => {
try {
await post('api/config', { plan_mode: b.dataset.mode === 'plan' });
$('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写';
} catch (e) { toastErr(e); }
};
}
$('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(toastErr);
loadSessions();
getJSON('api/state').then(applyState).catch(toastErr);
/* 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);