Files
Hermit/web/app.js
T
WHC 2d1052fd5f
ci / linux (clang) (push) Successful in 5m56s
ci / linux (gcc) (push) Successful in 8m38s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 9m21s
v0.8.3: WebUI models, providers and keys; garbled thinking fixed
Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

src/provider_switch.c: model list fetch/cache, key verify-and-save and
provider switching moved out of tui.c and shared by TUI and WebUI.

WebUI API:
- GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected
  key, still listing built-ins), POST /api/provider, POST /api/key
  (key checked against /v1/models, saved to credentials 0600, never
  echoed in replies, events or logs)
- /api/config validates everything before applying, persists model,
  effort and approval (not yolo), warns when effort does not apply;
  plan_mode no longer forces read-only
- state: turn_usage, api_base, key_set/key_from, model_info,
  effort_applies, sees_images, max_steps, tool_profile, perm_timeout;
  permission events carry their timeout

Frontend: searchable model picker with refresh and manual entry,
provider picker and key dialog, effort/vision hints, toasts instead of
silent failures, Chinese error text, real protocol in the settings and
usage panels, approval countdown from the server's timeout.

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
2026-10-09 15:54:53 +08:00

1355 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
}
/* the server answers errors as {"error": "..."}; status and body ride along
* so a caller can tell "needs a key" from "rejected key" */
class ApiError extends Error {
constructor(msg, status, data) { super(msg); this.status = status; this.data = data || {}; }
}
async function readReply(r) {
const text = await r.text();
let data = null;
try { data = text ? JSON.parse(text) : {}; } catch { data = null; }
if (!r.ok) throw new ApiError(errorText(r.status, (data && data.error) || text || r.statusText), r.status, data);
return data === null ? text : data;
}
async function getJSON(path) {
return readReply(await api(path));
}
async function post(path, body, method) {
const r = await api(path, {
method: method || 'POST',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return readReply(r);
}
/* engine messages that are still English, in the words the page uses */
const ERR_ZH = [
[/^busy: wait for the running turn/, '正在运行,等这一轮结束再试'],
[/^a turn is already running/, '已经有一轮在运行'],
[/^another change is running/, '另一项修改正在进行,稍后再试'],
[/^nothing to undo/, '没有可以撤销的写入'],
[/^no such session/, '找不到这个会话'],
[/^no such permission request/, '这个许可请求已经结束'],
[/^no such question/, '这个问题已经结束'],
[/^unauthorized|^forbidden/i, '没有权限:请带上 token 打开页面'],
[/^out of memory/, '服务器内存不足'],
[/^path is required/, '需要填写路径'],
[/^empty body/, '内容为空'],
];
function errorText(status, msg) {
msg = String(msg || '');
for (const [re, zh] of ERR_ZH) if (re.test(msg)) return zh;
if (status === 401 && !msg) return '没有权限:请带上 token 打开页面';
return msg || ('HTTP ' + status);
}
const errMsg = (e) => (e && e.message) || String(e);
let toastTimer = null;
function toast(msg, kind) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = String(msg);
t.className = 'toast ' + (kind || 'err');
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, kind === 'ok' ? 2500 : 6000);
}
const toastErr = (e) => toast(errMsg(e), 'err');
const $ = (id) => document.getElementById(id);
const el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
};
const attachURL = (rel) => 'api/attach/' + rel + qs();
const fmtSecs = (ms) => {
const s = ms / 1000;
if (s < 60) return s.toFixed(1) + ' 秒';
const m = Math.floor(s / 60);
return m + ' 分 ' + Math.round(s - m * 60) + ' 秒';
};
const num = (n) => (n || 0).toLocaleString();
const log = $('log');
const scroll = $('scroll');
let state = {};
let live = null; /* streaming assistant block */
let steps = null; /* this turn's tool list */
let stepIndex = new Map(); /* tool call id -> step row */
let files = new Map(); /* this turn's file changes: path -> {add, del} */
let permReq = null;
let askReq = null;
let busy = false;
let turnStart = 0;
/* --- anchored popovers ---------------------------------------------------
* The pattern the reference apps use: a small sheet under the control that
* owns the setting, each row = icon + title + one line, a check on the
* current value. Shift+Tab and the arrow keys work, Escape closes. */
let popAnchor = null;
let popOpenedAt = 0;
/* icons all come from the sprite in index.html: one 24px grid, one stroke
* weight, round caps. A mixed bag of unicode glyphs never reads as a family. */
const SVGNS = 'http://www.w3.org/2000/svg';
function icon(name, cls) {
/* an unknown name is just an old text glyph: keep it, do not blank it out */
if (!document.getElementById('i-' + name)) return el('span', cls || '', name);
const s = document.createElementNS(SVGNS, 'svg');
s.setAttribute('class', 'i' + (cls ? ' ' + cls : ''));
const u = document.createElementNS(SVGNS, 'use');
u.setAttribute('href', '#i-' + name);
s.appendChild(u);
return s;
}
function setIcon(btn, name) {
const old = btn.querySelector('svg.i');
if (old) old.replaceWith(icon(name));
}
function closePop() {
const p = $('pop');
p.hidden = true;
p.textContent = '';
popAnchor = null;
}
function popRow(o) {
const b = el('button', 'pop-item' + (o.on ? ' on' : '') + (o.danger ? ' danger' : ''));
b.appendChild(o.icon ? icon(o.icon, 'pi') : el('span', 'pi'));
const m = el('span', 'pm');
m.appendChild(el('span', 'pt', o.title));
if (o.desc) m.appendChild(el('span', 'pd', o.desc));
b.appendChild(m);
if (o.on) b.appendChild(el('span', 'pk', '✓'));
b.onclick = () => { const fn = o.onClick; closePop(); if (fn) fn(); };
return b;
}
function positionPop(pop, anchor, align) {
const a = anchor.getBoundingClientRect();
pop.style.visibility = 'hidden';
pop.style.left = '0px';
pop.style.top = '0px';
const p = pop.getBoundingClientRect();
let left = align === 'right' ? a.right - p.width : a.left;
left = Math.max(8, Math.min(left, window.innerWidth - p.width - 8));
let top = a.bottom + 8;
let origin = 'top';
if (top + p.height > window.innerHeight - 8) {
top = Math.max(8, a.top - p.height - 8);
origin = 'bottom';
}
pop.style.left = left + 'px';
pop.style.top = top + 'px';
pop.style.transformOrigin = (align === 'right' ? '100%' : '0') + ' ' + (origin === 'bottom' ? '100%' : '0');
pop.style.visibility = 'visible';
}
function openPop(anchor, build, opt) {
const pop = $('pop');
if (popAnchor === anchor) return closePop(); /* second tap closes */
closePop();
pop.textContent = '';
build(pop, closePop);
pop.hidden = false;
positionPop(pop, anchor, (opt && opt.align) || 'left');
popAnchor = anchor;
popOpenedAt = Date.now();
}
document.addEventListener('click', (e) => {
if ($('pop').hidden) return;
/* the click lands on a child of the anchor button, so compare containers */
if ($('pop').contains(e.target) || (popAnchor && popAnchor.contains(e.target))) return;
closePop();
});
/* a phone browser fires resize while the address bar settles right after the
* popover opened; that is not the user dismissing it */
window.addEventListener('resize', () => {
if (Date.now() - popOpenedAt > 700) closePop();
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !$('pop').hidden) closePop();
});
/* --- transcript ---------------------------------------------------------- */
function atBottom() {
return scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 60;
}
function scrollDown(force) {
if (force || atBottom()) scroll.scrollTop = scroll.scrollHeight;
updateJump();
}
function updateJump() {
$('to-bottom').hidden = atBottom();
}
function showEmpty(show) {
$('empty').classList.toggle('gone', !show);
}
function bubble(role, cls) {
const m = el('div', 'msg ' + (cls || role));
/* no avatar per message: the assistant reads as plain text in the column,
* the way the reference transcript does; only errors get a mark */
const g = el('div', 'gutter', role === 'error' ? '!' : '');
const b = el('div', 'body');
m.appendChild(g);
m.appendChild(b);
log.appendChild(m);
showEmpty(false);
scrollDown(true);
return { root: m, body: b };
}
function copyButton(text) {
const b = el('button', null, '复制');
b.onclick = async () => {
try {
await navigator.clipboard.writeText(text);
b.textContent = '已复制';
setTimeout(() => { b.textContent = '复制'; }, 1200);
} catch { b.textContent = '复制失败'; }
};
return b;
}
function attachStrip(atts) {
const strip = el('div', 'attach-strip');
for (const a of atts) {
const img = el('img');
img.alt = a.name || 'image';
img.loading = 'lazy';
img.src = attachURL(a.rel);
strip.appendChild(img);
}
return strip;
}
/* reasoning belongs inside .body: as a sibling it becomes a second flex item
* and squeezes the answer into a narrow column */
function thinkInto(body, text, open) {
let d = body.querySelector(':scope > details.think');
let t;
if (!d) {
d = el('details', 'think');
d.appendChild(el('summary', null, '思考过程'));
t = el('div');
d.appendChild(t);
body.insertBefore(d, body.firstChild);
} else {
t = d.querySelector('div');
}
if (open) d.open = true;
t.textContent += text;
return d;
}
function actionsFor(root, text) {
const row = el('div', 'msg-actions');
row.appendChild(copyButton(text));
root.appendChild(row);
return row;
}
function renderMessage(m) {
if (m.role === 'system' || m.role === 'tool') return;
const isUser = m.role === 'user';
const b = bubble(isUser ? 'user' : 'assistant');
if (!isUser && m.html) b.body.innerHTML = m.html;
else b.body.textContent = m.content || '';
if (m.reasoning) thinkInto(b.body, m.reasoning, false);
if (m.attach && m.attach.length) b.body.insertBefore(attachStrip(m.attach), b.body.firstChild);
if (!isUser) actionsFor(b.root, m.content || '');
}
async function loadSession() {
const data = await getJSON('api/session');
log.textContent = '';
stepIndex = new Map();
files = new Map();
renderFilesPanel();
steps = null;
live = null;
for (const m of data.messages) renderMessage(m);
showEmpty(log.children.length === 0);
const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user');
document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent';
scrollDown(true);
updateJump();
}
/* --- live blocks --------------------------------------------------------- */
function ensureLive() {
if (live) return live;
const b = bubble('assistant');
b.body.classList.add('live');
/* the answer gets its own box: rewriting body.innerHTML would drop the
* reasoning <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 : '') +
(s.key_set === false ? ' · 还没有 API key' : '');
$('chip-model').classList.toggle('warn', s.key_set === false);
const ap = $('chip-approval');
const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto'));
ap.querySelector('.lbl').textContent = (cur ? cur.title : s.approval || 'auto');
ap.classList.toggle('warn', s.approval === 'ask');
ap.title = '审批权限:' + (cur ? cur.title : s.approval);
for (const b of $('seg-mode').children)
b.classList.toggle('on', (b.dataset.mode === 'plan') === !!s.plan_mode);
$('foot-ws').textContent = s.workspace || 'workspace';
$('foot-session').textContent = (s.session || '-').slice(0, 20);
const u = s.usage || {};
const money = (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : '';
/* the panel carries two numbers only: what the context costs, and how much
* of it the provider already had cached */
const hit = Math.max(0, u.cached_tokens || 0);
const miss = Math.max(0, u.cache_miss_tokens || 0);
$('kv-ctx').textContent = num(s.context_tokens) + ' tok';
$('kv-cache').textContent = (hit + miss) ? Math.round((hit / (hit + miss)) * 100) + '%' : '-';
$('kv-cache').title = (hit + miss)
? num(hit) + ' 命中 / ' + num(miss) + ' 未命中' : '还没有缓存数据';
$('cost').textContent = money;
$('usage').textContent = u.requests ? `${u.requests} 往返 · ${num(u.prompt_tokens)}/${num(u.completion_tokens)} tok` : '';
$('btn-undo').disabled = !s.undo_available;
busy = !!s.status_busy;
$('btn-stop').hidden = !busy;
syncSend();
if (!busy && !permReq) $('status').textContent = '就绪';
const p = $('pending');
const n = (s.pending_images || []).length;
p.hidden = !n;
if (n) {
p.textContent = '';
for (const line of s.pending_images) {
const chip = el('span', 'chip');
chip.appendChild(el('span', null, line));
const x = el('button', 'icon tiny');
x.appendChild(icon('close'));
x.title = '清空待发图片';
x.onclick = () => post('api/attach', undefined, 'DELETE').catch(toastErr);
chip.appendChild(x);
p.appendChild(chip);
}
if (s.sees_images === 0)
p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
}
renderTodo(s.todo);
}
/* --- SSE ----------------------------------------------------------------- */
function connect() {
const es = new EventSource('api/events' + qs());
/* a turn can also start outside this page (the CLI, another client), so the
* clock starts on the first sign of activity, not only on send() */
const markBusy = () => { if (!turnStart) turnStart = Date.now(); };
const on = (name, fn) => es.addEventListener(name, (e) => {
try { fn(JSON.parse(e.data)); } catch (err) { console.error(name, err); }
});
on('state', applyState);
on('status', (d) => { markBusy(); if (d.message) $('status').textContent = d.message; });
on('assistant_html', (d) => {
markBusy();
const l = ensureLive();
l.html += d.text || '';
l.out.innerHTML = l.html;
scrollDown(false);
});
on('think_delta', (d) => {
markBusy();
thinkInto(ensureLive().body, d.text || '', true);
scrollDown(false);
});
on('tool', (ev) => { markBusy(); onTool(ev); });
on('tool_result', onToolResult);
on('todo', (d) => renderTodo(d.text));
on('session', (d) => {
closePop();
live = null; steps = null; stepIndex = new Map();
loadSession().then(loadSessions).catch(toastErr);
closeDrawers();
if (d && d.id) $('status').textContent = '已切换会话';
});
on('permission', showGate);
on('permission_done', hideGate);
on('ask_user', showAsk);
on('turn_end', (d) => {
if (turnStart) { turnMeta(Date.now() - turnStart); turnStart = 0; }
if (live) live.body.classList.remove('live');
live = null; steps = null; stepIndex = new Map();
if (d && d.rc) bubble('error').body.textContent = d.error || ('rc=' + d.rc);
loadSession().then(loadSessions).catch(toastErr);
});
/* the browser resumes from Last-Event-ID; a restarted server has none of
* those events, so its state (and maybe session) is fetched afresh */
let dropped = false;
es.onerror = () => { dropped = true; $('status').textContent = '连接中断,重连中…'; };
es.onopen = () => {
if (!dropped) return;
dropped = false;
if (!busy) $('status').textContent = '就绪';
const was = state.session;
getJSON('api/state').then((s) => {
applyState(s);
if (s.session !== was) loadSession().then(loadSessions).catch(toastErr);
}).catch(toastErr);
};
}
/* --- approval gate ------------------------------------------------------- */
const GATE_KEYS = [
{ v: 1, label: '允许一次', key: '1', cls: 'ok' },
{ v: 2, label: '总是允许这个工具', key: '2', cls: '' },
{ v: 3, label: '总是允许这个前缀', key: '3', cls: '' },
{ v: 0, label: '拒绝', key: '4', cls: 'danger' },
];
let gateTimer = null;
function showGate(ev) {
permReq = ev;
$('gate').hidden = false;
$('gate-title').textContent = ev.sandbox
? '沙箱拦下了这一步,要在沙箱外重跑吗?'
: '允许 ' + (ev.tool || '工具') + ' 运行吗?';
$('gate-detail').textContent = ev.preview || ev.detail || '';
const row = $('gate-buttons');
row.textContent = '';
for (const a of GATE_KEYS) {
const b = el('button', a.cls, a.label + ' (' + a.key + ')');
b.onclick = () => answerGate(a.v);
row.appendChild(b);
}
$('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。';
$('status').textContent = '等待你的许可…';
let left = ev.timeout || state.perm_timeout || 600;
clearInterval(gateTimer);
const tick = () => {
$('gate-timer').textContent = left >= 0 ? left + 's' : '';
if (left-- <= 0) clearInterval(gateTimer);
};
tick();
gateTimer = setInterval(tick, 1000);
scrollDown(true);
}
function hideGate() {
clearInterval(gateTimer);
gateTimer = null;
permReq = null;
$('gate').hidden = true;
$('gate-timer').textContent = '';
if (!busy) $('status').textContent = '就绪';
}
async function answerGate(value) {
if (!permReq) return;
const id = permReq.id;
hideGate();
try {
await post('api/permission', { id, answer: value });
$('status').textContent = value ? '已允许,继续执行…' : '已拒绝';
} catch (e) {
toast('回答失败:' + errMsg(e));
}
}
window.addEventListener('keydown', (e) => {
/* typing "3" or "y" into the box must not grant a permission */
const typing = e.isComposing || e.ctrlKey || e.metaKey || e.altKey ||
(e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)));
if (permReq && !$('gate').hidden && !typing) {
if (e.key === '1' || e.key.toLowerCase() === 'y') answerGate(1);
else if (e.key === '2') answerGate(2);
else if (e.key === '3') answerGate(3);
else if (e.key === '4' || e.key.toLowerCase() === 'n') answerGate(0);
}
if (e.key === 'Escape') {
if (!$('modal').hidden) return closeModal();
if (!$('pop').hidden) return closePop();
if (busy) return void post('api/cancel', {}).catch(toastErr);
}
if (e.ctrlKey && e.key.toLowerCase() === 'b') { e.preventDefault(); toggleSide(); }
if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); }
if (e.ctrlKey && e.key.toLowerCase() === 'k') { e.preventDefault(); showSessions(); }
});
/* --- modal sheet (session list) ------------------------------------------ */
function openModal(node) {
const box = $('modal-box');
box.textContent = '';
box.appendChild(node);
$('modal').hidden = false;
}
function closeModal() {
$('modal').hidden = true;
$('modal-box').textContent = '';
askReq = null;
}
function showAsk(ev) {
askReq = ev;
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, ev.question || '模型想确认一件事'));
if (ev.detail && ev.detail !== ev.question) box.appendChild(el('pre', 'step-out', ev.detail));
const send = (text, dismiss) => {
post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(toastErr);
closeModal();
};
const row = el('div', 'buttons');
for (const opt of ev.options || [])
row.appendChild(Object.assign(el('button', 'ok', opt), { onclick: () => send(opt, false) }));
const free = el('input');
free.placeholder = '自定义回答';
row.appendChild(free);
row.appendChild(Object.assign(el('button', 'ok', '回答'), { onclick: () => send(free.value, false) }));
row.appendChild(Object.assign(el('button', 'danger', '忽略'), { onclick: () => send('', true) }));
box.appendChild(row);
openModal(box);
setTimeout(() => free.focus(), 0);
}
/* --- sessions ------------------------------------------------------------ */
let sessionsCache = [];
function timeLabel(id) {
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/.exec(id || '');
return m ? `${+m[2]}/${+m[3]} ${m[4]}:${m[5]}` : '';
}
async function loadSessions() {
let data;
try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; }
sessionsCache = data.sessions || [];
const box = $('sessions-list');
box.textContent = '';
for (const s of sessionsCache.slice(0, 60)) {
const b = el('button', 'sess-item' + (s.id === data.current ? ' on' : ''));
const t = el('span', 't');
const ts = el('span', 'ts', s.title || '(空会话)');
t.appendChild(ts);
b.appendChild(t);
b.appendChild(el('span', 'n', s.messages + ' 条 · ' + (timeLabel(s.id) || '')));
b.title = s.id + ' · ' + s.messages + ' 条消息';
b.onclick = async () => {
if (s.id === (state.session || '')) { closeDrawers(); return; }
try {
await post('api/sessions/switch', { id: s.id });
await loadSession();
await loadSessions();
closeDrawers();
} catch (e) { toastErr(e); }
};
box.appendChild(b);
}
if (!sessionsCache.length) box.appendChild(el('div', 'muted tiny', '还没有历史会话'));
/* measure after layout: only rows whose title really overflows get the scroll */
requestAnimationFrame(() => {
for (const t of box.querySelectorAll('.t')) {
const ts = t.firstChild;
if (ts && ts.scrollWidth > t.clientWidth + 2) {
t.classList.add('mq');
t.style.setProperty('--mq', (ts.scrollWidth - t.clientWidth + 6) + 'px');
}
}
});
}
async function showSessions() {
await loadSessions();
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '会话'));
for (const s of sessionsCache) {
const row = el('div', 'sess');
row.appendChild(el('span', 'id', (s.id === state.session ? '● ' : ' ') + s.id.slice(0, 12)));
row.appendChild(el('span', 't', s.title || '(空会话)'));
row.appendChild(el('span', 'id', s.messages + ' 条'));
row.appendChild(Object.assign(el('button', null, '打开'), {
onclick: async () => {
try {
await post('api/sessions/switch', { id: s.id });
closeModal();
await loadSession();
await loadSessions();
} catch (e) { toastErr(e); }
},
}));
box.appendChild(row);
}
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', 'ok', '新建会话'), {
onclick: async () => {
try {
await post('api/sessions/new', {});
closeModal();
await loadSession();
await loadSessions();
} catch (e) { toastErr(e); }
},
}));
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
/* --- pickers as popovers ------------------------------------------------- */
/* "1M" / "200K" for a context window in tokens */
function fmtCtx(n) {
if (!n) return '';
if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + 'M';
return Math.round(n / 1000) + 'K';
}
function modelDesc(m) {
const parts = [];
if (m.context) parts.push(fmtCtx(m.context) + ' 上下文');
if (m.vision === true) parts.push('识图');
else if (m.vision === false) parts.push('纯文本');
if (m.reasoning) parts.push('推理强度');
if (m.price) parts.push((m.price.currency || '') + m.price.miss + '/' + m.price.out + ' 每百万');
parts.push(m.api ? '提供商列出' : '内置');
return parts.join(' · ');
}
let modelsCache = null; /* {provider, data} of the last GET api/models */
async function fetchModels(refresh) {
const r = await api('api/models' + (refresh ? '?refresh=1' : ''));
let d = null;
try { d = await readReply(r); } catch (e) {
/* 401 = the key was rejected: the body still has the built-in list */
if (e.status !== 401) throw e;
d = e.data || {};
}
modelsCache = { provider: d.provider, data: d };
return d;
}
function setModel(id) {
post('api/config', { model: id })
.then(() => { toast('模型 → ' + id, 'ok'); })
.catch(toastErr);
}
function modelPopover(anchor) {
openPop(anchor, (pop) => {
const head = el('div', 'pop-title pop-head');
head.appendChild(el('span', null, '模型 · ' + (state.provider || '-')));
const rb = el('button', 'icon tiny');
rb.title = '从提供商重新拉取模型列表';
rb.appendChild(icon('refresh'));
head.appendChild(rb);
pop.appendChild(head);
const row = el('div', 'pop-input');
const input = el('input');
input.placeholder = '搜索,或直接输入模型名';
input.spellcheck = false;
row.appendChild(input);
pop.appendChild(row);
const note = el('div', 'pop-note');
pop.appendChild(note);
const list = el('div', 'pop-list');
pop.appendChild(list);
let data = (modelsCache && modelsCache.provider === state.provider) ? modelsCache.data : null;
let loading = false;
const pick = (id) => { closePop(); if (id && id !== state.model) setModel(id); };
const render = () => {
list.textContent = '';
const q = input.value.trim();
const ql = q.toLowerCase();
const ms = (data && data.models) || [];
const shown = ms.filter((m) => !ql || m.id.toLowerCase().includes(ql)).slice(0, 300);
for (const m of shown)
list.appendChild(popRow({ title: m.id, desc: modelDesc(m), on: m.id === state.model, onClick: () => pick(m.id) }));
if (q && !ms.some((m) => m.id === q))
list.appendChild(popRow({ icon: 'plus', title: '使用「' + q + '」', desc: '不在列表里也可以直接用', onClick: () => pick(q) }));
if (!list.children.length)
list.appendChild(el('div', 'pop-empty', loading ? '加载中…' : '没有匹配的模型'));
};
const setNote = () => {
note.textContent = '';
note.className = 'pop-note';
if (loading) { note.textContent = '正在向 ' + (state.provider || '提供商') + ' 拉取模型列表…'; return; }
if (!data) return;
if (data.auth) {
note.classList.add('bad');
note.appendChild(el('span', null, data.error || 'API key 被拒绝'));
const fix = el('button', 'link', '更换 key');
fix.onclick = (e) => { e.stopPropagation(); closePop(); changeKey(); };
note.appendChild(fix);
} else if (data.api_error) {
note.classList.add('warn');
note.textContent = data.api_error + ';下面是内置列表';
} else if (!data.key_set) {
note.classList.add('warn');
note.textContent = '还没有 API key,只能显示内置列表';
} else if (data.api_count >= 0) {
note.textContent = data.provider + ' 列出 ' + data.api_count + ' 个模型';
}
};
const load = (refresh) => {
loading = true; setNote(); render();
fetchModels(refresh)
.then((d) => { data = d; })
.catch((e) => { data = data || { models: [] }; toastErr(e); })
.finally(() => { loading = false; setNote(); render(); });
};
rb.onclick = (e) => { e.stopPropagation(); load(true); };
input.addEventListener('input', render);
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const q = input.value.trim();
const ms = (data && data.models) || [];
const hit = ms.filter((m) => m.id.toLowerCase().includes(q.toLowerCase()));
pick(hit.length === 1 ? hit[0].id : q);
});
if (data) { setNote(); render(); } else load(false);
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(el('div', 'pop-title', '推理强度'));
if (state.effort_applies === false)
pop.appendChild(el('div', 'pop-note warn', '当前模型不接受推理强度参数:可以先选好,换到支持的模型时生效'));
const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' };
for (const e2 of EFFORTS) {
const r = popRow({
icon: LV[e2.key] || 'lv1',
title: e2.title,
desc: e2.desc,
on: e2.key === state.effort,
onClick: () => post('api/config', { effort: e2.key })
.then((d) => { if (d && d.warning) toast(d.warning, 'warn'); })
.catch(toastErr),
});
if (state.effort_applies === false) r.classList.add('dim');
pop.appendChild(r);
}
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({
icon: 'cpu',
title: '提供商:' + (state.provider || '-'),
desc: (state.api_base || '') + ' · 点这里切换',
onClick: showProviders,
}));
setTimeout(() => input.focus(), 0);
});
}
/* --- providers and keys -------------------------------------------------- */
function keyFromText(p) {
if (!p.has_key) return '未设置 key';
if (p.key_from === 'saved') return 'key 已保存';
if (p.key_from === 'session') return 'key 仅本次运行';
return 'key 来自环境变量 ' + p.key_from;
}
/* one password field; submit(key) returns a promise, a rejection stays in
* the dialog so the user can paste another key */
function keyDialog(title, sub, submit) {
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, title));
if (sub) box.appendChild(el('div', 'muted tiny', sub));
box.appendChild(el('div', 'muted tiny', 'key 会先用 /models 验证,再保存到配置目录的 credentials 文件(仅你可读),不会出现在页面、日志或事件里。'));
const input = el('input', 'key-input');
input.type = 'password';
input.autocomplete = 'off';
input.placeholder = 'sk-…';
box.appendChild(input);
const err = el('div', 'key-err');
box.appendChild(err);
const row = el('div', 'buttons');
const ok = el('button', 'ok', '验证并保存');
const go = async () => {
const k = input.value.trim();
if (!k) { err.textContent = '请先粘贴 key'; return; }
ok.disabled = true;
err.textContent = '验证中…';
try {
await submit(k);
input.value = '';
closeModal();
} catch (e) {
err.textContent = errMsg(e);
ok.disabled = false;
input.select();
}
};
ok.onclick = go;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
row.appendChild(ok);
row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
setTimeout(() => input.focus(), 0);
}
function afterSwitch(r) {
modelsCache = null;
toast('已切换到 ' + r.provider + (r.model ? ' · ' + r.model : '(请选一个模型)'), 'ok');
if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200);
}
function askKeyFor(p) {
keyDialog('API key · ' + p.name, p.api_base, async (key) => {
afterSwitch(await post('api/provider', { name: p.name, key }));
});
}
async function switchProvider(p) {
if (!p.has_key) return askKeyFor(p);
try {
closeModal();
afterSwitch(await post('api/provider', { name: p.name }));
} catch (e) {
if (e.data && e.data.need_key) return askKeyFor(p);
toastErr(e);
}
}
async function showProviders() {
let d;
try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); }
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '切换提供商'));
for (const p of d.providers || []) {
const row = el('div', 'prov' + (p.current ? ' on' : ''));
const m = el('div', 'pm');
m.appendChild(el('b', null, p.name + (p.current ? ' · 当前' : '')));
m.appendChild(el('span', 'muted tiny', p.api_base));
m.appendChild(el('span', 'tiny ' + (p.has_key ? 'ok-text' : 'warn-text'),
keyFromText(p) + ' · ' + (p.model || '模型从 API 获取') + (p.source === 'user' ? ' · models.json' : '')));
row.appendChild(m);
const acts = el('div', 'prov-acts');
if (p.current)
acts.appendChild(Object.assign(el('button', null, '换 key'), { onclick: changeKey }));
else
acts.appendChild(Object.assign(el('button', 'ok', p.has_key ? '切换' : '设 key 并切换'), {
onclick: () => switchProvider(p),
}));
row.appendChild(acts);
box.appendChild(row);
}
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
function changeKey() {
keyDialog('更换 API key · ' + (state.provider || ''), state.api_base || '', async (key) => {
const r = await post('api/key', { key });
modelsCache = null;
toast('key 已验证并保存', 'ok');
if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200);
});
}
function approvalPopover(anchor) {
openPop(anchor, (pop) => {
pop.appendChild(el('div', 'pop-title', '审批权限'));
for (const a of APPROVALS)
pop.appendChild(popRow({
icon: a.icon,
title: a.title,
desc: a.desc,
on: a.key === (state.approval || 'auto'),
danger: a.key === 'yolo',
onClick: () => post('api/config', { approval: a.key }).catch(toastErr),
}));
}, { align: 'right' });
}
function plusPopover(anchor) {
openPop(anchor, (pop) => {
pop.appendChild(popRow({
icon: 'image', title: '上传图片', desc: '也可以直接粘贴或拖进来',
onClick: () => $('file').click(),
}));
pop.appendChild(popRow({
icon: 'clipboard', title: '粘贴剪贴板', desc: '从剪贴板里取一张图',
onClick: async () => {
try {
const items = await navigator.clipboard.read();
for (const it of items) {
const type = it.types.find((t) => t.startsWith('image/'));
if (!type) continue;
const blob = await it.getType(type);
await upload(new File([blob], 'clipboard.png', { type }));
return;
}
$('status').textContent = '剪贴板里没有图片';
} catch { $('status').textContent = '浏览器不允许读剪贴板,请直接粘贴'; }
},
}));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({
icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件',
on: !!state.plan_mode,
onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(toastErr),
}));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(el('div', 'pop-title', '工作目录'));
const wrow = el('div', 'pop-input');
const wpath = el('input');
wpath.value = state.workspace || '';
wpath.placeholder = '/绝对路径/到项目';
wpath.spellcheck = false;
const wgo = el('button', null, '切换');
wgo.onclick = async () => {
const p = wpath.value.trim();
if (!p) return;
wgo.disabled = true;
try {
await post('api/workspace', { path: p });
closePop();
await loadSession();
await loadSessions();
$('status').textContent = '工作目录已切换';
} catch (e) {
toastErr(e);
wgo.disabled = false;
}
};
wrow.appendChild(wpath);
wrow.appendChild(wgo);
pop.appendChild(wrow);
});
}
function usagePopover(anchor) {
const u = state.usage || {};
openPop(anchor, (pop) => {
pop.appendChild(el('div', 'pop-title', '状态'));
const kv = (k, v) => {
const row = el('div', 'kv');
row.appendChild(el('span', null, k));
row.appendChild(el('b', null, v));
pop.appendChild(row);
};
kv('工作区', state.workspace || '-');
kv('会话', state.session || '-');
const mi = state.model_info || {};
kv('上下文占用', num(state.context_tokens) + (mi.context ? ' / ' + fmtCtx(mi.context) : '') + ' tok');
kv('模型往返', String(u.requests || 0));
kv('Token', num(u.prompt_tokens) + ' / ' + num(u.completion_tokens));
kv('费用', (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : (u.requests ? '未定价' : '-'));
const t = state.turn_usage || {};
if (t.requests)
kv('上一轮', t.requests + ' 往返 · ' + num(t.prompt_tokens) + ' / ' + num(t.completion_tokens) +
((t.priced && t.cost) ? ' · ' + (t.currency || '') + t.cost.toFixed(4) : ''));
kv('工具协议', state.protocol_tools || '-');
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') }));
pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', desc: '把旧消息收成摘要', onClick: compact }));
}, { align: 'right' });
}
function menuPopover(anchor) {
openPop(anchor, (pop) => {
pop.appendChild(popRow({ icon: 'list', title: '会话列表', desc: 'Ctrl+K', onClick: showSessions }));
pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') }));
pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', onClick: compact }));
pop.appendChild(popRow({
icon: 'undo', title: '撤销上一次写入', desc: '把文件恢复到工具改写之前',
onClick: undo,
}));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).then(() => toast('服务已退出', 'ok')).catch(toastErr) }));
}, { align: 'right' });
}
function showSettings() {
openPop($('btn-settings'), (pop) => {
pop.appendChild(el('div', 'pop-title', '工作区'));
const kv = (k, v) => {
const row = el('div', 'kv');
row.appendChild(el('span', null, k));
row.appendChild(el('b', null, v || '-'));
pop.appendChild(row);
};
kv('目录', state.workspace);
kv('会话', state.session);
kv('提供商', state.provider);
kv('端点', state.api_base);
kv('API key', keyFromText({ has_key: state.key_set, key_from: state.key_from }));
kv('模型', state.model);
kv('工具协议', state.protocol_tools);
kv('工具集', state.tool_profile);
kv('每轮最多步数', String(state.max_steps || '-'));
pop.appendChild(el('div', 'pop-sep'));
pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) }));
pop.appendChild(popRow({ icon: 'refresh', title: '切换提供商', desc: '换端点,需要时输入 key', onClick: showProviders }));
pop.appendChild(popRow({ icon: 'shield', title: '更换 API key', desc: '验证后保存,只对当前提供商', onClick: changeKey }));
pop.appendChild(popRow({ icon: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) }));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
});
}
async function copyText(t) {
try {
await navigator.clipboard.writeText(t);
$('status').textContent = '已复制:' + t;
} catch { $('status').textContent = '复制失败'; }
}
/* --- composer ------------------------------------------------------------ */
function syncSend() {
$('btn-send').disabled = busy || !$('input').value.trim();
}
async function compact() {
$('status').textContent = '压缩中…';
try { await post('api/compact', {}); $('status').textContent = '上下文已压缩'; }
catch (e) { $('status').textContent = '就绪'; toast('压缩失败:' + errMsg(e)); }
}
async function undo() {
try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; }
catch (e) { toast('撤销失败:' + errMsg(e)); }
}
async function send(text) {
const input = $('input');
const body = (text !== undefined ? text : input.value).trim();
if (!body || busy) return;
if (text === undefined) input.value = '';
autoGrow();
syncSend();
bubble('user').body.textContent = body;
steps = null;
stepIndex = new Map();
files = new Map();
live = null;
renderFilesPanel();
turnStart = Date.now();
try {
await post('api/turn', { text: body });
$('status').textContent = '已开始…';
} catch (e) {
bubble('error').body.textContent = errMsg(e);
}
}
function autoGrow() {
const t = $('input');
t.style.height = 'auto';
t.style.height = Math.min(t.scrollHeight, 240) + 'px';
}
async function upload(file) {
const r = await api('api/attach?name=' + encodeURIComponent(file.name || 'pasted.png'), {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
try { await readReply(r); } catch (e) { toast('上传失败:' + errMsg(e)); return; }
if (state.sees_images === 0) toast((state.model || '当前模型') + ' 不识图,这张图不会发给模型', 'warn');
}
/* --- layout -------------------------------------------------------------- */
function toggleSide() {
if (window.innerWidth <= 900) {
const open = document.body.classList.toggle('side-open');
document.body.classList.toggle('drawer-open', open);
document.body.classList.remove('panel-open');
return;
}
localStorage.setItem('cst.side', document.body.classList.toggle('no-side') ? '0' : '1');
}
function togglePanel() {
if (window.innerWidth <= 900) {
const open = document.body.classList.toggle('panel-open');
document.body.classList.toggle('drawer-open', open);
document.body.classList.remove('side-open');
return;
}
localStorage.setItem('cst.panel', document.body.classList.toggle('no-panel') ? '0' : '1');
}
function closeDrawers() {
document.body.classList.remove('side-open', 'panel-open', 'drawer-open');
}
function applyTheme(t) {
document.documentElement.dataset.theme = t;
localStorage.setItem('cst.theme', t);
}
function toggleTheme() {
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
}
/* --- wiring -------------------------------------------------------------- */
$('btn-send').onclick = () => send();
$('btn-stop').onclick = () => post('api/cancel', {}).catch(toastErr);
$('btn-new').onclick = async () => {
try { await post('api/sessions/new', {}); } catch (e) { toastErr(e); }
await loadSession();
await loadSessions();
closeDrawers();
};
$('btn-refresh-sessions').onclick = () => loadSessions();
$('chip-model').onclick = () => modelPopover($('chip-model'));
$('chip-approval').onclick = () => approvalPopover($('chip-approval'));
$('btn-plus').onclick = () => plusPopover($('btn-plus'));
$('cost').onclick = () => usagePopover($('cost'));
$('btn-menu').onclick = () => menuPopover($('btn-menu'));
$('usage').onclick = () => usagePopover($('cost'));
$('btn-settings').onclick = showSettings;
$('btn-collapse').onclick = toggleSide;
$('btn-side').onclick = toggleSide;
$('btn-drawer').onclick = toggleSide;
$('btn-panel').onclick = togglePanel;
$('btn-panel-close').onclick = togglePanel;
$('btn-theme').onclick = toggleTheme;
$('btn-undo').onclick = undo;
$('to-bottom').onclick = () => scrollDown(true);
for (const b of $('seg-mode').children) {
b.onclick = async () => {
try {
await post('api/config', { plan_mode: b.dataset.mode === 'plan' });
$('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写';
} catch (e) { toastErr(e); }
};
}
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
});
$('input').addEventListener('input', () => { autoGrow(); syncSend(); });
$('scroll').addEventListener('scroll', updateJump);
$('scroll').addEventListener('wheel', closePop, { passive: true });
$('file').addEventListener('change', async (e) => {
for (const f of e.target.files) await upload(f);
e.target.value = '';
});
document.addEventListener('paste', async (e) => {
const items = (e.clipboardData || {}).items || [];
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
e.preventDefault();
await upload(item.getAsFile());
}
}
});
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', async (e) => {
if (!e.dataTransfer.files.length) return;
e.preventDefault();
for (const f of e.dataTransfer.files) await upload(f);
});
$('backdrop').onclick = closeDrawers;
/* --- go ------------------------------------------------------------------ */
applyTheme(localStorage.getItem('cst.theme') || 'dark');
/* the rail/panel/theme classes are already on <body> from the inline script in
* index.html, so nothing here has to re-derive them */
autoGrow();
syncSend();
renderFilesPanel();
connect();
loadSession().catch(toastErr);
loadSessions();
getJSON('api/state').then(applyState).catch(toastErr);
/* deep link: ?open=chip-approval / ?open=cost opens that popover once the
* state is in, so a screenshot or a bookmark can land on it directly */
const wantsOpen = new URLSearchParams(location.search).get('open');
if (wantsOpen) setTimeout(() => { const a = $(wantsOpen); if (a) a.click(); }, 900);