v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests
A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on a worker thread, the hooks publish events into a ring buffer that SSE clients read, so the engine itself needs no changes. - src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect - src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming, body caps (413), Expect: 100-continue, connection cap, exact routing - src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML (tag/attribute allowlist, javascript: URLs and off-site images dropped) - src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes, token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no client is attached - src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI - web/: no-build front end (transcript, approvals, todo, sessions, image upload, status bar) - docs/webui.md: engine API v1 - events, requests, security model - tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run driven by a recorded SSE stream (no API key needed) - src/main.c: --web --web-port --web-host --web-token --web-dev --web-open and CST_WEB_PORT/HOST/TOKEN/ROOT An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI.
This commit is contained in:
+433
@@ -0,0 +1,433 @@
|
||||
/* 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(() => {});
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CST-Agent</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="bar">
|
||||
<span class="tag" id="planmode">Build</span>
|
||||
<span class="tag" id="approval">auto</span>
|
||||
<span class="ws" id="ws" title="workspace"></span>
|
||||
<span class="model" id="model" title="model">-</span>
|
||||
<span class="muted" id="ctx"></span>
|
||||
<span class="grow"></span>
|
||||
<button id="btn-sessions" title="sessions">会话</button>
|
||||
<button id="btn-model" title="model">模型</button>
|
||||
<button id="btn-undo" title="undo">撤销</button>
|
||||
<button id="btn-compact" title="compact">压缩</button>
|
||||
<button id="btn-clear" title="clear view">清屏</button>
|
||||
</header>
|
||||
|
||||
<main id="log"></main>
|
||||
<aside id="todo" hidden></aside>
|
||||
|
||||
<footer id="composer">
|
||||
<div id="pending" hidden></div>
|
||||
<div class="row">
|
||||
<textarea id="input" rows="1" placeholder="输入消息,Enter 发送,Shift+Enter 换行"></textarea>
|
||||
<button id="btn-send">发送</button>
|
||||
<button id="btn-stop" hidden>停止</button>
|
||||
<label class="att" title="附上图片"><input type="file" id="file" accept="image/*" hidden>+图</label>
|
||||
</div>
|
||||
<div class="hint"><span id="status">就绪</span><span class="grow"></span><span id="usage" class="muted"></span></div>
|
||||
</footer>
|
||||
|
||||
<div id="modal" hidden><div id="modal-box"></div></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--bg2: #1b1e24;
|
||||
--fg: #d8dde5;
|
||||
--muted: #8b93a1;
|
||||
--line: #2a2f38;
|
||||
--accent: #7fb2ff;
|
||||
--ok: #7ddc8f;
|
||||
--warn: #ffcc66;
|
||||
--err: #ff7b72;
|
||||
--tool: #b5e3c8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--fg);
|
||||
font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, "Noto Sans Mono CJK SC", monospace;
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
}
|
||||
button {
|
||||
background: var(--bg2); color: var(--fg); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 3px 9px; font: inherit; cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
.grow { flex: 1; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
#bar {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--line); background: var(--bg2);
|
||||
position: sticky; top: 0; z-index: 3; flex-wrap: wrap;
|
||||
}
|
||||
.tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; color: var(--muted); }
|
||||
.tag.on { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
||||
.ws { color: var(--muted); overflow: hidden; text-overflow: ellipsis; max-width: 40vw; white-space: nowrap; }
|
||||
.model { color: var(--accent); }
|
||||
|
||||
#log { flex: 1; overflow: auto; padding: 12px 14px 20px; }
|
||||
#log .msg { margin: 0 0 14px; }
|
||||
#log .who { color: var(--muted); font-size: 12px; margin-bottom: 2px; }
|
||||
#log .body { white-space: pre-wrap; word-break: break-word; }
|
||||
#log .assistant .body > :first-child { margin-top: 0; }
|
||||
#log .body pre {
|
||||
background: var(--bg2); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 8px 10px; overflow: auto;
|
||||
}
|
||||
#log .body code { background: var(--bg2); border-radius: 4px; padding: 0 3px; }
|
||||
#log .body pre code { background: none; padding: 0; }
|
||||
#log .body table { border-collapse: collapse; }
|
||||
#log .body th, #log .body td { border: 1px solid var(--line); padding: 2px 6px; }
|
||||
#log .body blockquote { border-left: 3px solid var(--line); margin: 0; padding-left: 10px; color: var(--muted); }
|
||||
#log .body a { color: var(--accent); }
|
||||
#log .body img { max-width: min(420px, 100%); border-radius: 6px; border: 1px solid var(--line); }
|
||||
|
||||
details.think { color: var(--muted); margin: 4px 0; }
|
||||
details.think summary { cursor: pointer; }
|
||||
details.tool {
|
||||
border: 1px solid var(--line); border-radius: 6px; margin: 6px 0; background: var(--bg2);
|
||||
}
|
||||
details.tool > summary { cursor: pointer; padding: 4px 8px; color: var(--tool); }
|
||||
details.tool .args, details.tool pre { margin: 0; padding: 6px 10px; white-space: pre-wrap; }
|
||||
details.tool pre { border-top: 1px solid var(--line); max-height: 40vh; overflow: auto; }
|
||||
.tool .exit-bad { color: var(--err); }
|
||||
.spin::after { content: " …"; color: var(--warn); }
|
||||
|
||||
#todo {
|
||||
border-top: 1px solid var(--line); background: var(--bg2); padding: 6px 12px;
|
||||
max-height: 22vh; overflow: auto; white-space: pre-wrap; font-size: 13px;
|
||||
}
|
||||
#todo h4 { margin: 0 0 4px; color: var(--muted); font-size: 12px; font-weight: normal; }
|
||||
|
||||
#composer { border-top: 1px solid var(--line); background: var(--bg2); padding: 8px 10px; }
|
||||
#composer .row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
#input {
|
||||
flex: 1; resize: vertical; min-height: 34px; max-height: 40vh;
|
||||
background: var(--bg); color: var(--fg); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 7px 9px; font: inherit;
|
||||
}
|
||||
.att {
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px; cursor: pointer;
|
||||
}
|
||||
.hint { display: flex; gap: 10px; font-size: 12px; color: var(--muted); padding-top: 4px; }
|
||||
#pending { color: var(--warn); font-size: 12px; padding-bottom: 4px; }
|
||||
|
||||
#modal {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, .55);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 9;
|
||||
}
|
||||
#modal-box {
|
||||
background: var(--bg2); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px; width: min(820px, 94vw); max-height: 88vh; overflow: auto;
|
||||
}
|
||||
#modal-box h3 { margin: 0 0 8px; }
|
||||
#modal-box pre {
|
||||
background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 8px; max-height: 46vh; overflow: auto; white-space: pre-wrap;
|
||||
}
|
||||
#modal-box .buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
#modal-box .danger { border-color: var(--err); color: var(--err); }
|
||||
#modal-box .ok { border-color: var(--ok); color: var(--ok); }
|
||||
.sess { display: flex; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
.sess .id { color: var(--muted); font-size: 12px; }
|
||||
.sess .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
Reference in New Issue
Block a user