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