/* CST-Agent WebUI client: HTTP + SSE against the engine in the same binary. * No build step, no dependencies: the server renders Markdown, this file * only moves messages around and draws them. */ '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'); const h = location.hash.replace(/^#/, ''); const hp = new URLSearchParams(h); return hp.get('token') || ''; })(); 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 log = $('log'); let state = {}; let live = null; /* {body, html, think} for the streaming assistant block */ let tools = new Map();/* tool call id -> element */ let permReq = null; /* pending permission event */ let askReq = null; /* pending question */ let firstPaint = true; /* --- transcript ---------------------------------------------------------- */ function atBottom() { return log.scrollTop + log.clientHeight >= log.scrollHeight - 40; } function scrollDown(force) { if (force || atBottom()) log.scrollTop = log.scrollHeight; } function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; } function bubble(who, cls) { const m = el('div', 'msg ' + (cls || '')); m.appendChild(el('div', 'who', who)); const b = el('div', 'body'); m.appendChild(b); log.appendChild(m); scrollDown(true); return { root: m, body: b }; } function renderMessage(m) { if (m.role === 'system') return; if (m.role === 'tool') return; /* drawn with its call */ const cls = m.role === 'user' ? 'user' : 'assistant'; const who = m.role === 'user' ? 'You' : 'Assistant'; const b = bubble(who, cls); if (m.role === 'assistant' && m.html) b.body.innerHTML = m.html; else b.body.textContent = m.content || ''; if (m.reasoning) { const d = el('details', 'think'); d.appendChild(el('summary', null, 'Thought')); d.appendChild(el('div', null, m.reasoning)); b.root.insertBefore(d, b.body); } if (m.attach) { for (const a of m.attach) { const img = el('img'); img.alt = a.name || 'image'; img.src = 'api/attach/' + a.rel + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : ''); log.insertBefore(img, null); } } } async function loadSession() { const data = await getJSON('api/session'); log.textContent = ''; firstPaint = true; for (const m of data.messages) renderMessage(m); firstPaint = false; scrollDown(true); } /* --- live blocks --------------------------------------------------------- */ function ensureLive() { if (live) return live; const b = bubble('Assistant', 'assistant'); live = { root: b.root, body: b.body, html: '', think: null }; return live; } function thinkBlock(text) { const l = ensureLive(); if (!l.think) { const d = el('details', 'think'); d.appendChild(el('summary', null, 'Thought')); const t = el('div'); d.appendChild(t); l.root.insertBefore(d, l.body); l.think = t; } l.think.textContent += text; return l; } function toolCard(ev) { let c = tools.get(ev.id); if (!c) { const d = el('details', 'tool'); const s = el('summary', null, ev.name || 'tool'); d.appendChild(s); const args = el('div', 'args', JSON.stringify(ev.args || {}, null, 0)); d.appendChild(args); const out = el('pre'); d.appendChild(out); log.appendChild(d); c = { root: d, sum: s, out, open: false }; tools.set(ev.id, c); } return c; } function onTool(ev) { const c = toolCard(ev); if (ev.phase === 'start' || ev.phase === 'begin') { c.sum.classList.add('spin'); c.open = true; c.root.open = true; } else if (ev.phase === 'end' || ev.phase === 'done') { c.sum.classList.remove('spin'); } if (ev.detail && !c.out.textContent) c.out.textContent = ev.detail; scrollDown(false); } function onToolResult(ev) { const c = toolCard(ev); c.sum.classList.remove('spin'); c.out.textContent = (ev.out || '') + (ev.truncated ? '\n… (截断)' : ''); if (ev.exit) { c.sum.appendChild(el('span', ev.exit > 0 ? 'exit-bad' : '', ' exit=' + ev.exit)); } scrollDown(false); } /* --- state / status ------------------------------------------------------ */ function applyState(s) { state = s; $('ws').textContent = s.workspace || ''; $('ws').title = s.workspace || ''; $('model').textContent = (s.provider ? s.provider + ':' : '') + (s.model || '-'); const pm = $('planmode'); pm.textContent = s.plan_mode ? 'Plan' : 'Build'; pm.classList.toggle('on', !!s.plan_mode); $('approval').textContent = s.approval || 'auto'; $('ctx').textContent = s.context_tokens ? (s.context_tokens + ' tok') : ''; const u = s.usage || {}; $('usage').textContent = u.requests ? `${u.requests} req · ${u.prompt_tokens}/${u.completion_tokens} tok` + (u.priced && u.cost ? ` · ${u.currency}${u.cost.toFixed(4)}` : '') : ''; const busy = !!s.status_busy; $('btn-stop').hidden = !busy; $('btn-send').hidden = busy; $('status').textContent = busy ? '运行中…' : '就绪'; const p = $('pending'); p.hidden = !(s.pending_images && s.pending_images.length); p.textContent = (s.pending_images || []).join(' '); const t = $('todo'); t.hidden = !s.todo; if (s.todo) { t.textContent = ''; t.appendChild(el('h4', null, '任务')); t.appendChild(el('div', null, s.todo)); } } /* --- SSE ----------------------------------------------------------------- */ function connect() { const url = 'api/events' + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : ''); const es = new EventSource(url); es.addEventListener('state', (e) => applyState(JSON.parse(e.data))); es.addEventListener('status', (e) => { $('status').textContent = JSON.parse(e.data).message; }); es.addEventListener('assistant_html', (e) => { const l = ensureLive(); l.html += JSON.parse(e.data).text; l.body.innerHTML = l.html; scrollDown(false); }); es.addEventListener('assistant_delta', () => {}); es.addEventListener('think_delta', (e) => { thinkBlock(JSON.parse(e.data).text); scrollDown(false); }); es.addEventListener('tool', (e) => onTool(JSON.parse(e.data))); es.addEventListener('tool_result', (e) => onToolResult(JSON.parse(e.data))); es.addEventListener('todo', (e) => applyState(Object.assign({}, state, { todo: JSON.parse(e.data).text }))); es.addEventListener('session', () => loadSession()); es.addEventListener('permission', (e) => showPermission(JSON.parse(e.data))); es.addEventListener('permission_done', () => closeModal()); es.addEventListener('ask_user', (e) => showAsk(JSON.parse(e.data))); es.addEventListener('turn_end', (e) => { const d = JSON.parse(e.data); live = null; if (d.rc) bubble('Error', 'assistant').body.textContent = d.error || ('rc=' + d.rc); else loadSession(); /* replace the live view with the stored messages */ }); es.onerror = () => { $('status').textContent = '连接中断,重连中…'; }; } /* --- modals -------------------------------------------------------------- */ function openModal(node) { const box = $('modal-box'); box.textContent = ''; box.appendChild(node); $('modal').hidden = false; } function closeModal() { $('modal').hidden = true; $('modal-box').textContent = ''; permReq = askReq = null; } window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('modal').hidden) closeModal(); }); function showPermission(ev) { permReq = ev; const box = document.createDocumentFragment(); box.appendChild(el('h3', null, (ev.sandbox ? '沙箱拦截:要在沙箱外重跑?' : '允许 ' + ev.tool + '?'))); if (ev.preview) { const pre = el('pre'); pre.textContent = ev.preview; box.appendChild(pre); } else if (ev.detail) { const pre = el('pre'); pre.textContent = ev.detail; box.appendChild(pre); } const row = el('div', 'buttons'); const answer = (n) => { post('api/permission', { id: ev.id, answer: n }).catch(() => {}); closeModal(); }; row.appendChild(Object.assign(el('button', 'danger', '拒绝'), { onclick: () => answer(0) })); row.appendChild(Object.assign(el('button', 'ok', '允许一次'), { onclick: () => answer(1) })); row.appendChild(Object.assign(el('button', null, '总是允许(工具)'), { onclick: () => answer(2) })); if (ev.tool === 'run_shell') row.appendChild(Object.assign(el('button', null, '总是允许(该前缀)'), { onclick: () => answer(3) })); box.appendChild(row); openModal(box); } function showAsk(ev) { askReq = ev; const box = document.createDocumentFragment(); box.appendChild(el('h3', null, 'Agent 提问')); const pre = el('pre'); pre.textContent = ev.question || ''; box.appendChild(pre); const row = el('div', 'buttons'); const send = (text, dismiss) => { post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(() => {}); closeModal(); }; for (const opt of ev.options || []) { row.appendChild(Object.assign(el('button', null, opt), { onclick: () => send(opt, false) })); } const free = el('input'); free.placeholder = '自定义回答'; free.style.cssText = 'flex:1;min-width:220px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit'; 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); } async function showSessions() { const data = await getJSON('api/sessions'); const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '会话')); for (const s of data.sessions) { const row = el('div', 'sess'); row.appendChild(el('span', 'id', (s.id === data.current ? '* ' : ' ') + s.id.slice(0, 12))); row.appendChild(el('span', 't', s.title || '(空)')); row.appendChild(el('span', 'id', s.messages + ' msgs')); row.appendChild(Object.assign(el('button', null, '打开'), { onclick: async () => { await post('api/sessions/switch', { id: s.id }); closeModal(); await loadSession(); }, })); 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(); }, })); row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row); openModal(box); } function showModel() { const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '模型与模式')); const mk = (label, value, apply) => { const row = el('div', 'buttons'); row.appendChild(el('span', 'muted', label)); const input = el('input'); input.value = value || ''; input.style.cssText = 'flex:1;min-width:200px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit'; row.appendChild(input); row.appendChild(Object.assign(el('button', 'ok', '应用'), { onclick: async () => { await post('api/config', apply(input.value)); }, })); box.appendChild(row); }; mk('模型', state.model, (v) => ({ model: v })); mk('provider', state.provider, (v) => ({ provider: v })); const row = el('div', 'buttons'); row.appendChild(el('span', 'muted', '推理强度')); for (const level of ['low', 'medium', 'high', 'max']) { row.appendChild(Object.assign(el('button', level === state.effort ? 'ok' : '', level), { onclick: () => post('api/config', { effort: level }), })); } box.appendChild(row); const row2 = el('div', 'buttons'); row2.appendChild(el('span', 'muted', '审批')); for (const mode of ['ask', 'auto', 'yolo']) { row2.appendChild(Object.assign(el('button', mode === state.approval ? 'ok' : '', mode), { onclick: () => post('api/config', { approval: mode }), })); } row2.appendChild(Object.assign(el('button', state.plan_mode ? 'ok' : '', state.plan_mode ? 'Plan' : 'Build'), { onclick: () => post('api/config', { plan_mode: !state.plan_mode }), })); box.appendChild(row2); const row3 = el('div', 'buttons'); row3.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row3); openModal(box); } /* --- composer ------------------------------------------------------------ */ async function send() { const input = $('input'); const text = input.value.trim(); if (!text || state.status_busy) return; input.value = ''; bubble('You', 'user').body.textContent = text; try { await post('api/turn', { text }); } catch (e) { bubble('Error', 'assistant').body.textContent = String(e.message || e); } } $('btn-send').onclick = send; $('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {}); $('btn-sessions').onclick = () => showSessions().catch((e) => alert(e.message)); $('btn-model').onclick = showModel; $('btn-undo').onclick = () => post('api/undo', {}).catch((e) => alert(e.message)); $('btn-compact').onclick = () => post('api/compact', {}).catch((e) => alert(e.message)); $('btn-clear').onclick = () => { log.textContent = ''; }; $('input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } }); $('input').addEventListener('input', (e) => { e.target.style.height = 'auto'; e.target.style.height = Math.min(e.target.scrollHeight, 240) + 'px'; }); async function upload(file) { const r = await api('api/attach?name=' + encodeURIComponent(file.name), { method: 'POST', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, }); if (!r.ok) alert(await r.text()); } $('file').addEventListener('change', async (e) => { for (const f of e.target.files) await upload(f); e.target.value = ''; }); document.addEventListener('paste', async (e) => { for (const item of e.clipboardData.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); }); /* --- go ------------------------------------------------------------------ */ connect(); loadSession().catch(() => {}); getJSON('api/state').then(applyState).catch(() => {});