Files
Hermit/web/app.js
T
WHC 9bf7707912
ci / linux (clang) (push) Successful in 6m15s
ci / linux (gcc) (push) Successful in 7m14s
ci / musl (push) Successful in 7m14s
ci / windows-cross (push) Successful in 11m4s
v0.8.5: WebUI process timeline, Gemini-style images, aligned layout
Process timeline: a run of consecutive thinking and tool calls is one
block that folds to a single line ("思考 4 次 · 工具 6 次"; while running
it shows the current action) and opens into a compact timeline, one row
per thought or call. Visible text, a note or the next prompt closes the
block; history and the live stream follow the same rule. show_thinking
now also applies while streaming instead of forcing thoughts open.

Layout: the avatar gutter and the flex-wrap that dropped it under long
answers are gone, so answer, process blocks, notes and composer share one
left edge. The text column and composer are both 720px (--content); the
rail and run panel grow with clamp() on wide screens. A rebuild after
each turn no longer replays every entrance animation, and the turn time
survives it. Message actions are icon buttons and only appear under text.

Images: a prompt's images sit above the bubble, right-aligned (one keeps
its shape, several are 152px rounded tiles) and open in a lightbox (Esc,
arrow keys). Images uploaded from the page show as thumbnails over the
composer and appear on the prompt as soon as it is sent.

Also: ?theme= is no longer overridden by app.js, the user bubble is
visible in the light theme, and the narrow top bar no longer wraps.
2026-10-10 14:54:36 +08:00

2556 lines
104 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* CST-Agent WebUI client: HTTP + SSE against the engine in the same binary.
* No build step, no dependencies. The server renders Markdown to sanitized
* HTML; this file lays out the conversation, the tool lines, the approval
* gate and the anchored popovers. Protocol: docs/webui.md. */
'use strict';
/* --- token: from ?token= / #token=; the query parameter is appended to
* every request so EventSource (which cannot set headers) works as well,
* and a same-site cookie carries it for the <img> tags in rendered
* Markdown. --- */
const TOKEN = (() => {
const q = new URLSearchParams(location.search);
if (q.get('token')) return q.get('token');
return new URLSearchParams(location.hash.replace(/^#/, '')).get('token') || '';
})();
if (TOKEN) document.cookie = 'cst_token=' + encodeURIComponent(TOKEN) + '; path=/; SameSite=Strict';
const qs = (extra) => {
const p = new URLSearchParams(extra || {});
if (TOKEN) p.set('token', TOKEN);
const s = p.toString();
return s ? '?' + s : '';
};
function api(path, opt) {
if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
return fetch(path, opt);
}
/* the server answers errors as {"error": "..."}; status and body ride along
* so a caller can tell "needs a key" from "rejected key" */
class ApiError extends Error {
constructor(msg, status, data) { super(msg); this.status = status; this.data = data || {}; }
}
async function readReply(r) {
const text = await r.text();
let data = null;
try { data = text ? JSON.parse(text) : {}; } catch { data = null; }
if (!r.ok) throw new ApiError(errorText(r.status, (data && data.error) || text || r.statusText), r.status, data);
return data === null ? text : data;
}
async function getJSON(path) {
return readReply(await api(path));
}
async function post(path, body, method) {
const r = await api(path, {
method: method || 'POST',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return readReply(r);
}
/* engine messages that are still English, in the words the page uses */
const ERR_ZH = [
[/^busy: wait for the running turn/, '正在运行,等这一轮结束再试'],
[/^a turn is already running/, '已经有一轮在运行'],
[/^another change is running/, '另一项修改正在进行,稍后再试'],
[/^nothing to undo/, '没有可以撤销的写入'],
[/^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) + ' · ' : '') + 'CST-Agent';
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 · <<PY>": 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));
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;
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 = (busy && !v.startsWith('/')) || !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) 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: '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 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.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');
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.model ? ' · ' + 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.steps + ' 步 · ' + r.tools + ' 次工具 · ' + fmtSecs(r.ms || 0), r.desc));
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 <body> from the inline script in
* index.html, so nothing here has to re-derive them */
autoGrow();
syncSend();
renderFilesPanel();
connect();
loadSession().catch(toastErr);
loadSessions();
getJSON('api/state').then(applyState).catch(toastErr);
/* deep link: ?open=chip-approval / ?open=cost opens that popover once the
* state is in, so a screenshot or a bookmark can land on it directly */
const wantsOpen = new URLSearchParams(location.search).get('open');
if (wantsOpen) setTimeout(() => { const a = $(wantsOpen); if (a) a.click(); }, 900);