Sessions: rename, delete and search; /api/retry regenerates the last answer, editing a prompt resends from there; /api/export downloads the transcript as Markdown (cst_session_export_md). Session messages carry note/tool_result markers, so a reload rebuilds the tool steps with their arguments, output and exit status; subagent progress is shown. Diff and settings: /api/diff shows the real diff; /api/settings covers protocol, max_steps, tool switches and confirm; /api/permissions edits the rules; sandbox status is reported. Session rename no longer reads past a short title. Commands: slash commands with completion in the composer (/model, /provider, /new, /retry, /undo, /compact, /export, /diff, /init, /skill and more), a Ctrl+P palette, Shift+Tab cycles the approval mode, F1 or ? shows the keys. "Thinking expanded" is saved in the settings (show_thinking), "expand all tool output" in the browser. Extensions: GET /api/extensions lists MCP servers, plugins, skills, agent roles and subagent runs; POST /api/extensions/reload and /api/extensions/trust; a workspace switch reloads them. /api/init and /api/skill run on the server (cst_init_prompt shared with the TUI). Errors, statuses and [cst] notes are shown in Chinese with a readable reason instead of rc=; a failed turn's error survives the reload. Tests: scripts/e2e_webui_sessions.py, e2e_webui_workspace.py and e2e_webui_ext.py, run from e2e_webui.sh.
2355 lines
97 KiB
JavaScript
2355 lines
97 KiB
JavaScript
/* 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 steps = null; /* this turn's tool list */
|
||
let stepIndex = new Map(); /* tool call id -> step row */
|
||
let files = new Map(); /* this turn's file changes: path -> {add, del} */
|
||
let permReq = null;
|
||
let askReq = null;
|
||
let busy = false;
|
||
let turnStart = 0;
|
||
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);
|
||
}
|
||
function bubble(role, cls) {
|
||
const m = el('div', 'msg ' + (cls || role));
|
||
/* no avatar per message: the assistant reads as plain text in the column,
|
||
* the way the reference transcript does; only errors get a mark */
|
||
const g = el('div', 'gutter', role === 'error' ? '!' : '');
|
||
const b = el('div', 'body');
|
||
m.appendChild(g);
|
||
m.appendChild(b);
|
||
log.appendChild(m);
|
||
showEmpty(false);
|
||
scrollDown(true);
|
||
return { root: m, body: b };
|
||
}
|
||
function copyButton(text) {
|
||
const b = el('button', null, '复制');
|
||
b.onclick = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
b.textContent = '已复制';
|
||
setTimeout(() => { b.textContent = '复制'; }, 1200);
|
||
} catch { b.textContent = '复制失败'; }
|
||
};
|
||
return b;
|
||
}
|
||
function attachStrip(atts) {
|
||
const strip = el('div', 'attach-strip');
|
||
for (const a of atts) {
|
||
const img = el('img');
|
||
img.alt = a.name || 'image';
|
||
img.loading = 'lazy';
|
||
img.src = attachURL(a.rel);
|
||
strip.appendChild(img);
|
||
}
|
||
return strip;
|
||
}
|
||
/* reasoning belongs inside .body: as a sibling it becomes a second flex item
|
||
* and squeezes the answer into a narrow column */
|
||
function thinkInto(body, text, open) {
|
||
let d = body.querySelector(':scope > details.think');
|
||
let t;
|
||
if (!d) {
|
||
d = el('details', 'think');
|
||
d.appendChild(el('summary', null, '思考过程'));
|
||
t = el('div');
|
||
d.appendChild(t);
|
||
body.insertBefore(d, body.firstChild);
|
||
} else {
|
||
t = d.querySelector('div');
|
||
}
|
||
if (open) d.open = true;
|
||
t.textContent += text;
|
||
return d;
|
||
}
|
||
function actionsFor(root, text) {
|
||
const row = el('div', 'msg-actions');
|
||
row.appendChild(copyButton(text));
|
||
root.appendChild(row);
|
||
return row;
|
||
}
|
||
/* "[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 || '');
|
||
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';
|
||
if (isUser) steps = null; /* each prompt starts its own step list */
|
||
const text = m.text !== undefined ? m.text : (m.content || '');
|
||
if (isUser || text.trim() || m.reasoning || (m.attach && m.attach.length)) {
|
||
const b = bubble(isUser ? 'user' : 'assistant');
|
||
if (!isUser && m.html) b.body.innerHTML = m.html;
|
||
else b.body.textContent = text;
|
||
if (m.reasoning) thinkInto(b.body, m.reasoning, !!state.show_thinking);
|
||
if (m.attach && m.attach.length) b.body.insertBefore(attachStrip(m.attach), b.body.firstChild);
|
||
if (isUser) b.root.dataset.prompt = '1';
|
||
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(Object.assign(el('button', null, '编辑重发'), { onclick: () => 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(Object.assign(el('button', null, '重新生成'), { onclick: () => retry() }));
|
||
}
|
||
}
|
||
|
||
async function loadSession() {
|
||
const data = await getJSON('api/session');
|
||
log.textContent = '';
|
||
stepIndex = new Map();
|
||
files = new Map();
|
||
renderFilesPanel();
|
||
steps = null;
|
||
live = null;
|
||
for (const m of data.messages) renderMessage(m);
|
||
for (const d of log.querySelectorAll('details.turn-steps')) d.open = detailsAll;
|
||
steps = null;
|
||
stepIndex = new Map();
|
||
lastPromptActions();
|
||
if (turnErr && turnErr.session === state.session) {
|
||
if (turnErr.stopped) noteLine('已停止');
|
||
else bubble('error').body.textContent = turnErr.text;
|
||
}
|
||
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 --------------------------------------------------------- */
|
||
|
||
function ensureLive() {
|
||
if (live) return live;
|
||
const b = bubble('assistant');
|
||
b.body.classList.add('live');
|
||
/* the answer gets its own box: rewriting body.innerHTML would drop the
|
||
* reasoning <details> that thinkInto keeps at the top of body */
|
||
const out = el('div', 'live-html');
|
||
b.body.appendChild(out);
|
||
live = { root: b.root, body: b.body, out, html: '' };
|
||
return live;
|
||
}
|
||
function ensureSteps() {
|
||
if (steps) return steps;
|
||
const d = el('details', 'turn-steps');
|
||
d.open = true;
|
||
const s = el('summary', 'steps-sum', '本轮工具调用');
|
||
d.appendChild(s);
|
||
const box = el('div', 'steps-box');
|
||
d.appendChild(box);
|
||
log.appendChild(d);
|
||
steps = { root: d, sum: s, box, count: 0 };
|
||
return steps;
|
||
}
|
||
function phaseText(p) {
|
||
return { open: '准备中', ready: '待执行', run: '执行中', 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 c = ensureSteps();
|
||
const node = el('details', '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(name);
|
||
sum.appendChild(args);
|
||
sum.appendChild(phase);
|
||
const body = el('div', 'step-body');
|
||
node.appendChild(sum);
|
||
node.appendChild(body);
|
||
c.box.appendChild(node);
|
||
st = { root: node, sum, name, args, phase, body, t0: Date.now(), bad: false };
|
||
stepIndex.set(ev.id, st);
|
||
c.count++;
|
||
c.sum.textContent = '本轮工具调用 · ' + c.count + ' 次';
|
||
return st;
|
||
}
|
||
|
||
/* --- the run panel answers one question: which files moved, and by how many
|
||
* lines. Everything else about a call stays in the transcript where it
|
||
* happened, so the panel stays readable no matter how long a turn runs. */
|
||
function linesOf(s) {
|
||
return (typeof s === 'string' && s !== '') ? s.split('\n').length : 0;
|
||
}
|
||
function patchStat(text) {
|
||
let add = 0, del = 0;
|
||
for (const ln of String(text).split('\n')) {
|
||
if (ln.startsWith('+++') || ln.startsWith('---')) continue;
|
||
if (ln.charAt(0) === '+') add++;
|
||
else if (ln.charAt(0) === '-') del++;
|
||
}
|
||
return { add, del };
|
||
}
|
||
function statFromArgs(name, args) {
|
||
const a = args || {}, n = String(name || '');
|
||
const body = a.patch ?? a.diff ?? a.input;
|
||
if (/patch|diff/i.test(n) && typeof body === 'string') return patchStat(body);
|
||
if (/write|create/i.test(n) && typeof a.content === 'string')
|
||
return { add: linesOf(a.content), del: 0 };
|
||
if (/edit|replace|rename/i.test(n)) {
|
||
const old = a.old_string ?? a.old ?? a.find ?? a.old_text;
|
||
const nw = a.new_string ?? a.new ?? a.replace ?? a.new_text;
|
||
if (typeof nw === 'string' || typeof old === 'string')
|
||
return { add: linesOf(nw), del: linesOf(old) };
|
||
}
|
||
return null;
|
||
}
|
||
function noteFileChange(ev) {
|
||
const st = statFromArgs(ev.name, ev.args);
|
||
if (!st) return;
|
||
const a = ev.args || {};
|
||
const path = String(a.path || a.file_path || a.file || a.filename || '补丁');
|
||
const cur = files.get(path) || { add: 0, del: 0 };
|
||
cur.add += st.add;
|
||
cur.del += st.del;
|
||
files.set(path, cur);
|
||
renderFilesPanel();
|
||
}
|
||
function renderFilesPanel() {
|
||
const box = $('files-body');
|
||
if (!box) return;
|
||
box.textContent = '';
|
||
box.className = '';
|
||
if ($('files-count')) $('files-count').textContent = String(files.size);
|
||
if (!files.size) {
|
||
box.className = 'muted';
|
||
box.textContent = '本轮还没有改动文件。';
|
||
return;
|
||
}
|
||
for (const [p, v] of files) {
|
||
const row = el('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';
|
||
}
|
||
scrollDown(false);
|
||
}
|
||
function onToolResult(ev) {
|
||
const st = stepFor(ev);
|
||
st.root.classList.remove('run');
|
||
st.bad = !!ev.exit;
|
||
st.root.classList.toggle('bad', st.bad);
|
||
st.phase.textContent = st.bad ? ('失败 exit=' + ev.exit) : '完成';
|
||
st.body.textContent = '';
|
||
if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent));
|
||
if (ev.out) st.body.appendChild(el('pre', 'step-out', ev.out + (ev.truncated ? '\n… (截断)' : '')));
|
||
if (!st.bad) noteFileChange(ev);
|
||
scrollDown(false);
|
||
}
|
||
function turnMeta(ms) {
|
||
const wrap = el('div', 'turn-meta');
|
||
const d = el('details');
|
||
d.appendChild(el('summary', null, '用时 ' + fmtSecs(ms)));
|
||
d.appendChild(el('div', 'muted tiny', '从发出去到最后一个工具结束;模型延迟和工具耗时都算在里面。'));
|
||
wrap.appendChild(d);
|
||
log.appendChild(wrap);
|
||
}
|
||
|
||
/* --- model checklist ----------------------------------------------------- */
|
||
|
||
function todoRows(text) {
|
||
const rows = [];
|
||
for (const raw of String(text || '').split('\n')) {
|
||
let line = raw.replace(/\s+$/, '');
|
||
if (!line.trim()) continue;
|
||
let done = false, doing = false;
|
||
line = line.replace(/^\s*[-*+]\s*/, '').replace(/^\s*\d+[.)]\s*/, '');
|
||
if (/^\[[xX]\]/.test(line)) { done = true; line = line.replace(/^\[[xX]\]\s*/, ''); }
|
||
else line = line.replace(/^\[( |>)\]\s*/, '');
|
||
if (/^(✔|✅|\[done\])/i.test(line)) { done = true; line = line.replace(/^(✔|✅|\[done\])\s*/i, ''); }
|
||
else if (/^(▶|➡|\[now\]|\[doing\])/i.test(line)) { doing = true; line = line.replace(/^(▶|➡|\[now\]|\[doing\])\s*/i, ''); }
|
||
if (!line.trim()) continue;
|
||
rows.push({ text: line, done, doing });
|
||
}
|
||
return rows;
|
||
}
|
||
function renderTodo(text) {
|
||
const body = $('todo-body');
|
||
body.textContent = '';
|
||
const rows = todoRows(text);
|
||
if (!rows.length) {
|
||
body.className = 'muted';
|
||
body.textContent = '模型还没有给出任务清单。';
|
||
return;
|
||
}
|
||
body.className = '';
|
||
const ul = el('ul', 'todo-list');
|
||
for (const r of rows) {
|
||
const li = el('li', r.done ? 'done' : r.doing ? 'doing' : '');
|
||
li.appendChild(el('span', 'boxmark', r.done ? '☑' : r.doing ? '▣' : '☐'));
|
||
li.appendChild(el('span', null, r.text));
|
||
ul.appendChild(li);
|
||
}
|
||
body.appendChild(ul);
|
||
}
|
||
|
||
/* --- state --------------------------------------------------------------- */
|
||
|
||
const APPROVALS = [
|
||
{ key: 'ask', icon: 'shield', title: '每次询问', desc: '动文件或跑命令之前都要你点一下' },
|
||
{ key: 'auto', icon: 'bolt', title: '自动', desc: '直接执行,只有危险命令才问你' },
|
||
{ key: 'yolo', icon: 'alert', title: '完全放行', desc: '不再询问,风险自负' },
|
||
];
|
||
const EFFORTS = [
|
||
{ key: 'low', title: '低', desc: '最快,适合小改动' },
|
||
{ key: 'medium', title: '中', desc: '默认平衡' },
|
||
{ key: 'high', title: '高', desc: '多想一点,慢一些' },
|
||
{ key: 'max', title: '极高', desc: '最慢最贵,难题再用' },
|
||
];
|
||
|
||
function applyState(s) {
|
||
state = Object.assign({}, state, s);
|
||
const model = (s.provider ? s.provider + ' · ' : '') + (s.model || '-');
|
||
$('chip-model').querySelector('.lbl').textContent = model;
|
||
$('chip-model').title = model + (s.effort ? ' · 推理强度 ' + s.effort : '') +
|
||
(s.key_set === false ? ' · 还没有 API key' : '');
|
||
$('chip-model').classList.toggle('warn', s.key_set === false);
|
||
const ap = $('chip-approval');
|
||
const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto'));
|
||
ap.querySelector('.lbl').textContent = (cur ? cur.title : s.approval || 'auto');
|
||
ap.classList.toggle('warn', s.approval === 'ask');
|
||
ap.title = '审批权限:' + (cur ? cur.title : s.approval);
|
||
for (const b of $('seg-mode').children)
|
||
b.classList.toggle('on', (b.dataset.mode === 'plan') === !!s.plan_mode);
|
||
$('foot-ws').textContent = s.workspace || 'workspace';
|
||
$('foot-session').textContent = (s.session || '-').slice(0, 20);
|
||
|
||
const u = s.usage || {};
|
||
const money = (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : '';
|
||
/* the panel carries two numbers only: what the context costs, and how much
|
||
* of it the provider already had cached */
|
||
const hit = Math.max(0, u.cached_tokens || 0);
|
||
const miss = Math.max(0, u.cache_miss_tokens || 0);
|
||
$('kv-ctx').textContent = num(s.context_tokens) + ' tok';
|
||
$('kv-cache').textContent = (hit + miss) ? Math.round((hit / (hit + miss)) * 100) + '%' : '-';
|
||
$('kv-cache').title = (hit + miss)
|
||
? num(hit) + ' 命中 / ' + num(miss) + ' 未命中' : '还没有缓存数据';
|
||
$('cost').textContent = money;
|
||
$('usage').textContent = u.requests ? `${u.requests} 往返 · ${num(u.prompt_tokens)}/${num(u.completion_tokens)} tok` : '';
|
||
$('btn-undo').disabled = !s.undo_available;
|
||
|
||
busy = !!s.status_busy;
|
||
$('btn-stop').hidden = !busy;
|
||
syncSend();
|
||
if (!busy && !permReq) $('status').textContent = '就绪';
|
||
|
||
const p = $('pending');
|
||
const n = (s.pending_images || []).length;
|
||
p.hidden = !n;
|
||
if (n) {
|
||
p.textContent = '';
|
||
for (const line of s.pending_images) {
|
||
const chip = el('span', 'chip');
|
||
chip.appendChild(el('span', null, line));
|
||
const x = el('button', 'icon tiny');
|
||
x.appendChild(icon('close'));
|
||
x.title = '清空待发图片';
|
||
x.onclick = () => post('api/attach', undefined, 'DELETE').catch(toastErr);
|
||
chip.appendChild(x);
|
||
p.appendChild(chip);
|
||
}
|
||
if (s.sees_images === 0)
|
||
p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
|
||
}
|
||
renderTodo(s.todo);
|
||
if ('show_thinking' in s && s.show_thinking !== shownThinking) {
|
||
shownThinking = s.show_thinking;
|
||
for (const d of log.querySelectorAll('details.think')) d.open = !!s.show_thinking;
|
||
}
|
||
}
|
||
|
||
/* --- 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();
|
||
thinkInto(ensureLive().body, d.text || '', true);
|
||
scrollDown(false);
|
||
});
|
||
on('tool', (ev) => { markBusy(); onTool(ev); });
|
||
on('tool_result', onToolResult);
|
||
on('todo', (d) => renderTodo(d.text));
|
||
on('session', (d) => {
|
||
closePop();
|
||
live = null; steps = null; stepIndex = new Map();
|
||
turnErr = 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) => {
|
||
if (turnStart) { turnMeta(Date.now() - turnStart); turnStart = 0; }
|
||
if (live) live.body.classList.remove('live');
|
||
live = null; steps = null; 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(loadSessions).catch(toastErr);
|
||
});
|
||
/* the browser resumes from Last-Event-ID; a restarted server has none of
|
||
* those events, so its state (and maybe session) is fetched afresh */
|
||
let dropped = false;
|
||
es.onerror = () => { dropped = true; $('status').textContent = '连接中断,重连中…'; };
|
||
es.onopen = () => {
|
||
if (!dropped) return;
|
||
dropped = false;
|
||
if (!busy) $('status').textContent = '就绪';
|
||
const was = state.session;
|
||
getJSON('api/state').then((s) => {
|
||
applyState(s);
|
||
if (s.session !== was) loadSession().then(loadSessions).catch(toastErr);
|
||
}).catch(toastErr);
|
||
};
|
||
}
|
||
|
||
/* --- approval gate ------------------------------------------------------- */
|
||
|
||
const GATE_KEYS = [
|
||
{ v: 1, label: '允许一次', key: '1', cls: 'ok' },
|
||
{ v: 2, label: '总是允许这个工具', key: '2', cls: '' },
|
||
{ v: 3, label: '总是允许这个前缀', key: '3', cls: '' },
|
||
{ v: 0, label: '拒绝', key: '4', cls: 'danger' },
|
||
];
|
||
let gateTimer = null;
|
||
function showGate(ev) {
|
||
permReq = ev;
|
||
$('gate').hidden = false;
|
||
$('gate-title').textContent = ev.sandbox
|
||
? '沙箱拦下了这一步,要在沙箱外重跑吗?'
|
||
: '允许 ' + (ev.tool || '工具') + ' 运行吗?';
|
||
$('gate-detail').textContent = ev.preview || ev.detail || '';
|
||
const row = $('gate-buttons');
|
||
row.textContent = '';
|
||
for (const a of GATE_KEYS) {
|
||
const b = el('button', a.cls, a.label + ' (' + a.key + ')');
|
||
b.onclick = () => answerGate(a.v);
|
||
row.appendChild(b);
|
||
}
|
||
$('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。';
|
||
$('status').textContent = '等待你的许可…';
|
||
let left = ev.timeout || state.perm_timeout || 600;
|
||
clearInterval(gateTimer);
|
||
const tick = () => {
|
||
$('gate-timer').textContent = left >= 0 ? left + 's' : '';
|
||
if (left-- <= 0) clearInterval(gateTimer);
|
||
};
|
||
tick();
|
||
gateTimer = setInterval(tick, 1000);
|
||
scrollDown(true);
|
||
}
|
||
function hideGate() {
|
||
clearInterval(gateTimer);
|
||
gateTimer = null;
|
||
permReq = null;
|
||
$('gate').hidden = true;
|
||
$('gate-timer').textContent = '';
|
||
if (!busy) $('status').textContent = '就绪';
|
||
}
|
||
async function answerGate(value) {
|
||
if (!permReq) return;
|
||
const id = permReq.id;
|
||
hideGate();
|
||
try {
|
||
await post('api/permission', { id, answer: value });
|
||
$('status').textContent = value ? '已允许,继续执行…' : '已拒绝';
|
||
} catch (e) {
|
||
toast('回答失败:' + errMsg(e));
|
||
}
|
||
}
|
||
window.addEventListener('keydown', (e) => {
|
||
/* typing "3" or "y" into the box must not grant a permission */
|
||
const typing = e.isComposing || e.ctrlKey || e.metaKey || e.altKey ||
|
||
(e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)));
|
||
if (permReq && !$('gate').hidden && !typing) {
|
||
if (e.key === '1' || e.key.toLowerCase() === 'y') answerGate(1);
|
||
else if (e.key === '2') answerGate(2);
|
||
else if (e.key === '3') answerGate(3);
|
||
else if (e.key === '4' || e.key.toLowerCase() === 'n') answerGate(0);
|
||
}
|
||
if (e.key === 'Escape') {
|
||
if (!$('modal').hidden) return closeModal();
|
||
if (!$('pop').hidden) return closePop();
|
||
if (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) {
|
||
const b = bubble('user');
|
||
b.body.textContent = text;
|
||
b.root.dataset.prompt = '1';
|
||
steps = null;
|
||
stepIndex = new Map();
|
||
files = new Map();
|
||
live = null;
|
||
turnErr = 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);
|
||
}
|
||
freshTurn(body);
|
||
try {
|
||
await post('api/turn', { text: body });
|
||
if (busy) $('status').textContent = '已开始…';
|
||
} catch (e) {
|
||
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.turn-steps, 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; }
|
||
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;
|
||
|
||
/* --- go ------------------------------------------------------------------ */
|
||
|
||
applyTheme(localStorage.getItem('cst.theme') || 'dark');
|
||
/* the rail/panel/theme classes are already on <body> from the inline script in
|
||
* index.html, so nothing here has to re-derive them */
|
||
autoGrow();
|
||
syncSend();
|
||
renderFilesPanel();
|
||
connect();
|
||
loadSession().catch(toastErr);
|
||
loadSessions();
|
||
getJSON('api/state').then(applyState).catch(toastErr);
|
||
/* deep link: ?open=chip-approval / ?open=cost opens that popover once the
|
||
* state is in, so a screenshot or a bookmark can land on it directly */
|
||
const wantsOpen = new URLSearchParams(location.search).get('open');
|
||
if (wantsOpen) setTimeout(() => { const a = $(wantsOpen); if (a) a.click(); }, 900);
|