/* Hermit 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
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/, '没有可以撤销的写入'],
[/^nothing to retry/, '没有可以重试的消息'],
[/^cannot reset the rules/, '清除许可规则失败'],
[/^expected a JSON object/, '请求格式不对'],
[/^cannot set the title/, '标题保存失败'],
[/^cannot delete the session/, '删除会话失败'],
[/^cannot start a session/, '无法新建会话'],
[/^no such session/, '找不到这个会话'],
[/^no such permission request/, '这个许可请求已经结束'],
[/^no such question/, '这个问题已经结束'],
[/^unauthorized|^forbidden/i, '没有权限:请带上 token 打开页面'],
[/^out of memory/, '服务器内存不足'],
[/^path is required/, '需要填写路径'],
[/^empty body/, '内容为空'],
[/^no such skill/, '没有这个 skill(/skills 查看全部)'],
[/^cannot save the settings/, '设置保存失败'],
[/^nothing to compact/, '没有可以压缩的内容'],
[/^compact llm: (.*)/, (m) => '压缩时模型请求失败:' + m[1]],
[/^compact produced empty summary/, '压缩失败:模型给了空摘要'],
[/^no such directory: (.*)/, (m) => '目录不存在:' + m[1]],
[/^cannot use as a workspace: (.*)/, (m) => '不能用作工作目录:' + m[1]],
[/^at most (\d+) images per message/, (m) => '每条消息最多 ' + m[1] + ' 张图'],
[/^the images (?:already picked fill|together exceed) (\d+) MB/, (m) => '图片加起来超过 ' + m[1] + ' MB'],
[/^HTTP (\d+): the API key was rejected/, (m) => 'API key 被拒绝(HTTP ' + m[1] + ')'],
[/^not found$/, '服务端没有这个接口:页面和程序版本不一致,刷新页面试试'],
];
/* the first match of a [regexp, text | (match) => text] table, or null */
function zhOf(table, msg) {
for (const [re, zh] of table) {
const m = re.exec(msg);
if (m) return typeof zh === 'function' ? zh(m) : zh;
}
return null;
}
function errorText(status, msg) {
msg = String(msg || '');
const zh = zhOf(ERR_ZH, msg);
if (zh) return zh;
if (status === 401 && !msg) return '没有权限:请带上 token 打开页面';
return msg || ('HTTP ' + status);
}
/* the engine's one-line status messages */
const STATUS_ZH = [
[/^calling model \(step (\d+)\)/, (m) => '调用模型 · 第 ' + m[1] + ' 步…'],
[/^final answer pass/, '最后一次作答…'],
[/^tool round done/, '工具执行完,继续…'],
[/^contract error/, '模型的工具调用格式不对,让它重发…'],
[/^empty response/, '模型回了空内容,重试…'],
[/^output cut off/, '输出被截断,接着往下写…'],
[/^the model refused images/, '模型不收图片,改发文字说明…'],
[/^prompt (\d+)K tokens \(budget (\d+)K\)/, (m) => '上下文 ' + m[1] + 'K 超过预算 ' + m[2] + 'K,压缩中…'],
[/^context compacted/, '上下文已压缩'],
[/^done$/, '完成'],
[/^blocked by the sandbox/, '被沙箱拦下'],
[/^rerunning outside the sandbox/, '在沙箱外重跑…'],
[/^unknown tool/, '模型调用了不存在的工具'],
[/^skip repeated edit/, '跳过重复的修改'],
[/^skip duplicate read/, '跳过重复的读取'],
[/^running reads in parallel/, '并行读取中…'],
];
const statusText = (msg) => zhOf(STATUS_ZH, String(msg || '')) || msg;
/* why a turn stopped, in words: turn_end carries the engine's error text */
const HTTP_ZH = {
400: '请求被拒绝', 401: 'API key 无效或已过期', 402: '余额不足', 403: '没有权限用这个模型',
404: '模型或端点不存在', 408: '请求超时', 413: '请求太大', 429: '请求太频繁或额度用完',
};
const TURN_ERR_ZH = [
[/^(?:llm: )?cancelled$/, '已停止'],
[/^llm: http (\d+)(?::\s*([\s\S]*))?/i, (m) => '模型请求失败:' +
(HTTP_ZH[m[1]] || (+m[1] >= 500 ? '服务端出错' : '出错')) + '(HTTP ' + m[1] + ')' + (m[2] ? '\n' + m[2] : '')],
[/^llm: curl: (.*)/, (m) => '连不上模型服务:' + m[1]],
[/^llm: api: (.*)/, (m) => '模型服务报错:' + m[1]],
[/^llm: (.*)/, (m) => '模型请求失败:' + m[1]],
[/^(\S+): repeated contract errors/, (m) => '模型连续几次没按 ' + m[1] +
' 协议的格式调用工具,这一轮停了。可以在运行设置里换个工具协议,或换个模型再试。'],
[/^empty model response/, '模型返回了空内容。'],
[/^step limit \((\d+)\) reached/, (m) => '这一轮用满了 ' + m[1] + ' 步上限。说"继续"接着做,或在运行设置里调大步数。'],
];
const turnError = (d) => zhOf(TURN_ERR_ZH, String(d.error || '')) || d.error ||
('这一轮没有完成(错误码 ' + d.rc + ')');
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 proc = null; /* the open process block: thinking + tool calls */
let stepIndex = new Map(); /* tool call id -> step row */
let lastTurn = null; /* { session, ms }: the last turn's time, kept across the rebuild */
let pendingPreviews = []; /* { src, name }: object URLs of the images uploaded from this page */
let shownPreviews = []; /* the ones the newest prompt still shows until the rebuild */
let files = new Map(); /* this turn's file changes: path -> {add, del} */
let permReq = null;
let askReq = null;
let busy = false;
let turnStart = 0;
let turnErr = null; /* why the last turn stopped; kept across reloads */
let detailsAll = localStorage.getItem('cst.details') === '1'; /* tool lines start open */
let shownThinking; /* state.show_thinking last applied to the page */
/* --- 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);
}
/* every row of the transcript shares one left edge, the composer's: no avatar
* column, the assistant reads as plain text, only errors carry a mark */
function bubble(role, cls) {
const m = el('div', 'msg ' + (cls || role));
const b = el('div', 'body');
m.appendChild(b);
log.appendChild(m);
showEmpty(false);
scrollDown(true);
return { root: m, body: b };
}
function actBtn(name, title, fn) {
const b = el('button', 'act');
b.title = title;
b.setAttribute('aria-label', title);
b.appendChild(icon(name));
b.onclick = fn;
return b;
}
function copyButton(text) {
const b = actBtn('copy', '复制', async () => {
try {
await navigator.clipboard.writeText(text);
setIcon(b, 'check');
b.title = '已复制';
setTimeout(() => { setIcon(b, 'copy'); b.title = '复制'; }, 1200);
} catch { toast('浏览器不允许写剪贴板'); }
});
return b;
}
/* images sit above the prompt, outside the bubble: one keeps its shape, more
* than one become a row of square tiles; a click opens the full picture */
function attachStrip(srcs) {
const strip = el('div', 'attach-strip' + (srcs.length === 1 ? ' one' : ''));
srcs.forEach((s, i) => {
const b = el('button', 'thumb');
b.title = s.name || '查看图片';
const img = el('img');
img.alt = s.name || 'image';
img.loading = 'lazy';
img.decoding = 'async';
img.src = s.src;
b.appendChild(img);
b.onclick = () => openLightbox(srcs, i);
strip.appendChild(b);
});
return strip;
}
function userBubble(text, srcs) {
const b = bubble('user');
b.body.textContent = text;
if (srcs && srcs.length) b.root.insertBefore(attachStrip(srcs), b.body);
b.root.dataset.prompt = '1';
actionsFor(b.root, text);
return b;
}
function actionsFor(root, text) {
const row = el('div', 'msg-actions');
row.appendChild(copyButton(text));
root.appendChild(row);
return row;
}
/* --- lightbox ------------------------------------------------------------ */
let lb = null; /* { srcs, i } while a picture is open */
function showLightbox() {
const s = lb.srcs[lb.i];
$('lb-img').src = s.src;
$('lb-img').alt = s.name || '';
const many = lb.srcs.length > 1;
$('lb-count').textContent = many ? (lb.i + 1) + ' / ' + lb.srcs.length : '';
$('lb-prev').hidden = !many;
$('lb-next').hidden = !many;
}
function openLightbox(srcs, i) {
lb = { srcs: srcs.slice(), i };
showLightbox();
$('lightbox').hidden = false;
}
function closeLightbox() {
$('lightbox').hidden = true;
$('lb-img').removeAttribute('src');
lb = null;
}
function stepLightbox(d) {
if (!lb || lb.srcs.length < 2) return;
lb.i = (lb.i + d + lb.srcs.length) % lb.srcs.length;
showLightbox();
}
/* --- process timeline -----------------------------------------------------
* Consecutive thinking and tool calls share one block that folds to a single
* line; visible text (or a note, or the next prompt) closes it. History and
* the live stream follow the same rule, so a reload draws what streamed. */
function procLabel(p) {
const parts = [];
if (p.thinks) parts.push(p.thinks > 1 ? '思考 ' + p.thinks + ' 次' : '已思考');
if (p.tools) parts.push('工具 ' + p.tools + ' 次');
const counts = parts.join(' · ');
if (p.root.classList.contains('run')) {
p.lbl.textContent = p.act || '处理中…';
p.meta.textContent = counts;
} else {
p.lbl.textContent = counts || '过程';
p.meta.textContent = '';
}
}
function ensureProc() {
if (proc) return proc;
if (live && live.html) sealLive();
const d = el('details', 'proc');
d.open = !!state.show_thinking;
const s = el('summary', 'proc-sum');
s.appendChild(icon('chevron', 'proc-chev'));
const lbl = el('span', 'proc-lbl');
const meta = el('span', 'proc-meta');
s.appendChild(lbl);
s.appendChild(meta);
const box = el('div', 'tl');
d.appendChild(s);
d.appendChild(box);
log.appendChild(d);
showEmpty(false);
proc = { root: d, lbl, meta, box, thinks: 0, tools: 0, cur: null, act: '' };
procLabel(proc);
return proc;
}
function sealProc() {
if (!proc) return;
proc.root.classList.remove('run');
procLabel(proc);
proc = null;
}
function procAct(p, text) {
p.root.classList.add('run');
p.act = text;
procLabel(p);
}
function firstLine(s) {
const l = String(s).split('\n').find((x) => x.trim()) || '';
return l.trim().slice(0, 200);
}
function procThink(text, running) {
const p = ensureProc();
let t = p.cur && p.cur.kind === 'think' ? p.cur : null;
if (!t) {
const d = el('details', 'tl-item think');
d.open = !!state.show_thinking;
const s = el('summary');
s.appendChild(el('span', 'tl-dot'));
s.appendChild(el('span', 'tl-name', '思考'));
const prev = el('span', 'tl-prev');
s.appendChild(prev);
const body = el('div', 'think-text');
const node = document.createTextNode('');
body.appendChild(node);
d.appendChild(s);
d.appendChild(body);
p.box.appendChild(d);
t = { kind: 'think', root: d, prev, node, hasLine: false };
p.cur = t;
p.thinks++;
}
t.node.appendData(text);
/* the preview is the first line, so it stops changing once that is whole */
if (!t.hasLine) {
t.prev.textContent = firstLine(t.node.data);
t.hasLine = /\S[^\n]*\n/.test(t.node.data);
}
if (running) procAct(p, '正在思考…');
else procLabel(p);
return t;
}
/* "[cst] Plan mode is now ON: ..." notes the engine adds to the history; the
* model reads the English, the page shows one line per change */
const APPROVAL_ZH = { ask: '每次询问', auto: '自动', yolo: '完全放行' };
const NOTE_ZH = [
[/^Plan mode is now ON/, '切到计划模式(只读)'],
[/^Plan mode is now OFF/, '切回构建模式'],
[/^Approval mode is now (\w+)/, (m) => '审批改为' + (APPROVAL_ZH[m[1]] || m[1])],
[/^The git branch is now (.+?)\.?$/, (m) => 'git 分支变为 ' + m[1]],
[/^Today's date is now (.+?)\.?$/, (m) => '日期变为 ' + m[1]],
[/^The workspace's AGENTS\.md changed/, 'AGENTS.md 有改动,已重新读入'],
[/^The workspace's AGENTS\.md was removed/, 'AGENTS.md 已删除'],
];
function noteLine(text) {
const lines = String(text || '').split('\n').filter((l) => l.startsWith('[cst]'))
.map((l) => l.replace(/^\[cst\]\s*/, ''));
const shown = (lines.length ? lines : [String(text || '').split('\n')[0]])
.map((l) => zhOf(NOTE_ZH, l) || l);
const n = el('div', 'note-line', shown.join(' · '));
n.title = String(text || '');
sealProc();
log.appendChild(n);
}
/* history: the stored calls become the same step lines a live turn draws,
* and each result fills its line in */
function historyCall(c) {
const st = stepFor({ id: c.id, name: c.name });
const a = argsLine(c.args);
if (a) st.args.textContent = a;
}
function historyResult(m) {
const st = stepFor({ id: m.tool_call_id, name: m.tool_name });
st.root.classList.remove('run');
st.root.classList.add('done');
st.bad = !!m.exit;
st.root.classList.toggle('bad', st.bad);
st.phase.textContent = st.bad ? '失败 exit=' + m.exit : '完成';
st.body.textContent = '';
if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent));
const out = m.out !== undefined ? m.out : (m.content || '');
if (out) st.body.appendChild(el('pre', 'step-out', out + (m.truncated ? '\n… (截断)' : '')));
}
function renderMessage(m) {
if (m.role === 'system') return;
if (m.note) return noteLine(m.content);
if (m.role === 'tool' || m.tool_result) return historyResult(m);
const isUser = m.role === 'user';
const text = m.text !== undefined ? m.text : (m.content || '');
if (isUser) {
sealProc();
stepIndex = new Map();
const srcs = (m.attach || []).map((a) => ({ src: attachURL(a.rel), name: a.name }));
userBubble(text, srcs);
return;
}
if (m.reasoning) {
procThink(m.reasoning, false);
proc.cur = null; /* the next round's thinking is its own entry */
}
if (text.trim()) {
sealProc();
const b = bubble('assistant');
if (m.html) b.body.innerHTML = m.html;
else b.body.textContent = text;
actionsFor(b.root, text);
}
for (const c of m.calls || []) historyCall(c);
}
/* only the newest exchange can be redone: the server rewinds one turn */
function lastPromptActions() {
const prompts = log.querySelectorAll('.msg[data-prompt]');
const p = prompts[prompts.length - 1];
if (!p) return;
const row = p.querySelector('.msg-actions');
if (row) row.appendChild(actBtn('edit', '编辑重发', () => editLast(p)));
const answers = log.querySelectorAll('.msg.assistant');
const a = answers[answers.length - 1];
if (a && (p.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING)) {
const r = a.querySelector('.msg-actions');
if (r) r.appendChild(actBtn('retry', '重新生成', () => retry()));
}
}
async function loadSession() {
const data = await getJSON('api/session');
log.textContent = '';
stepIndex = new Map();
files = new Map();
renderFilesPanel();
proc = null;
live = null;
for (const m of data.messages) renderMessage(m);
sealProc();
stepIndex = new Map();
lastPromptActions();
if (lastTurn && lastTurn.session === state.session && log.lastChild) turnMeta(lastTurn.ms);
if (turnErr && turnErr.session === state.session) {
if (turnErr.stopped) noteLine('已停止');
else bubble('error').body.textContent = turnErr.text;
}
/* a rebuild is not new content: settle every entrance animation at once */
if (log.getAnimations) {
for (const a of log.getAnimations({ subtree: true })) {
try { a.finish(); } catch { /* an infinite pulse cannot finish; it keeps running */ }
}
}
showEmpty(log.children.length === 0);
const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user' && !m.note && !m.tool_result);
document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'Hermit';
scrollDown(true);
updateJump();
}
/* --- live blocks --------------------------------------------------------- */
/* text closes the process block above it; thinking or a tool call after the
* text closes the answer, so the next words start a bubble below them */
function ensureLive() {
if (live) return live;
sealProc();
const b = bubble('assistant');
b.body.classList.add('live');
const out = el('div', 'live-html');
b.body.appendChild(out);
live = { root: b.root, body: b.body, out, html: '' };
return live;
}
function sealLive() {
if (!live) return;
live.body.classList.remove('live');
live = null;
}
function phaseText(p) {
return { open: '准备中', ready: '待执行', run: '执行中', progress: '进行中', 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 p = ensureProc();
const node = el('details', 'tl-item step-line run');
node.open = detailsAll;
const sum = el('summary');
const name = el('span', 'name', ev.name || 'tool');
const args = el('span', 'args');
const phase = el('span', 'phase', '准备中');
sum.appendChild(el('span', 'tl-dot'));
sum.appendChild(name);
sum.appendChild(args);
sum.appendChild(phase);
const body = el('div', 'step-body');
node.appendChild(sum);
node.appendChild(body);
p.box.appendChild(node);
st = { kind: 'tool', root: node, sum, name, args, phase, body, t0: Date.now(), bad: false, proc: p };
stepIndex.set(ev.id, st);
p.cur = st;
p.tools++;
procLabel(p);
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('button', 'fstat');
row.appendChild(icon('file', 'fimg'));
const nm = el('span', 'p', p);
row.title = '查看 ' + p + ' 的 diff';
row.onclick = () => showDiff(p === '补丁' ? undefined : p);
row.appendChild(nm);
row.appendChild(el('span', 'a', '+' + v.add));
row.appendChild(el('span', 'd', '−' + v.del));
box.appendChild(row);
}
}
/* --- the real diff: git status + git diff of the workspace --------------- */
function diffPre(text) {
const pre = el('pre', 'diff');
for (const ln of String(text).split('\n')) {
let cls = '';
if (/^(diff |index |\+\+\+ |--- |new file|deleted file|similarity|rename )/.test(ln)) cls = 'h';
else if (ln.startsWith('@@')) cls = 'hunk';
else if (ln.charAt(0) === '+') cls = 'add';
else if (ln.charAt(0) === '-') cls = 'del';
pre.appendChild(el('span', cls, ln + '\n'));
}
return pre;
}
const GIT_ST = { M: '修改', A: '新增', D: '删除', R: '重命名', C: '复制', U: '冲突', T: '类型', '?': '未跟踪', '!': '忽略' };
function gitLabel(code) {
const c = (code || '').replace(/ /g, '').charAt(0);
return GIT_ST[c] || code || '';
}
async function showDiff(path) {
let d;
try { d = await getJSON('api/diff' + (path ? '?path=' + encodeURIComponent(path) : '')); }
catch (e) { return toast('读取 diff 失败:' + errMsg(e)); }
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, path ? 'diff · ' + path : '工作区改动'));
const row = el('div', 'buttons');
if (!d.git) {
box.appendChild(el('p', 'muted', '这个工作区不是 git 仓库(或者没有装 git),看不到真实 diff;右栏的行数是按工具参数估算的。'));
} else {
const list = el('div', 'diff-files');
const fileOf = (f) => f.path.includes(' -> ') ? f.path.split(' -> ').pop() : f.path;
for (const f of d.files || []) {
const b = el('button', 'diff-file' + (fileOf(f) === path ? ' on' : ''));
b.appendChild(el('span', 'st', gitLabel(f.status)));
b.appendChild(el('span', 'p', f.path));
b.onclick = () => showDiff(fileOf(f));
list.appendChild(b);
}
if (!(d.files || []).length) list.appendChild(el('div', 'muted tiny', '工作区是干净的,没有改动。'));
box.appendChild(list);
if (d.diff) box.appendChild(diffPre(d.diff));
else if ((d.files || []).length) {
const f = (d.files || []).find((x) => fileOf(x) === path);
box.appendChild(el('p', 'muted tiny', f && f.status === '??'
? '未跟踪的新文件:git 还没有记录它,所以没有 diff。'
: '没有未暂存的改动(已 git add 的改动不在这里显示)。'));
}
if (d.truncated) box.appendChild(el('p', 'warn-text tiny', 'diff 太长,只显示了前 512 KB。'));
if (path) row.appendChild(Object.assign(el('button', null, '全部改动'), { onclick: () => showDiff() }));
row.appendChild(Object.assign(el('button', null, '刷新'), { onclick: () => showDiff(path) }));
}
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box, true);
}
function onTool(ev) {
const st = stepFor(ev);
const a = argsLine(ev.args);
if (a) st.args.textContent = a;
if (ev.phase === 'progress' && ev.detail) {
/* a sub-agent's latest steps: "explore · grep foo · read_file x" */
if (!st.detail) {
st.detail = el('span', 'step-detail');
st.sum.insertBefore(st.detail, st.phase);
}
st.detail.textContent = ev.detail;
$('status').textContent = ev.detail;
} else 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';
}
if (st.proc === proc) {
procAct(proc, ev.phase === 'done' ? '等待模型…'
: (ev.phase === 'progress' && ev.detail ? ev.detail : '执行 ' + (ev.name || '工具') + '…'));
}
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);
if (st.proc === proc) procAct(proc, '等待模型…');
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 = '就绪';
renderPending();
renderTodo(s.todo);
if ('show_thinking' in s && s.show_thinking !== shownThinking) {
shownThinking = s.show_thinking;
for (const d of log.querySelectorAll('details.proc, details.tl-item.think')) d.open = !!s.show_thinking;
}
}
const revokeAll = (list) => { for (const s of list) URL.revokeObjectURL(s.src); };
/* images waiting for the next prompt: thumbnails when this page uploaded all
* of them, otherwise (another client, the CLI) the server's one-line notes */
let pendingSeen = 0; /* the server's count last time: an upload's reply can beat its state event */
function renderPending() {
const p = $('pending');
const list = state.pending_images || [];
const n = list.length;
if (!n && pendingSeen && pendingPreviews.length) {
revokeAll(pendingPreviews);
pendingPreviews = [];
}
pendingSeen = n;
p.hidden = !n;
p.textContent = '';
if (!n) return;
const clear = () => post('api/attach', undefined, 'DELETE').catch(toastErr);
const clearTitle = n > 1 ? '清空全部 ' + n + ' 张待发图片' : '移除这张图片';
if (pendingPreviews.length === n) {
pendingPreviews.forEach((s, i) => {
const t = el('div', 'pend-thumb');
const img = el('img');
img.src = s.src;
img.alt = s.name || 'image';
img.title = list[i];
img.onclick = () => openLightbox(pendingPreviews, i);
const x = el('button', 'pend-x');
x.title = clearTitle;
x.setAttribute('aria-label', clearTitle);
x.appendChild(icon('close'));
x.onclick = clear;
t.appendChild(img);
t.appendChild(x);
p.appendChild(t);
});
} else {
for (const line of list) {
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 = clear;
chip.appendChild(x);
p.appendChild(chip);
}
}
if (state.sees_images === 0)
p.appendChild(el('span', 'chip warn', (state.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
}
/* --- 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 = statusText(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();
procThink(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; proc = null; stepIndex = new Map();
turnErr = null;
lastTurn = null;
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) => {
lastTurn = turnStart ? { session: state.session, ms: Date.now() - turnStart } : null;
turnStart = 0;
sealLive();
sealProc();
stepIndex = new Map();
/* the reload below rebuilds the log, so the reason is drawn after it */
turnErr = d && d.rc ? {
text: turnError(d),
stopped: /cancelled$/.test(d.error || ''),
session: state.session,
} : null;
loadSession().then(() => {
/* the rebuilt prompt shows the server's copies now */
if (!lb || !lb.srcs.some((s) => shownPreviews.includes(s))) {
revokeAll(shownPreviews);
shownPreviews = [];
}
}).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) => {
if (lb) {
if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); stepLightbox(-1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); stepLightbox(1); }
return;
}
/* 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 (editing) return cancelEdit();
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(); }
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
showPalette();
}
if (e.key === 'F1' || (e.key === '?' && !typing)) { e.preventDefault(); showHelp(); }
/* the composer handles its own Shift+Tab; a popover keeps it for focus */
if (e.key === 'Tab' && e.shiftKey && !typing && $('modal').hidden && $('pop').hidden) {
e.preventDefault();
cycleApproval();
}
});
/* --- modal sheet (session list) ------------------------------------------ */
function openModal(node, wide) {
const box = $('modal-box');
box.textContent = '';
box.classList.toggle('wide', !!wide);
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]}` : '';
}
let sessionsCurrent = '';
const sessMatch = (s, q) => !q || (s.title || '').toLowerCase().includes(q) || s.id.includes(q);
async function openSession(id) {
if (id === (state.session || '')) return;
await post('api/sessions/switch', { id });
await loadSession();
await loadSessions();
}
function renameSession(s) {
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '重命名会话'));
box.appendChild(el('p', 'muted tiny', s.id));
const input = el('input', 'key-input');
input.value = s.title || '';
input.placeholder = '留空则恢复为第一条消息';
box.appendChild(input);
const err = el('div', 'key-err');
box.appendChild(err);
const row = el('div', 'buttons');
const go = async () => {
try {
await post('api/sessions/rename', { id: s.id, title: input.value.trim() });
closeModal();
loadSessions();
} catch (e) { err.textContent = errMsg(e); }
};
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
row.appendChild(Object.assign(el('button', 'ok', '保存'), { onclick: go }));
row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
setTimeout(() => { input.focus(); input.select(); }, 0);
}
function deleteSession(s) {
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '删除会话?'));
box.appendChild(el('p', null, '「' + (s.title || s.id) + '」会被永久删除' +
(s.id === state.session ? ';这是当前会话,删除后会开一个新的。' : '。')));
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', 'danger', '删除'), {
onclick: async () => {
try {
await post('api/sessions/delete', { id: s.id });
closeModal();
toast('已删除', 'ok');
loadSessions();
} catch (e) { closeModal(); toastErr(e); }
},
}));
row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
function sessionMenu(anchor, s) {
openPop(anchor, (pop) => {
pop.appendChild(popRow({ icon: 'file', title: '重命名', onClick: () => renameSession(s) }));
if (s.id === state.session)
pop.appendChild(popRow({ icon: 'copy', title: '导出 Markdown', onClick: exportSession }));
pop.appendChild(popRow({ icon: 'close', title: '删除', danger: true, onClick: () => deleteSession(s) }));
}, { align: 'right' });
}
function renderSessionList() {
const box = $('sessions-list');
const q = ($('sess-search').value || '').trim().toLowerCase();
box.textContent = '';
const shown = sessionsCache.filter((s) => sessMatch(s, q)).slice(0, q ? 200 : 60);
for (const s of shown) {
const b = el('div', 'sess-item' + (s.id === sessionsCurrent ? ' on' : ''));
b.tabIndex = 0;
b.setAttribute('role', 'button');
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) || '')));
const more = el('button', 'more');
more.title = '更多';
more.appendChild(icon('more'));
more.onclick = (e) => { e.stopPropagation(); sessionMenu(more, s); };
b.appendChild(more);
b.title = s.id + ' · ' + s.messages + ' 条消息';
const open = async () => {
try { await openSession(s.id); closeDrawers(); } catch (e) { toastErr(e); }
};
b.onclick = open;
b.onkeydown = (e) => { if (e.key === 'Enter' && e.target === b) open(); };
b.oncontextmenu = (e) => { e.preventDefault(); sessionMenu(more, s); };
box.appendChild(b);
}
if (!shown.length)
box.appendChild(el('div', 'muted tiny', q ? '没有匹配的会话' : '还没有历史会话'));
/* 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 loadSessions() {
let data;
try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; }
sessionsCache = data.sessions || [];
sessionsCurrent = data.current || '';
renderSessionList();
}
async function showSessions() {
await loadSessions();
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '会话'));
const search = el('input', 'key-input');
search.placeholder = '搜索标题或 ID';
box.appendChild(search);
const list = el('div', 'sess-list');
box.appendChild(list);
const draw = () => {
list.textContent = '';
const q = search.value.trim().toLowerCase();
const shown = sessionsCache.filter((s) => sessMatch(s, q));
for (const s of shown) {
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 { closeModal(); await openSession(s.id); } catch (e) { toastErr(e); }
},
}));
row.appendChild(Object.assign(el('button', null, '重命名'), { onclick: () => renameSession(s) }));
row.appendChild(Object.assign(el('button', 'danger', '删除'), { onclick: () => deleteSession(s) }));
list.appendChild(row);
}
if (!shown.length) list.appendChild(el('div', 'muted tiny', q ? '没有匹配的会话' : '还没有历史会话'));
};
search.addEventListener('input', draw);
draw();
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);
setTimeout(() => search.focus(), 0);
}
/* --- 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: 'bolt', title: '命令面板', desc: 'Ctrl+P · 输入框里打 / 也行', onClick: showPalette }));
pop.appendChild(popRow({ icon: 'cpu', title: '扩展', desc: 'MCP、插件、skills、子代理', onClick: () => showExtensions() }));
pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') }));
pop.appendChild(popRow({ icon: 'refresh', title: '重新生成上一轮', desc: '删掉上一轮回答,用同一句话再问', onClick: () => retry() }));
pop.appendChild(popRow({ icon: 'file', title: '导出 Markdown', desc: '下载当前会话', onClick: exportSession }));
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: 'list', title: '思考过程默认展开', desc: '会记住,TUI 也用这个设置', on: !!state.show_thinking,
onClick: () => setThinking(),
}));
pop.appendChild(popRow({
icon: 'list', title: '展开全部工具输出', desc: '工具调用默认展开到参数和输出', on: detailsAll,
onClick: () => setDetails(),
}));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
pop.appendChild(popRow({ icon: 'alert', title: '快捷键与命令', desc: 'F1', onClick: showHelp }));
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: 'settings', title: '运行设置…', desc: '工具协议、步数、工具开关、确认项、沙箱、已保存的许可', onClick: showRunSettings }));
pop.appendChild(popRow({ icon: 'file', title: '工作区改动', desc: 'git status + diff', onClick: () => showDiff() }));
pop.appendChild(popRow({ icon: 'cpu', title: '扩展…', desc: 'MCP、插件、skills、子代理;信任工作区', onClick: () => showExtensions() }));
pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme }));
});
}
const PERM_ZH = { read: '只读', write: '写文件', shell: '命令', net: '联网', ask: '提问', meta: '内部' };
async function showRunSettings() {
let s, perms;
try { [s, perms] = await Promise.all([getJSON('api/settings'), getJSON('api/permissions')]); }
catch (e) { return toast('读取设置失败:' + errMsg(e)); }
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '运行设置'));
box.appendChild(el('p', 'muted tiny', '只对这次运行生效,和 TUI 里的 /protocol、/tools、/max-steps 一样;有轮次在跑时不能改。'));
const field = (label, input) => {
const f = el('label', 'field');
f.appendChild(el('span', null, label));
f.appendChild(input);
box.appendChild(f);
return input;
};
const select = (opts, cur) => {
const sel = el('select');
for (const o of opts) sel.appendChild(Object.assign(el('option', null, o), { value: o, selected: o === cur }));
return sel;
};
const proto = field('工具协议', select(s.protocols || [], s.protocol));
const steps = field('每轮步数', Object.assign(el('input'), { type: 'number', min: 1, max: 1000, value: s.max_steps }));
const prof = field('工具集', select(s.profiles || [], s.tool_profile));
const mode = field('工作模式', select(s.modes || [], s.mode));
box.appendChild(el('h4', 'sub', '需要确认(审批模式 ' + (s.approval || '-') + ')'));
const conf = {};
const confRow = el('div', 'checks');
for (const [k, label] of [['shell', '运行命令'], ['write', '改文件'], ['net', '联网']]) {
const l = el('label', 'check');
const cb = Object.assign(el('input'), { type: 'checkbox', checked: !!(s.confirm || {})[k] });
conf[k] = cb;
l.appendChild(cb);
l.appendChild(el('span', null, label));
confRow.appendChild(l);
}
box.appendChild(confRow);
box.appendChild(el('h4', 'sub', '工具开关'));
const toolBoxes = new Map();
const tl = el('div', 'tool-list');
for (const t of s.tools || []) {
const l = el('label', 'tool-row' + (t.on ? '' : ' dim'));
const cb = Object.assign(el('input'), { type: 'checkbox', checked: !t.off });
toolBoxes.set(t.name, cb);
l.appendChild(cb);
l.appendChild(el('code', null, t.name));
l.appendChild(el('span', 'perm', PERM_ZH[t.perm] || t.perm));
const desc = el('span', 'desc', (t.owner ? '[' + t.owner + '] ' : '') + (t.desc || ''));
desc.title = desc.textContent;
l.appendChild(desc);
tl.appendChild(l);
}
box.appendChild(tl);
const sb = s.sandbox || {};
box.appendChild(el('h4', 'sub', '命令沙箱'));
box.appendChild(el('p', sb.active ? 'ok-text tiny' : 'warn-text tiny', sb.active
? '已启用:' + sb.kind + '(命令只能写工作区和临时目录)'
: '不可用:按规则审批命令(' + (sb.kind || 'none') + ')'));
if (sb.labels) box.appendChild(el('pre', 'step-out', sb.labels));
box.appendChild(el('h4', 'sub', '已保存的"总是允许"'));
const rules = el('div', 'tiny');
const drawRules = (p) => {
rules.textContent = '';
const tools = p.tools || [], pre = p.shell_prefixes || [];
rules.appendChild(el('div', null, '工具:' + (tools.length ? tools.join('、') : '(无)')));
rules.appendChild(el('div', null, '命令前缀:' + (pre.length ? pre.map((x) => '`' + x + '`').join('、') : '(无)')));
rules.appendChild(el('div', 'muted', '保存在 ' + (p.file || '.agent/permissions.json')));
};
drawRules(perms);
box.appendChild(rules);
const clear = Object.assign(el('button', 'link danger-text', '全部清除'), {
onclick: async () => {
try {
await post('api/permissions', undefined, 'DELETE');
drawRules(await getJSON('api/permissions'));
toast('已清除保存的许可规则', 'ok');
} catch (e) { toastErr(e); }
},
});
box.appendChild(clear);
const err = el('div', 'key-err');
box.appendChild(err);
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', 'ok', '保存'), {
onclick: async () => {
const body = {};
if (proto.value !== s.protocol) body.protocol = proto.value;
if (+steps.value !== s.max_steps) body.max_steps = +steps.value;
if (prof.value !== s.tool_profile) body.tool_profile = prof.value;
if (mode.value !== s.mode) body.mode = mode.value;
const c = {};
for (const k of Object.keys(conf)) if (conf[k].checked !== !!(s.confirm || {})[k]) c[k] = conf[k].checked;
if (Object.keys(c).length) body.confirm = c;
const tools = {};
for (const t of s.tools || []) {
const on = toolBoxes.get(t.name).checked;
if (on === !!t.off) tools[t.name] = on;
}
if (Object.keys(tools).length) body.tools = tools;
if (!Object.keys(body).length) return closeModal();
try {
await post('api/settings', body);
closeModal();
toast('设置已更新', 'ok');
} catch (e) { err.textContent = errMsg(e); }
},
}));
row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal }));
box.appendChild(row);
openModal(box, true);
}
async function copyText(t) {
try {
await navigator.clipboard.writeText(t);
$('status').textContent = '已复制:' + t;
} catch { $('status').textContent = '复制失败'; }
}
/* --- composer ------------------------------------------------------------ */
function syncSend() {
const v = $('input').value;
/* commands also work while a turn runs (/stop, /help, …) */
$('btn-send').disabled = !v.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)); }
}
let editing = false; /* the composer holds the last prompt, to replace it */
function setEditing(on) {
editing = on;
$('edit-bar').hidden = !on;
}
function cancelEdit() {
setEditing(false);
$('input').value = '';
autoGrow();
syncSend();
}
function editLast(node) {
if (busy) return toast('等这一轮结束再编辑');
const body = node.querySelector('.body');
const input = $('input');
input.value = body ? body.textContent : '';
setEditing(true);
autoGrow();
syncSend();
input.focus();
}
function freshTurn(text, srcs) {
sealProc();
sealLive();
revokeAll(shownPreviews);
shownPreviews = srcs || [];
userBubble(text, shownPreviews);
stepIndex = new Map();
files = new Map();
turnErr = null;
lastTurn = null;
renderFilesPanel();
turnStart = Date.now();
}
/* drop the newest prompt and everything after it from the page, as the
* server does with the session */
function cutLastTurn() {
const prompts = log.querySelectorAll('.msg[data-prompt]');
const p = prompts[prompts.length - 1];
if (!p) return '';
const body = p.querySelector('.body');
const text = body ? body.textContent : '';
while (p.nextSibling) p.nextSibling.remove();
p.remove();
return text;
}
/* the page is cut before the request: the new turn's events can arrive
* before its reply does */
async function retry(text) {
if (busy) return toast('等这一轮结束再重试');
if (!log.querySelector('.msg[data-prompt]')) return toast('没有可以重试的消息');
const old = cutLastTurn();
freshTurn(text !== undefined ? text : old);
try {
await post('api/retry', text === undefined ? {} : { text });
if (busy) $('status').textContent = text === undefined ? '重新生成中…' : '已重发…';
} catch (e) {
toast('重试失败:' + errMsg(e));
turnStart = 0;
loadSession().catch(toastErr);
}
}
async function send(text) {
const input = $('input');
const raw = text !== undefined ? text : input.value;
const body = raw.trim();
if (!body) return;
if (raw.startsWith('/') && !editing) {
const r = runSlash(body);
if (r === 'keep') return;
if (r) {
if (text === undefined) input.value = '';
autoGrow();
syncSend();
return;
}
}
if (busy) {
try {
await post('api/task', { message: body });
if (text === undefined) input.value = '';
autoGrow(); syncSend();
$('status').textContent = '正在应用最新指令,已停止冲突的待执行操作';
} catch (e) { toastErr(e); }
return;
}
if (text === undefined) input.value = '';
autoGrow();
syncSend();
if (editing) {
setEditing(false);
return retry(body);
}
/* the images go out with this prompt: draw them now from the local copies */
const pend = (state.pending_images || []).length;
let srcs = [];
if (pend && pendingPreviews.length === pend) {
srcs = pendingPreviews;
pendingPreviews = [];
}
freshTurn(body, srcs);
try {
await post('api/turn', { text: body });
if (busy) $('status').textContent = '已开始…';
} catch (e) {
if (srcs.length && !pendingPreviews.length && shownPreviews === srcs) {
pendingPreviews = srcs; /* still waiting on the server: keep the thumbnails */
shownPreviews = [];
renderPending();
}
bubble('error').body.textContent = errMsg(e);
}
}
async function exportSession() {
try {
const r = await api('api/export');
const md = await readReply(r);
const a = el('a');
a.href = URL.createObjectURL(new Blob([md], { type: 'text/markdown' }));
a.download = (state.session || 'session') + '.md';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
} catch (e) { toast('导出失败:' + errMsg(e)); }
}
/* --- commands: "/name args" in the composer, Ctrl+P, F1 ------------------- */
async function newSession() {
try { await post('api/sessions/new', {}); } catch (e) { return toastErr(e); }
await loadSession();
await loadSessions();
closeDrawers();
}
function setComposer(text) {
const input = $('input');
input.value = text;
autoGrow();
syncSend();
input.focus();
input.setSelectionRange(text.length, text.length);
slashSuggest();
}
function cycleApproval() {
const keys = APPROVALS.map((a) => a.key);
const next = keys[(keys.indexOf(state.approval || 'auto') + 1) % keys.length];
setApproval(next);
}
function setApproval(a) {
if (!a) return approvalPopover($('chip-approval'));
const ap = APPROVALS.find((x) => x.key === a);
if (!ap) return toast('审批模式只能是 ask / auto / yolo');
post('api/config', { approval: a })
.then(() => toast('审批:' + ap.title + ' · ' + ap.desc, a === 'yolo' ? 'warn' : 'ok'))
.catch(toastErr);
}
function setEffort(a) {
if (!a) return modelPopover($('chip-model'));
if (!EFFORTS.some((x) => x.key === a)) return toast('推理强度只能是 low / medium / high / max');
post('api/config', { effort: a })
.then((r) => toast(r && r.warning ? r.warning : '推理强度 → ' + a, r && r.warning ? 'warn' : 'ok'))
.catch(toastErr);
}
function setPlan(on) {
post('api/config', { plan_mode: on })
.then(() => toast(on ? '计划模式:只读,先出方案' : '构建模式:可以改文件', 'ok'))
.catch(toastErr);
}
/* "on" / "off" / anything else flips */
const onOffArg = (a, cur) => (a === 'on' ? true : a === 'off' ? false : !cur);
function setThinking(a) {
const on = onOffArg(a, !!state.show_thinking);
post('api/config', { show_thinking: on })
.then(() => toast(on ? '思考过程默认展开' : '思考过程默认收起', 'ok'))
.catch(toastErr);
}
function setDetails(a) {
detailsAll = onOffArg(a, detailsAll);
localStorage.setItem('cst.details', detailsAll ? '1' : '0');
for (const d of log.querySelectorAll('details.step-line')) d.open = detailsAll;
toast(detailsAll ? '工具输出全部展开' : '工具输出全部收起', 'ok');
}
async function providerCmd(a) {
if (!a) return showProviders();
let d;
try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); }
const p = (d.providers || []).find((x) => x.name.toLowerCase() === a.toLowerCase());
if (!p) return toast('没有叫 ' + a + ' 的提供商(/provider 打开列表)');
switchProvider(p);
}
function renameCurrent(a) {
if (a) {
return post('api/sessions/rename', { title: a })
.then(() => { toast('已重命名', 'ok'); loadSessions(); })
.catch(toastErr);
}
renameSession(sessionsCache.find((s) => s.id === state.session) || { id: state.session || '', title: '' });
}
function editLastPrompt() {
const ps = log.querySelectorAll('.msg[data-prompt]');
if (!ps.length) return toast('还没有可以编辑的消息');
editLast(ps[ps.length - 1]);
}
async function copyLastAnswer() {
const as = log.querySelectorAll('.msg.assistant .body');
if (!as.length) return toast('还没有回答');
try {
await navigator.clipboard.writeText(as[as.length - 1].innerText);
toast('已复制最后一条回答', 'ok');
} catch { toast('浏览器不允许写剪贴板'); }
}
/* /init and /skill: the server builds the prompt and starts the turn */
async function startCmdTurn(label, path, body) {
if (busy) return toast('等这一轮结束再试');
freshTurn(label);
try {
await post(path, body || {});
if (busy) $('status').textContent = '已开始…';
} catch (e) {
toast(errMsg(e));
turnStart = 0;
loadSession().catch(toastErr);
}
}
function runSkill(a) {
if (!a) return showExtensions('skills');
const name = a.split(/\s+/)[0];
startCmdTurn('/skill ' + a, 'api/skill', { name, task: a.slice(name.length).trim() });
}
/* completions for the argument; null = none (or still loading) */
let extCache = null, providersCache = null, completing = false;
const fetched = new Set(); /* one try per source: a failed fetch is not retried per keystroke */
function fetchOnce(key, load) {
if (completing || fetched.has(key)) return;
fetched.add(key);
completing = true;
load().catch(() => {}).finally(() => { completing = false; slashSuggest(); });
}
const ON_OFF = () => [{ value: 'on', desc: '开' }, { value: 'off', desc: '关' }];
function completeSkill(arg) {
if (/\s/.test(arg)) return null;
if (!extCache) {
fetchOnce('ext', async () => { try { extCache = await getJSON('api/extensions'); } catch { extCache = { skills: [] }; } });
return null;
}
return (extCache.skills || []).map((s) => ({ value: s.name, desc: s.desc }));
}
function completeModel() {
if (!modelsCache || modelsCache.provider !== state.provider) {
fetchOnce('models:' + state.provider, () => fetchModels(false));
return null;
}
return ((modelsCache.data || {}).models || []).map((m) => ({ value: m.id, desc: m.label || '' }));
}
function completeProvider() {
if (!providersCache) {
fetchOnce('providers', async () => {
try { providersCache = (await getJSON('api/providers')).providers || []; } catch { providersCache = []; }
});
return null;
}
return providersCache.map((p) => ({ value: p.name, desc: p.api_base }));
}
const COMMANDS = [
{ name: 'help', aliases: ['keys'], desc: '快捷键和命令列表', key: 'F1', run: () => showHelp() },
{ name: 'new', aliases: ['clear'], desc: '新建会话', run: newSession },
{ name: 'sessions', desc: '会话列表:搜索、切换、重命名、删除', key: 'Ctrl+K', run: () => showSessions() },
{ name: 'rename', args: '[标题]', desc: '重命名当前会话(不带标题打开对话框)', run: renameCurrent },
{ name: 'retry', desc: '重新生成上一轮', run: () => retry() },
{ name: 'edit', desc: '编辑最后一句再发', run: editLastPrompt },
{ name: 'stop', desc: '停止当前一轮', key: 'Esc', run: () => post('api/cancel', {}).catch(toastErr) },
{ name: 'undo', desc: '撤销上一次写入', run: undo },
{ name: 'compact', desc: '把旧消息压成摘要,腾出上下文', run: compact },
{ name: 'export', desc: '下载当前会话的 Markdown', run: exportSession },
{ name: 'copy', desc: '复制最后一条回答', run: copyLastAnswer },
{ name: 'diff', args: '[文件]', desc: '工作区改动(git status + diff)', run: (a) => showDiff(a || undefined) },
{ name: 'model', args: '[模型]', desc: '换模型(不带参数打开列表)', complete: completeModel,
run: (a) => (a ? setModel(a) : modelPopover($('chip-model'))) },
{ name: 'effort', args: '[low|medium|high|max]', desc: '推理强度',
complete: () => EFFORTS.map((e) => ({ value: e.key, desc: e.title + ' · ' + e.desc })), run: setEffort },
{ name: 'provider', args: '[名字]', desc: '切换提供商(不带参数打开列表)', complete: completeProvider, run: providerCmd },
{ name: 'key', desc: '更换当前提供商的 API key', run: changeKey },
{ name: 'approval', args: '[ask|auto|yolo]', desc: '审批权限', key: 'Shift+Tab',
complete: () => APPROVALS.map((a) => ({ value: a.key, desc: a.title + ' · ' + a.desc })), run: setApproval },
{ name: 'plan', desc: '计划模式:只读,先出方案', run: () => setPlan(true) },
{ name: 'build', desc: '构建模式:可以改文件', run: () => setPlan(false) },
{ name: 'settings', aliases: ['protocol', 'tools', 'max-steps', 'permissions'],
desc: '运行设置:工具协议、步数、工具开关、确认项、沙箱、许可规则', run: () => showRunSettings() },
{ name: 'init', desc: '让 agent 分析仓库,写一份 AGENTS.md', run: () => startCmdTurn('/init', 'api/init') },
{ name: 'skill', args: '<名字> [任务]', desc: '带着某个 skill 开始一轮', complete: completeSkill, more: true, run: runSkill },
{ name: 'skills', desc: '查看可用的 skills', run: () => showExtensions('skills') },
{ name: 'extensions', aliases: ['mcp', 'plugins', 'agents'], desc: 'MCP、插件、skills、子代理;信任工作区',
run: (a, name) => showExtensions(name === 'extensions' ? undefined : name) },
{ name: 'task', aliases: ['jobs'], desc: '当前目标、执行控制和异步作业', run: showTaskBoard },
{ name: 'thinking', args: '[on|off]', desc: '思考过程默认展开/收起(会记住)', complete: ON_OFF, run: setThinking },
{ name: 'details', args: '[on|off]', desc: '工具输出全部展开/收起(会记住)', complete: ON_OFF, run: setDetails },
{ name: 'theme', desc: '切换深色/浅色', run: toggleTheme },
{ name: 'palette', desc: '命令面板', key: 'Ctrl+P', run: () => showPalette() },
];
const findCmd = (name) => COMMANDS.find((c) => c.name === name || (c.aliases || []).includes(name));
function invokeCmd(c, arg, name) {
hideSlash();
try {
const r = c.run(arg || '', name || c.name);
if (r && r.catch) r.catch(toastErr);
} catch (e) { toastErr(e); }
}
/* true = ran it, 'keep' = an unknown command (left in the box), false = not
* a command at all ("/usr/bin 是什么" goes to the model) */
function runSlash(line) {
const m = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(line);
if (!m) return false;
const name = m[1].toLowerCase();
const c = findCmd(name);
if (!c) {
toast('没有 /' + m[1] + ' 这个命令。F1 看全部命令;想当普通消息发,开头加个空格。', 'warn');
return 'keep';
}
invokeCmd(c, (m[2] || '').trim(), name);
return true;
}
/* the list above the composer: command names, then argument values */
let slashItems = [], slashSel = 0;
function hideSlash() {
$('slash').hidden = true;
slashItems = [];
}
function slashSuggest() {
const v = $('input').value;
const m = /^\/([^\s/]*)(?: ([^\n]*))?$/.exec(v);
if (!m) return hideSlash();
const name = m[1].toLowerCase();
let items;
if (m[2] === undefined) {
items = COMMANDS
.filter((c) => c.name.startsWith(name) || (c.aliases || []).some((a) => a.startsWith(name)))
.map((c) => ({ cmd: c, label: '/' + c.name, hint: c.args || '', desc: c.desc }));
} else {
const c = findCmd(name);
const opts = c && c.complete ? c.complete(m[2]) : null;
if (!opts) return hideSlash();
const q = m[2].toLowerCase();
items = opts
.filter((o) => !q || o.value.toLowerCase().includes(q))
.slice(0, 30)
.map((o) => ({ cmd: c, value: o.value, label: o.value, hint: '', desc: o.desc || '' }));
}
const prev = slashItems[slashSel];
slashItems = items;
/* what was typed in full wins, then the row the arrows were on */
const exact = m[2] === undefined
? items.findIndex((i) => i.cmd.name === name || (i.cmd.aliases || []).includes(name))
: items.findIndex((i) => i.value === m[2]);
const keep = prev ? items.findIndex((i) => i.label === prev.label) : -1;
slashSel = exact >= 0 ? exact : keep >= 0 ? keep : 0;
if (!items.length) return hideSlash();
renderSlash();
}
function renderSlash() {
const box = $('slash');
box.textContent = '';
slashItems.forEach((it, i) => {
const b = el('button', 'slash-item' + (i === slashSel ? ' on' : ''));
b.type = 'button';
b.appendChild(el('code', null, it.label));
if (it.hint) b.appendChild(el('span', 'hint', it.hint));
b.appendChild(el('span', 'desc', it.desc));
/* mousedown keeps the focus in the composer */
b.onmousedown = (e) => { e.preventDefault(); slashSel = i; acceptSlash(it, true); };
box.appendChild(b);
});
box.hidden = false;
const on = box.children[slashSel];
if (on) on.scrollIntoView({ block: 'nearest' });
}
/* Enter runs the pick, Tab only completes the text */
function acceptSlash(it, run) {
if (!it) return;
const c = it.cmd;
if (it.value === undefined) {
if (run && !(c.more && c.complete)) {
$('input').value = '';
autoGrow();
syncSend();
return invokeCmd(c, '');
}
return setComposer('/' + c.name + (c.args ? ' ' : ''));
}
const line = '/' + c.name + ' ' + it.value;
if (run && !c.more) {
$('input').value = '';
autoGrow();
syncSend();
return invokeCmd(c, it.value);
}
setComposer(line + (c.more ? ' ' : ''));
if (c.more) hideSlash();
}
function cmdLabel(c) {
return '/' + c.name + (c.args ? ' ' + c.args : '');
}
function showPalette() {
hideSlash();
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '命令面板'));
const input = el('input', 'key-input');
input.placeholder = '输入命令或说明过滤,↑↓ 选择,Enter 执行';
input.spellcheck = false;
box.appendChild(input);
const list = el('div', 'palette-list');
box.appendChild(list);
let items = [], sel = 0;
const pick = (c) => {
closeModal();
/* a required argument goes back to the composer to be filled in */
if (c.args && c.args.startsWith('<')) return setComposer('/' + c.name + ' ');
invokeCmd(c, '');
};
const render = () => {
const q = input.value.trim().toLowerCase().replace(/^\//, '');
items = COMMANDS.filter((c) => !q || c.name.includes(q) || c.desc.toLowerCase().includes(q) ||
(c.aliases || []).some((a) => a.includes(q)));
sel = Math.min(sel, Math.max(0, items.length - 1));
list.textContent = '';
items.forEach((c, i) => {
const b = el('button', 'palette-item' + (i === sel ? ' on' : ''));
b.appendChild(el('code', null, cmdLabel(c)));
b.appendChild(el('span', 'desc', c.desc));
if (c.key) b.appendChild(el('kbd', null, c.key));
b.onclick = () => pick(c);
list.appendChild(b);
});
if (!items.length) list.appendChild(el('div', 'muted tiny', '没有匹配的命令'));
const on = list.children[sel];
if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' });
};
input.addEventListener('input', () => { sel = 0; render(); });
input.addEventListener('keydown', (e) => {
const n = Math.max(1, items.length);
if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % n; render(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel + n - 1) % n; render(); }
else if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); if (items[sel]) pick(items[sel]); }
});
openModal(box);
render();
setTimeout(() => input.focus(), 0);
}
const KEYS_HELP = [
['Enter', '发送'],
['Shift+Enter', '换行'],
['/', '输入命令:Tab 补全,↑↓ 选择,Enter 执行'],
['Ctrl+P', '命令面板'],
['Ctrl+K', '会话列表'],
['Shift+Tab', '切换审批:每次询问 → 自动 → 完全放行'],
['Esc', '关闭弹窗 / 取消编辑 / 停止当前一轮'],
['Ctrl+B · Ctrl+J', '收起或展开左栏 · 右栏'],
['y · n · 1-4', '回答许可请求(焦点不在输入框时)'],
['F1 · ?', '这个帮助'],
];
function showHelp() {
hideSlash();
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '快捷键'));
const keys = el('div', 'keys-grid');
for (const [k, d] of KEYS_HELP) {
keys.appendChild(el('kbd', null, k));
keys.appendChild(el('span', null, d));
}
box.appendChild(keys);
box.appendChild(el('h4', 'sub', '命令(在输入框里打,或者 Ctrl+P)'));
const cmds = el('div', 'keys-grid cmds');
for (const c of COMMANDS) {
cmds.appendChild(el('code', null, cmdLabel(c)));
cmds.appendChild(el('span', null, c.desc + ((c.aliases || []).length ? '(也可以 /' + c.aliases.join(' /') + ')' : '')));
}
box.appendChild(cmds);
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box, true);
}
/* --- extensions: MCP servers, plugins, skills, sub-agents ----------------- */
const MCP_ZH = { starting: '启动中', ready: '就绪', failed: '失败', exited: '已退出', off: '未启动' };
const RUN_ZH = { done: '完成', error: '出错', cancelled: '已取消', running: '运行中' };
function reportZh(r) {
return String(r || '')
.replace(/(\d+) skills?/, '$1 个 skill')
.replace(/(\d+) plugins? \((\d+) tools?\)/, '$1 个插件($2 个工具)')
.replace(/(\d+) MCP servers?/, '$1 个 MCP 服务器')
.replace(/untrusted workspace: \/plugins trust/, '工作区未信任,它自带的插件和 MCP 没有加载');
}
async function showTaskBoard() {
const box = el('div');
box.appendChild(el('h3', null, '任务控制 / 异步作业'));
const status = el('p', 'muted tiny'); box.appendChild(status);
const controls = el('div', 'buttons left'); box.appendChild(controls);
const jobs = el('div'); box.appendChild(jobs);
const message = el('textarea'); message.placeholder = '执行中直接补充或纠正指令'; message.rows = 3; message.style.width = '100%'; box.appendChild(message);
box.appendChild(Object.assign(el('button', null, '立即应用指令'), { onclick: async () => {
if (!message.value.trim()) return;
try { await post('api/task', { message: message.value }); message.value = ''; } catch (e) { toastErr(e); }
} }));
const delta = el('pre'); delta.style.whiteSpace = 'pre-wrap'; box.appendChild(delta);
let updating = false;
const update = async () => {
if (updating || !box.isConnected) return;
updating = true;
try {
const state = await getJSON('api/task');
status.textContent = '版本 ' + state.revision + ' · ' + state.status + ' · ' + (state.cancel_source || '') + '\n' + (state.goal || '');
controls.textContent = '';
const control = (label, body) => controls.appendChild(Object.assign(el('button', null, label), { onclick: async () => { try { await post('api/task', body); await update(); } catch (e) { toastErr(e); } } }));
control(state.read_only ? '允许修改' : '只查看', { read_only: !state.read_only });
control(state.delegation ? '主 agent 亲自执行' : '允许委派', { delegation: !state.delegation });
control(state.paused ? '恢复' : '暂停', { paused: !state.paused });
jobs.textContent = '';
for (const job of state.jobs || []) {
const row = el('div', 'ext-row');
row.appendChild(el('h4', null, job.job_id + ' · ' + job.kind + ' · ' + job.status));
row.appendChild(el('p', 'muted tiny', (job.cancel_source || '') + ' · ' + (job.resource || '')));
row.appendChild(el('pre', null, (job.output || '').slice(-3000)));
if (['running', 'queued', 'running_protected'].includes(job.status)) row.appendChild(Object.assign(el('button', 'danger', job.status === 'running_protected' ? '强制停止当前操作' : '取消'), {
onclick: async () => { try { await post('api/jobs/cancel', { job_id: job.job_id, force: job.status === 'running_protected' }); } catch (e) { toastErr(e); } },
}));
if ((job.resource || '').includes('/.agent/v2/wt-')) {
for (const integrate of [false, true]) row.appendChild(Object.assign(el('button', null, integrate ? '集成修改' : '预览修改'), {
onclick: async () => { try { const r = await post('api/worktree', { worktree: job.resource, integrate }); delta.textContent = r.output; } catch (e) { toastErr(e); } },
}));
}
jobs.appendChild(row);
}
} catch (e) { status.textContent = errMsg(e); } finally { updating = false; }
};
openModal(box, true); await update();
const timer = setInterval(() => { if (!box.isConnected) clearInterval(timer); else update(); }, 500);
}
async function showExtensions(focus) {
let d;
try { d = extCache = await getJSON('api/extensions'); }
catch (e) { return toast('读取扩展失败:' + errMsg(e)); }
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '扩展'));
if (d.worker_budget) {
const b = d.worker_budget;
box.appendChild(el('p', 'muted tiny',
'本回合子任务 ' + b.delegations + '/' + b.max_tasks +
' · 请求 ' + b.requests + '/' + b.max_requests +
' · 缓存 ' + (b.cached_tokens == null ? '未知' : b.cached_tokens + '/' + b.prompt_tokens) +
(b.cost == null ? '' : ' · ' + b.currency + b.cost.toFixed(6))));
}
if (!d.enabled)
box.appendChild(el('p', 'warn-text tiny', '当前工具集是 minimal:MCP、插件、skills 和子代理都不加载。在运行设置里换回 default,再点"重新加载"。'));
box.appendChild(el('p', 'muted tiny', (d.report ? '已加载:' + reportZh(d.report) + '。' : '没有加载任何扩展。') +
(d.trusted
? '这个工作区已信任:它的 .agent/plugins 和 .agent/mcp.json 会加载。'
: '这个工作区未信任:它的 .agent/plugins 和 .agent/mcp.json 不会加载(配置目录里的照常加载)。')));
const acts = el('div', 'buttons left');
acts.appendChild(Object.assign(el('button', null, '任务控制与作业'), { onclick: showTaskBoard }));
acts.appendChild(Object.assign(el('button', null, '模型与 worker 配置'), {
onclick: async () => {
try {
const config = await getJSON('api/workers/config');
const editor = el('div');
editor.appendChild(el('h3', null, 'Worker 配置'));
editor.appendChild(el('p', null, 'profiles 定义 provider/model/effort,roles 将角色绑定到档案;预算为 max_steps/max_tasks/max_requests。orchestrate 开启编排模式。密钥使用已有平台凭据。'));
const text = el('textarea');
text.rows = 20; text.style.width = '100%';
text.value = JSON.stringify(config, null, 2);
editor.appendChild(text);
editor.appendChild(Object.assign(el('button', null, '保存'), {
onclick: async () => {
try {
await post('api/workers/config', JSON.parse(text.value));
toast('Worker 配置已保存', 'ok');
showExtensions('agents');
} catch (e) { toastErr(e); }
},
}));
openModal(editor, true);
} catch (e) { toastErr(e); }
},
}));
const act = async (path, body, done) => {
try {
const r = await post(path, body);
toast(done + (r.report ? ':' + reportZh(r.report) : ''), 'ok');
showExtensions(focus);
} catch (e) { toastErr(e); }
};
acts.appendChild(Object.assign(el('button', null, '重新加载'), {
title: '停掉 MCP 服务器和插件,重新读取(有轮次在跑时不行)',
onclick: () => act('api/extensions/reload', {}, '已重新加载'),
}));
acts.appendChild(Object.assign(el('button', d.trusted ? 'danger' : 'ok', d.trusted ? '取消信任这个工作区' : '信任这个工作区'), {
title: '信任后会运行工作区里 .agent/plugins 和 .agent/mcp.json 指定的程序',
onclick: () => act('api/extensions/trust', { on: !d.trusted }, d.trusted ? '已取消信任' : '已信任'),
}));
box.appendChild(acts);
const secs = {};
const section = (key, title, rows, empty, draw) => {
const h = el('h4', 'sub', title + ' · ' + rows.length);
secs[key] = h;
box.appendChild(h);
const list = el('div', 'ext-list');
if (!rows.length) list.appendChild(el('div', 'muted tiny', empty));
for (const r of rows) list.appendChild(draw(r));
box.appendChild(list);
};
const row = (name, badge, badgeCls, meta, sub, button) => {
const r = el('div', 'ext-row');
const m = el('div', 'pm');
const t = el('div', 'ext-title');
t.appendChild(el('b', null, name));
if (badge) t.appendChild(el('span', 'ext-badge ' + (badgeCls || ''), badge));
if (meta) t.appendChild(el('span', 'muted tiny', meta));
m.appendChild(t);
if (sub) m.appendChild(el('div', 'tiny ' + (badgeCls === 'bad' ? 'danger-text' : 'muted'), sub));
r.appendChild(m);
if (button) r.appendChild(button);
return r;
};
const useBtn = (label, text) => Object.assign(el('button', null, label), {
onclick: () => { closeModal(); setComposer(text); },
});
section('mcp', 'MCP 服务器', d.mcp || [],
'没有 MCP 服务器。在配置目录的 mcp.json(或已信任工作区的 .agent/mcp.json)里写 {"mcpServers": {"名字": {"command": …, "args": […]}}},再点重新加载。',
(s) => row(s.name, MCP_ZH[s.state] || s.state,
s.state === 'ready' ? 'ok' : s.state === 'failed' || s.state === 'exited' ? 'bad' : '',
(s.state === 'ready' ? s.tools + ' 个工具(mcp__' + s.name + '__*)· ' : '') + s.source,
s.error || s.command));
section('plugins', '插件', d.plugins || [],
'没有插件。在配置目录的 plugins/(或已信任工作区的 .agent/plugins/)下放 <目录>/plugin.json,再点重新加载。',
(p) => row(p.name, p.error ? '出错' : p.tools + ' 个工具', p.error ? 'bad' : '',
p.source + (p.mcp ? ' · MCP' : '') + (p.skills ? ' · skills' : ''), p.error || p.desc));
section('skills', 'Skills', d.skills || [],
'没有 skill。在工作区的 .agent/skills/ 或配置目录的 skills/ 下放 <目录>/SKILL.md(frontmatter 写 name 和 description),再点重新加载。',
(s) => row(s.name, null, null, s.source, s.desc, useBtn('使用', '/skill ' + s.name + ' ')));
section('agents', '子代理角色', d.roles || [],
d.task_tool ? '没有角色。' : 'task 工具没开(工具集是 minimal)。',
(r) => row(r.name, r.read_only ? '只读' : null, null, r.source + ' · ' + (r.profile || r.model || '继承主模型'), r.desc,
d.task_tool ? useBtn('交给它', '用 ' + r.name + ' 子代理(task 工具)来 ') : null));
section('runs', '本会话的子代理运行', d.runs || [], '这个会话还没有用过子代理。',
(r) => row(r.role + ' · ' + r.file, RUN_ZH[r.status] || r.status,
r.status === 'done' ? 'ok' : r.status === 'error' ? 'bad' : '',
(r.provider || '') + '/' + (r.model || '') + ' · ' +
r.requests + '/' + r.max_steps + ' 请求 · ' + r.tools + ' 次工具 · ' +
'缓存 ' + (r.cached_tokens == null ? '未知' : r.cached_tokens + '/' + r.prompt_tokens + ' tokens') +
' · ' + fmtSecs(r.ms || 0) + (r.cost == null ? '' : ' · ' + r.currency + r.cost.toFixed(6)), r.desc,
r.status === 'running' ? Object.assign(el('button', 'danger', '取消'), {
onclick: () => act('api/workers/cancel', { worker_id: r.file }, '已请求取消'),
}) : useBtn('续接', '继续 worker_id=' + r.file + ' 的任务:')));
const close = el('div', 'buttons');
close.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(close);
openModal(box, true);
const target = secs[focus === 'skill' ? 'skills' : focus];
if (target) setTimeout(() => target.scrollIntoView({ block: 'start' }), 0);
}
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; }
pendingPreviews.push({ src: URL.createObjectURL(file), name: file.name || 'pasted.png' });
renderPending();
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 = newSession;
$('btn-refresh-sessions').onclick = () => loadSessions();
$('sess-search').addEventListener('input', renderSessionList);
$('btn-diff').onclick = () => showDiff();
$('btn-edit-cancel').onclick = cancelEdit;
$('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.isComposing) return;
if (!$('slash').hidden && slashItems.length) {
const n = slashItems.length;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
slashSel = (slashSel + (e.key === 'ArrowDown' ? 1 : n - 1)) % n;
renderSlash();
return;
}
if ((e.key === 'Tab' && !e.shiftKey) || (e.key === 'Enter' && !e.shiftKey)) {
e.preventDefault();
acceptSlash(slashItems[slashSel], e.key === 'Enter');
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
hideSlash();
return;
}
}
if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); cycleApproval(); return; }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
$('input').addEventListener('input', () => { autoGrow(); syncSend(); slashSuggest(); });
$('input').addEventListener('blur', () => setTimeout(hideSlash, 150));
$('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;
$('lightbox').onclick = (e) => { if (e.target === $('lightbox')) closeLightbox(); };
$('lb-close').onclick = closeLightbox;
$('lb-prev').onclick = () => stepLightbox(-1);
$('lb-next').onclick = () => stepLightbox(1);
/* --- go ------------------------------------------------------------------ */
/* 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(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);