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