/* 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 tags in rendered * Markdown. --- */ const TOKEN = (() => { const q = new URLSearchParams(location.search); if (q.get('token')) return q.get('token'); return new URLSearchParams(location.hash.replace(/^#/, '')).get('token') || ''; })(); if (TOKEN) document.cookie = 'cst_token=' + encodeURIComponent(TOKEN) + '; path=/; SameSite=Strict'; const qs = (extra) => { const p = new URLSearchParams(extra || {}); if (TOKEN) p.set('token', TOKEN); const s = p.toString(); return s ? '?' + s : ''; }; function api(path, opt) { if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN); return fetch(path, opt); } /* the server answers errors as {"error": "..."}; status and body ride along * so a caller can tell "needs a key" from "rejected key" */ class ApiError extends Error { constructor(msg, status, data) { super(msg); this.status = status; this.data = data || {}; } } async function readReply(r) { const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : {}; } catch { data = null; } if (!r.ok) throw new ApiError(errorText(r.status, (data && data.error) || text || r.statusText), r.status, data); return data === null ? text : data; } async function getJSON(path) { return readReply(await api(path)); } async function post(path, body, method) { const r = await api(path, { method: method || 'POST', headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); return readReply(r); } /* engine messages that are still English, in the words the page uses */ const ERR_ZH = [ [/^busy: wait for the running turn/, '正在运行,等这一轮结束再试'], [/^a turn is already running/, '已经有一轮在运行'], [/^another change is running/, '另一项修改正在进行,稍后再试'], [/^nothing to undo/, '没有可以撤销的写入'], [/^nothing to retry/, '没有可以重试的消息'], [/^cannot reset the rules/, '清除许可规则失败'], [/^expected a JSON object/, '请求格式不对'], [/^cannot set the title/, '标题保存失败'], [/^cannot delete the session/, '删除会话失败'], [/^cannot start a session/, '无法新建会话'], [/^no such session/, '找不到这个会话'], [/^no such permission request/, '这个许可请求已经结束'], [/^no such question/, '这个问题已经结束'], [/^unauthorized|^forbidden/i, '没有权限:请带上 token 打开页面'], [/^out of memory/, '服务器内存不足'], [/^path is required/, '需要填写路径'], [/^empty body/, '内容为空'], [/^no such skill/, '没有这个 skill(/skills 查看全部)'], [/^cannot save the settings/, '设置保存失败'], [/^nothing to compact/, '没有可以压缩的内容'], [/^compact llm: (.*)/, (m) => '压缩时模型请求失败:' + m[1]], [/^compact produced empty summary/, '压缩失败:模型给了空摘要'], [/^no such directory: (.*)/, (m) => '目录不存在:' + m[1]], [/^cannot use as a workspace: (.*)/, (m) => '不能用作工作目录:' + m[1]], [/^at most (\d+) images per message/, (m) => '每条消息最多 ' + m[1] + ' 张图'], [/^the images (?:already picked fill|together exceed) (\d+) MB/, (m) => '图片加起来超过 ' + m[1] + ' MB'], [/^HTTP (\d+): the API key was rejected/, (m) => 'API key 被拒绝(HTTP ' + m[1] + ')'], [/^not found$/, '服务端没有这个接口:页面和程序版本不一致,刷新页面试试'], ]; /* the first match of a [regexp, text | (match) => text] table, or null */ function zhOf(table, msg) { for (const [re, zh] of table) { const m = re.exec(msg); if (m) return typeof zh === 'function' ? zh(m) : zh; } return null; } function errorText(status, msg) { msg = String(msg || ''); const zh = zhOf(ERR_ZH, msg); if (zh) return zh; if (status === 401 && !msg) return '没有权限:请带上 token 打开页面'; return msg || ('HTTP ' + status); } /* the engine's one-line status messages */ const STATUS_ZH = [ [/^calling model \(step (\d+)\)/, (m) => '调用模型 · 第 ' + m[1] + ' 步…'], [/^final answer pass/, '最后一次作答…'], [/^tool round done/, '工具执行完,继续…'], [/^contract error/, '模型的工具调用格式不对,让它重发…'], [/^empty response/, '模型回了空内容,重试…'], [/^output cut off/, '输出被截断,接着往下写…'], [/^the model refused images/, '模型不收图片,改发文字说明…'], [/^prompt (\d+)K tokens \(budget (\d+)K\)/, (m) => '上下文 ' + m[1] + 'K 超过预算 ' + m[2] + 'K,压缩中…'], [/^context compacted/, '上下文已压缩'], [/^done$/, '完成'], [/^blocked by the sandbox/, '被沙箱拦下'], [/^rerunning outside the sandbox/, '在沙箱外重跑…'], [/^unknown tool/, '模型调用了不存在的工具'], [/^skip repeated edit/, '跳过重复的修改'], [/^skip duplicate read/, '跳过重复的读取'], [/^running reads in parallel/, '并行读取中…'], ]; const statusText = (msg) => zhOf(STATUS_ZH, String(msg || '')) || msg; /* why a turn stopped, in words: turn_end carries the engine's error text */ const HTTP_ZH = { 400: '请求被拒绝', 401: 'API key 无效或已过期', 402: '余额不足', 403: '没有权限用这个模型', 404: '模型或端点不存在', 408: '请求超时', 413: '请求太大', 429: '请求太频繁或额度用完', }; const TURN_ERR_ZH = [ [/^(?:llm: )?cancelled$/, '已停止'], [/^llm: http (\d+)(?::\s*([\s\S]*))?/i, (m) => '模型请求失败:' + (HTTP_ZH[m[1]] || (+m[1] >= 500 ? '服务端出错' : '出错')) + '(HTTP ' + m[1] + ')' + (m[2] ? '\n' + m[2] : '')], [/^llm: curl: (.*)/, (m) => '连不上模型服务:' + m[1]], [/^llm: api: (.*)/, (m) => '模型服务报错:' + m[1]], [/^llm: (.*)/, (m) => '模型请求失败:' + m[1]], [/^(\S+): repeated contract errors/, (m) => '模型连续几次没按 ' + m[1] + ' 协议的格式调用工具,这一轮停了。可以在运行设置里换个工具协议,或换个模型再试。'], [/^empty model response/, '模型返回了空内容。'], [/^step limit \((\d+)\) reached/, (m) => '这一轮用满了 ' + m[1] + ' 步上限。说"继续"接着做,或在运行设置里调大步数。'], ]; const turnError = (d) => zhOf(TURN_ERR_ZH, String(d.error || '')) || d.error || ('这一轮没有完成(错误码 ' + d.rc + ')'); const errMsg = (e) => (e && e.message) || String(e); let toastTimer = null; function toast(msg, kind) { const t = document.getElementById('toast'); if (!t) return; t.textContent = String(msg); t.className = 'toast ' + (kind || 'err'); t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, kind === 'ok' ? 2500 : 6000); } const toastErr = (e) => toast(errMsg(e), 'err'); const $ = (id) => document.getElementById(id); const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; const attachURL = (rel) => 'api/attach/' + rel + qs(); const fmtSecs = (ms) => { const s = ms / 1000; if (s < 60) return s.toFixed(1) + ' 秒'; const m = Math.floor(s / 60); return m + ' 分 ' + Math.round(s - m * 60) + ' 秒'; }; const num = (n) => (n || 0).toLocaleString(); const log = $('log'); const scroll = $('scroll'); let state = {}; let live = null; /* streaming assistant block */ let proc = null; /* the open process block: thinking + tool calls */ let stepIndex = new Map(); /* tool call id -> step row */ let lastTurn = null; /* { session, ms }: the last turn's time, kept across the rebuild */ let pendingPreviews = []; /* { src, name }: object URLs of the images uploaded from this page */ let shownPreviews = []; /* the ones the newest prompt still shows until the rebuild */ let files = new Map(); /* this turn's file changes: path -> {add, del} */ let permReq = null; let askReq = null; let busy = false; let turnStart = 0; let turnErr = null; /* why the last turn stopped; kept across reloads */ let detailsAll = localStorage.getItem('cst.details') === '1'; /* tool lines start open */ let shownThinking; /* state.show_thinking last applied to the page */ /* --- anchored popovers --------------------------------------------------- * The pattern the reference apps use: a small sheet under the control that * owns the setting, each row = icon + title + one line, a check on the * current value. Shift+Tab and the arrow keys work, Escape closes. */ let popAnchor = null; let popOpenedAt = 0; /* icons all come from the sprite in index.html: one 24px grid, one stroke * weight, round caps. A mixed bag of unicode glyphs never reads as a family. */ const SVGNS = 'http://www.w3.org/2000/svg'; function icon(name, cls) { /* an unknown name is just an old text glyph: keep it, do not blank it out */ if (!document.getElementById('i-' + name)) return el('span', cls || '', name); const s = document.createElementNS(SVGNS, 'svg'); s.setAttribute('class', 'i' + (cls ? ' ' + cls : '')); const u = document.createElementNS(SVGNS, 'use'); u.setAttribute('href', '#i-' + name); s.appendChild(u); return s; } function setIcon(btn, name) { const old = btn.querySelector('svg.i'); if (old) old.replaceWith(icon(name)); } function closePop() { const p = $('pop'); p.hidden = true; p.textContent = ''; popAnchor = null; } function popRow(o) { const b = el('button', 'pop-item' + (o.on ? ' on' : '') + (o.danger ? ' danger' : '')); b.appendChild(o.icon ? icon(o.icon, 'pi') : el('span', 'pi')); const m = el('span', 'pm'); m.appendChild(el('span', 'pt', o.title)); if (o.desc) m.appendChild(el('span', 'pd', o.desc)); b.appendChild(m); if (o.on) b.appendChild(el('span', 'pk', '✓')); b.onclick = () => { const fn = o.onClick; closePop(); if (fn) fn(); }; return b; } function positionPop(pop, anchor, align) { const a = anchor.getBoundingClientRect(); pop.style.visibility = 'hidden'; pop.style.left = '0px'; pop.style.top = '0px'; const p = pop.getBoundingClientRect(); let left = align === 'right' ? a.right - p.width : a.left; left = Math.max(8, Math.min(left, window.innerWidth - p.width - 8)); let top = a.bottom + 8; let origin = 'top'; if (top + p.height > window.innerHeight - 8) { top = Math.max(8, a.top - p.height - 8); origin = 'bottom'; } pop.style.left = left + 'px'; pop.style.top = top + 'px'; pop.style.transformOrigin = (align === 'right' ? '100%' : '0') + ' ' + (origin === 'bottom' ? '100%' : '0'); pop.style.visibility = 'visible'; } function openPop(anchor, build, opt) { const pop = $('pop'); if (popAnchor === anchor) return closePop(); /* second tap closes */ closePop(); pop.textContent = ''; build(pop, closePop); pop.hidden = false; positionPop(pop, anchor, (opt && opt.align) || 'left'); popAnchor = anchor; popOpenedAt = Date.now(); } document.addEventListener('click', (e) => { if ($('pop').hidden) return; /* the click lands on a child of the anchor button, so compare containers */ if ($('pop').contains(e.target) || (popAnchor && popAnchor.contains(e.target))) return; closePop(); }); /* a phone browser fires resize while the address bar settles right after the * popover opened; that is not the user dismissing it */ window.addEventListener('resize', () => { if (Date.now() - popOpenedAt > 700) closePop(); }); window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('pop').hidden) closePop(); }); /* --- transcript ---------------------------------------------------------- */ function atBottom() { return scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 60; } function scrollDown(force) { if (force || atBottom()) scroll.scrollTop = scroll.scrollHeight; updateJump(); } function updateJump() { $('to-bottom').hidden = atBottom(); } function showEmpty(show) { $('empty').classList.toggle('gone', !show); } /* every row of the transcript shares one left edge, the composer's: no avatar * column, the assistant reads as plain text, only errors carry a mark */ function bubble(role, cls) { const m = el('div', 'msg ' + (cls || role)); const b = el('div', 'body'); m.appendChild(b); log.appendChild(m); showEmpty(false); scrollDown(true); return { root: m, body: b }; } function actBtn(name, title, fn) { const b = el('button', 'act'); b.title = title; b.setAttribute('aria-label', title); b.appendChild(icon(name)); b.onclick = fn; return b; } function copyButton(text) { const b = actBtn('copy', '复制', async () => { try { await navigator.clipboard.writeText(text); setIcon(b, 'check'); b.title = '已复制'; setTimeout(() => { setIcon(b, 'copy'); b.title = '复制'; }, 1200); } catch { toast('浏览器不允许写剪贴板'); } }); return b; } /* images sit above the prompt, outside the bubble: one keeps its shape, more * than one become a row of square tiles; a click opens the full picture */ function attachStrip(srcs) { const strip = el('div', 'attach-strip' + (srcs.length === 1 ? ' one' : '')); srcs.forEach((s, i) => { const b = el('button', 'thumb'); b.title = s.name || '查看图片'; const img = el('img'); img.alt = s.name || 'image'; img.loading = 'lazy'; img.decoding = 'async'; img.src = s.src; b.appendChild(img); b.onclick = () => openLightbox(srcs, i); strip.appendChild(b); }); return strip; } function userBubble(text, srcs) { const b = bubble('user'); b.body.textContent = text; if (srcs && srcs.length) b.root.insertBefore(attachStrip(srcs), b.body); b.root.dataset.prompt = '1'; actionsFor(b.root, text); return b; } function actionsFor(root, text) { const row = el('div', 'msg-actions'); row.appendChild(copyButton(text)); root.appendChild(row); return row; } /* --- lightbox ------------------------------------------------------------ */ let lb = null; /* { srcs, i } while a picture is open */ function showLightbox() { const s = lb.srcs[lb.i]; $('lb-img').src = s.src; $('lb-img').alt = s.name || ''; const many = lb.srcs.length > 1; $('lb-count').textContent = many ? (lb.i + 1) + ' / ' + lb.srcs.length : ''; $('lb-prev').hidden = !many; $('lb-next').hidden = !many; } function openLightbox(srcs, i) { lb = { srcs: srcs.slice(), i }; showLightbox(); $('lightbox').hidden = false; } function closeLightbox() { $('lightbox').hidden = true; $('lb-img').removeAttribute('src'); lb = null; } function stepLightbox(d) { if (!lb || lb.srcs.length < 2) return; lb.i = (lb.i + d + lb.srcs.length) % lb.srcs.length; showLightbox(); } /* --- process timeline ----------------------------------------------------- * Consecutive thinking and tool calls share one block that folds to a single * line; visible text (or a note, or the next prompt) closes it. History and * the live stream follow the same rule, so a reload draws what streamed. */ function procLabel(p) { const parts = []; if (p.thinks) parts.push(p.thinks > 1 ? '思考 ' + p.thinks + ' 次' : '已思考'); if (p.tools) parts.push('工具 ' + p.tools + ' 次'); const counts = parts.join(' · '); if (p.root.classList.contains('run')) { p.lbl.textContent = p.act || '处理中…'; p.meta.textContent = counts; } else { p.lbl.textContent = counts || '过程'; p.meta.textContent = ''; } } function ensureProc() { if (proc) return proc; if (live && live.html) sealLive(); const d = el('details', 'proc'); d.open = !!state.show_thinking; const s = el('summary', 'proc-sum'); s.appendChild(icon('chevron', 'proc-chev')); const lbl = el('span', 'proc-lbl'); const meta = el('span', 'proc-meta'); s.appendChild(lbl); s.appendChild(meta); const box = el('div', 'tl'); d.appendChild(s); d.appendChild(box); log.appendChild(d); showEmpty(false); proc = { root: d, lbl, meta, box, thinks: 0, tools: 0, cur: null, act: '' }; procLabel(proc); return proc; } function sealProc() { if (!proc) return; proc.root.classList.remove('run'); procLabel(proc); proc = null; } function procAct(p, text) { p.root.classList.add('run'); p.act = text; procLabel(p); } function firstLine(s) { const l = String(s).split('\n').find((x) => x.trim()) || ''; return l.trim().slice(0, 200); } function procThink(text, running) { const p = ensureProc(); let t = p.cur && p.cur.kind === 'think' ? p.cur : null; if (!t) { const d = el('details', 'tl-item think'); d.open = !!state.show_thinking; const s = el('summary'); s.appendChild(el('span', 'tl-dot')); s.appendChild(el('span', 'tl-name', '思考')); const prev = el('span', 'tl-prev'); s.appendChild(prev); const body = el('div', 'think-text'); const node = document.createTextNode(''); body.appendChild(node); d.appendChild(s); d.appendChild(body); p.box.appendChild(d); t = { kind: 'think', root: d, prev, node, hasLine: false }; p.cur = t; p.thinks++; } t.node.appendData(text); /* the preview is the first line, so it stops changing once that is whole */ if (!t.hasLine) { t.prev.textContent = firstLine(t.node.data); t.hasLine = /\S[^\n]*\n/.test(t.node.data); } if (running) procAct(p, '正在思考…'); else procLabel(p); return t; } /* "[cst] Plan mode is now ON: ..." notes the engine adds to the history; the * model reads the English, the page shows one line per change */ const APPROVAL_ZH = { ask: '每次询问', auto: '自动', yolo: '完全放行' }; const NOTE_ZH = [ [/^Plan mode is now ON/, '切到计划模式(只读)'], [/^Plan mode is now OFF/, '切回构建模式'], [/^Approval mode is now (\w+)/, (m) => '审批改为' + (APPROVAL_ZH[m[1]] || m[1])], [/^The git branch is now (.+?)\.?$/, (m) => 'git 分支变为 ' + m[1]], [/^Today's date is now (.+?)\.?$/, (m) => '日期变为 ' + m[1]], [/^The workspace's AGENTS\.md changed/, 'AGENTS.md 有改动,已重新读入'], [/^The workspace's AGENTS\.md was removed/, 'AGENTS.md 已删除'], ]; function noteLine(text) { const lines = String(text || '').split('\n').filter((l) => l.startsWith('[cst]')) .map((l) => l.replace(/^\[cst\]\s*/, '')); const shown = (lines.length ? lines : [String(text || '').split('\n')[0]]) .map((l) => zhOf(NOTE_ZH, l) || l); const n = el('div', 'note-line', shown.join(' · ')); n.title = String(text || ''); sealProc(); log.appendChild(n); } /* history: the stored calls become the same step lines a live turn draws, * and each result fills its line in */ function historyCall(c) { const st = stepFor({ id: c.id, name: c.name }); const a = argsLine(c.args); if (a) st.args.textContent = a; } function historyResult(m) { const st = stepFor({ id: m.tool_call_id, name: m.tool_name }); st.root.classList.remove('run'); st.root.classList.add('done'); st.bad = !!m.exit; st.root.classList.toggle('bad', st.bad); st.phase.textContent = st.bad ? '失败 exit=' + m.exit : '完成'; st.body.textContent = ''; if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent)); const out = m.out !== undefined ? m.out : (m.content || ''); if (out) st.body.appendChild(el('pre', 'step-out', out + (m.truncated ? '\n… (截断)' : ''))); } function renderMessage(m) { if (m.role === 'system') return; if (m.note) return noteLine(m.content); if (m.role === 'tool' || m.tool_result) return historyResult(m); const isUser = m.role === 'user'; const text = m.text !== undefined ? m.text : (m.content || ''); if (isUser) { sealProc(); stepIndex = new Map(); const srcs = (m.attach || []).map((a) => ({ src: attachURL(a.rel), name: a.name })); userBubble(text, srcs); return; } if (m.reasoning) { procThink(m.reasoning, false); proc.cur = null; /* the next round's thinking is its own entry */ } if (text.trim()) { sealProc(); const b = bubble('assistant'); if (m.html) b.body.innerHTML = m.html; else b.body.textContent = text; actionsFor(b.root, text); } for (const c of m.calls || []) historyCall(c); } /* only the newest exchange can be redone: the server rewinds one turn */ function lastPromptActions() { const prompts = log.querySelectorAll('.msg[data-prompt]'); const p = prompts[prompts.length - 1]; if (!p) return; const row = p.querySelector('.msg-actions'); if (row) row.appendChild(actBtn('edit', '编辑重发', () => editLast(p))); const answers = log.querySelectorAll('.msg.assistant'); const a = answers[answers.length - 1]; if (a && (p.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING)) { const r = a.querySelector('.msg-actions'); if (r) r.appendChild(actBtn('retry', '重新生成', () => retry())); } } async function loadSession() { const data = await getJSON('api/session'); log.textContent = ''; stepIndex = new Map(); files = new Map(); renderFilesPanel(); proc = null; live = null; for (const m of data.messages) renderMessage(m); sealProc(); stepIndex = new Map(); lastPromptActions(); if (lastTurn && lastTurn.session === state.session && log.lastChild) turnMeta(lastTurn.ms); if (turnErr && turnErr.session === state.session) { if (turnErr.stopped) noteLine('已停止'); else bubble('error').body.textContent = turnErr.text; } /* a rebuild is not new content: settle every entrance animation at once */ if (log.getAnimations) { for (const a of log.getAnimations({ subtree: true })) { try { a.finish(); } catch { /* an infinite pulse cannot finish; it keeps running */ } } } showEmpty(log.children.length === 0); const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user' && !m.note && !m.tool_result); document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent'; scrollDown(true); updateJump(); } /* --- live blocks --------------------------------------------------------- */ /* text closes the process block above it; thinking or a tool call after the * text closes the answer, so the next words start a bubble below them */ function ensureLive() { if (live) return live; sealProc(); const b = bubble('assistant'); b.body.classList.add('live'); const out = el('div', 'live-html'); b.body.appendChild(out); live = { root: b.root, body: b.body, out, html: '' }; return live; } function sealLive() { if (!live) return; live.body.classList.remove('live'); live = null; } function phaseText(p) { return { open: '准备中', ready: '待执行', run: '执行中', progress: '进行中', done: '完成' }[p] || p || ''; } function argsLine(args) { if (!args) return ''; return Object.entries(args) .map(([k, v]) => (typeof v === 'string' ? v : JSON.stringify(v))) .join(' '); } /* one line per call, like "已执行 python · <": open it for the details */ function stepFor(ev) { let st = stepIndex.get(ev.id); if (st) return st; const p = ensureProc(); const node = el('details', 'tl-item step-line run'); node.open = detailsAll; const sum = el('summary'); const name = el('span', 'name', ev.name || 'tool'); const args = el('span', 'args'); const phase = el('span', 'phase', '准备中'); sum.appendChild(el('span', 'tl-dot')); sum.appendChild(name); sum.appendChild(args); sum.appendChild(phase); const body = el('div', 'step-body'); node.appendChild(sum); node.appendChild(body); p.box.appendChild(node); st = { kind: 'tool', root: node, sum, name, args, phase, body, t0: Date.now(), bad: false, proc: p }; stepIndex.set(ev.id, st); p.cur = st; p.tools++; procLabel(p); return st; } /* --- the run panel answers one question: which files moved, and by how many * lines. Everything else about a call stays in the transcript where it * happened, so the panel stays readable no matter how long a turn runs. */ function linesOf(s) { return (typeof s === 'string' && s !== '') ? s.split('\n').length : 0; } function patchStat(text) { let add = 0, del = 0; for (const ln of String(text).split('\n')) { if (ln.startsWith('+++') || ln.startsWith('---')) continue; if (ln.charAt(0) === '+') add++; else if (ln.charAt(0) === '-') del++; } return { add, del }; } function statFromArgs(name, args) { const a = args || {}, n = String(name || ''); const body = a.patch ?? a.diff ?? a.input; if (/patch|diff/i.test(n) && typeof body === 'string') return patchStat(body); if (/write|create/i.test(n) && typeof a.content === 'string') return { add: linesOf(a.content), del: 0 }; if (/edit|replace|rename/i.test(n)) { const old = a.old_string ?? a.old ?? a.find ?? a.old_text; const nw = a.new_string ?? a.new ?? a.replace ?? a.new_text; if (typeof nw === 'string' || typeof old === 'string') return { add: linesOf(nw), del: linesOf(old) }; } return null; } function noteFileChange(ev) { const st = statFromArgs(ev.name, ev.args); if (!st) return; const a = ev.args || {}; const path = String(a.path || a.file_path || a.file || a.filename || '补丁'); const cur = files.get(path) || { add: 0, del: 0 }; cur.add += st.add; cur.del += st.del; files.set(path, cur); renderFilesPanel(); } function renderFilesPanel() { const box = $('files-body'); if (!box) return; box.textContent = ''; box.className = ''; if ($('files-count')) $('files-count').textContent = String(files.size); if (!files.size) { box.className = 'muted'; box.textContent = '本轮还没有改动文件。'; return; } for (const [p, v] of files) { const row = el('button', 'fstat'); row.appendChild(icon('file', 'fimg')); const nm = el('span', 'p', p); row.title = '查看 ' + p + ' 的 diff'; row.onclick = () => showDiff(p === '补丁' ? undefined : p); row.appendChild(nm); row.appendChild(el('span', 'a', '+' + v.add)); row.appendChild(el('span', 'd', '−' + v.del)); box.appendChild(row); } } /* --- the real diff: git status + git diff of the workspace --------------- */ function diffPre(text) { const pre = el('pre', 'diff'); for (const ln of String(text).split('\n')) { let cls = ''; if (/^(diff |index |\+\+\+ |--- |new file|deleted file|similarity|rename )/.test(ln)) cls = 'h'; else if (ln.startsWith('@@')) cls = 'hunk'; else if (ln.charAt(0) === '+') cls = 'add'; else if (ln.charAt(0) === '-') cls = 'del'; pre.appendChild(el('span', cls, ln + '\n')); } return pre; } const GIT_ST = { M: '修改', A: '新增', D: '删除', R: '重命名', C: '复制', U: '冲突', T: '类型', '?': '未跟踪', '!': '忽略' }; function gitLabel(code) { const c = (code || '').replace(/ /g, '').charAt(0); return GIT_ST[c] || code || ''; } async function showDiff(path) { let d; try { d = await getJSON('api/diff' + (path ? '?path=' + encodeURIComponent(path) : '')); } catch (e) { return toast('读取 diff 失败:' + errMsg(e)); } const box = document.createDocumentFragment(); box.appendChild(el('h3', null, path ? 'diff · ' + path : '工作区改动')); const row = el('div', 'buttons'); if (!d.git) { box.appendChild(el('p', 'muted', '这个工作区不是 git 仓库(或者没有装 git),看不到真实 diff;右栏的行数是按工具参数估算的。')); } else { const list = el('div', 'diff-files'); const fileOf = (f) => f.path.includes(' -> ') ? f.path.split(' -> ').pop() : f.path; for (const f of d.files || []) { const b = el('button', 'diff-file' + (fileOf(f) === path ? ' on' : '')); b.appendChild(el('span', 'st', gitLabel(f.status))); b.appendChild(el('span', 'p', f.path)); b.onclick = () => showDiff(fileOf(f)); list.appendChild(b); } if (!(d.files || []).length) list.appendChild(el('div', 'muted tiny', '工作区是干净的,没有改动。')); box.appendChild(list); if (d.diff) box.appendChild(diffPre(d.diff)); else if ((d.files || []).length) { const f = (d.files || []).find((x) => fileOf(x) === path); box.appendChild(el('p', 'muted tiny', f && f.status === '??' ? '未跟踪的新文件:git 还没有记录它,所以没有 diff。' : '没有未暂存的改动(已 git add 的改动不在这里显示)。')); } if (d.truncated) box.appendChild(el('p', 'warn-text tiny', 'diff 太长,只显示了前 512 KB。')); if (path) row.appendChild(Object.assign(el('button', null, '全部改动'), { onclick: () => showDiff() })); row.appendChild(Object.assign(el('button', null, '刷新'), { onclick: () => showDiff(path) })); } row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row); openModal(box, true); } function onTool(ev) { const st = stepFor(ev); const a = argsLine(ev.args); if (a) st.args.textContent = a; if (ev.phase === 'progress' && ev.detail) { /* a sub-agent's latest steps: "explore · grep foo · read_file x" */ if (!st.detail) { st.detail = el('span', 'step-detail'); st.sum.insertBefore(st.detail, st.phase); } st.detail.textContent = ev.detail; $('status').textContent = ev.detail; } else if (ev.detail && !st.args.textContent) st.args.textContent = ev.detail; st.phase.textContent = phaseText(ev.phase); st.root.classList.toggle('run', ev.phase !== 'done'); if (ev.phase === 'done') { st.root.classList.remove('run'); st.root.classList.add('done'); st.phase.textContent = '完成 · ' + ((Date.now() - st.t0) / 1000).toFixed(1) + 's'; } if (st.proc === proc) { procAct(proc, ev.phase === 'done' ? '等待模型…' : (ev.phase === 'progress' && ev.detail ? ev.detail : '执行 ' + (ev.name || '工具') + '…')); } scrollDown(false); } function onToolResult(ev) { const st = stepFor(ev); st.root.classList.remove('run'); st.bad = !!ev.exit; st.root.classList.toggle('bad', st.bad); st.phase.textContent = st.bad ? ('失败 exit=' + ev.exit) : '完成'; st.body.textContent = ''; if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent)); if (ev.out) st.body.appendChild(el('pre', 'step-out', ev.out + (ev.truncated ? '\n… (截断)' : ''))); if (!st.bad) noteFileChange(ev); if (st.proc === proc) procAct(proc, '等待模型…'); scrollDown(false); } function turnMeta(ms) { const wrap = el('div', 'turn-meta'); const d = el('details'); d.appendChild(el('summary', null, '用时 ' + fmtSecs(ms))); d.appendChild(el('div', 'muted tiny', '从发出去到最后一个工具结束;模型延迟和工具耗时都算在里面。')); wrap.appendChild(d); log.appendChild(wrap); } /* --- model checklist ----------------------------------------------------- */ function todoRows(text) { const rows = []; for (const raw of String(text || '').split('\n')) { let line = raw.replace(/\s+$/, ''); if (!line.trim()) continue; let done = false, doing = false; line = line.replace(/^\s*[-*+]\s*/, '').replace(/^\s*\d+[.)]\s*/, ''); if (/^\[[xX]\]/.test(line)) { done = true; line = line.replace(/^\[[xX]\]\s*/, ''); } else line = line.replace(/^\[( |>)\]\s*/, ''); if (/^(✔|✅|\[done\])/i.test(line)) { done = true; line = line.replace(/^(✔|✅|\[done\])\s*/i, ''); } else if (/^(▶|➡|\[now\]|\[doing\])/i.test(line)) { doing = true; line = line.replace(/^(▶|➡|\[now\]|\[doing\])\s*/i, ''); } if (!line.trim()) continue; rows.push({ text: line, done, doing }); } return rows; } function renderTodo(text) { const body = $('todo-body'); body.textContent = ''; const rows = todoRows(text); if (!rows.length) { body.className = 'muted'; body.textContent = '模型还没有给出任务清单。'; return; } body.className = ''; const ul = el('ul', 'todo-list'); for (const r of rows) { const li = el('li', r.done ? 'done' : r.doing ? 'doing' : ''); li.appendChild(el('span', 'boxmark', r.done ? '☑' : r.doing ? '▣' : '☐')); li.appendChild(el('span', null, r.text)); ul.appendChild(li); } body.appendChild(ul); } /* --- state --------------------------------------------------------------- */ const APPROVALS = [ { key: 'ask', icon: 'shield', title: '每次询问', desc: '动文件或跑命令之前都要你点一下' }, { key: 'auto', icon: 'bolt', title: '自动', desc: '直接执行,只有危险命令才问你' }, { key: 'yolo', icon: 'alert', title: '完全放行', desc: '不再询问,风险自负' }, ]; const EFFORTS = [ { key: 'low', title: '低', desc: '最快,适合小改动' }, { key: 'medium', title: '中', desc: '默认平衡' }, { key: 'high', title: '高', desc: '多想一点,慢一些' }, { key: 'max', title: '极高', desc: '最慢最贵,难题再用' }, ]; function applyState(s) { state = Object.assign({}, state, s); const model = (s.provider ? s.provider + ' · ' : '') + (s.model || '-'); $('chip-model').querySelector('.lbl').textContent = model; $('chip-model').title = model + (s.effort ? ' · 推理强度 ' + s.effort : '') + (s.key_set === false ? ' · 还没有 API key' : ''); $('chip-model').classList.toggle('warn', s.key_set === false); const ap = $('chip-approval'); const cur = APPROVALS.find((a) => a.key === (s.approval || 'auto')); ap.querySelector('.lbl').textContent = (cur ? cur.title : s.approval || 'auto'); ap.classList.toggle('warn', s.approval === 'ask'); ap.title = '审批权限:' + (cur ? cur.title : s.approval); for (const b of $('seg-mode').children) b.classList.toggle('on', (b.dataset.mode === 'plan') === !!s.plan_mode); $('foot-ws').textContent = s.workspace || 'workspace'; $('foot-session').textContent = (s.session || '-').slice(0, 20); const u = s.usage || {}; const money = (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : ''; /* the panel carries two numbers only: what the context costs, and how much * of it the provider already had cached */ const hit = Math.max(0, u.cached_tokens || 0); const miss = Math.max(0, u.cache_miss_tokens || 0); $('kv-ctx').textContent = num(s.context_tokens) + ' tok'; $('kv-cache').textContent = (hit + miss) ? Math.round((hit / (hit + miss)) * 100) + '%' : '-'; $('kv-cache').title = (hit + miss) ? num(hit) + ' 命中 / ' + num(miss) + ' 未命中' : '还没有缓存数据'; $('cost').textContent = money; $('usage').textContent = u.requests ? `${u.requests} 往返 · ${num(u.prompt_tokens)}/${num(u.completion_tokens)} tok` : ''; $('btn-undo').disabled = !s.undo_available; busy = !!s.status_busy; $('btn-stop').hidden = !busy; syncSend(); if (!busy && !permReq) $('status').textContent = '就绪'; renderPending(); renderTodo(s.todo); if ('show_thinking' in s && s.show_thinking !== shownThinking) { shownThinking = s.show_thinking; for (const d of log.querySelectorAll('details.proc, details.tl-item.think')) d.open = !!s.show_thinking; } } const revokeAll = (list) => { for (const s of list) URL.revokeObjectURL(s.src); }; /* images waiting for the next prompt: thumbnails when this page uploaded all * of them, otherwise (another client, the CLI) the server's one-line notes */ let pendingSeen = 0; /* the server's count last time: an upload's reply can beat its state event */ function renderPending() { const p = $('pending'); const list = state.pending_images || []; const n = list.length; if (!n && pendingSeen && pendingPreviews.length) { revokeAll(pendingPreviews); pendingPreviews = []; } pendingSeen = n; p.hidden = !n; p.textContent = ''; if (!n) return; const clear = () => post('api/attach', undefined, 'DELETE').catch(toastErr); const clearTitle = n > 1 ? '清空全部 ' + n + ' 张待发图片' : '移除这张图片'; if (pendingPreviews.length === n) { pendingPreviews.forEach((s, i) => { const t = el('div', 'pend-thumb'); const img = el('img'); img.src = s.src; img.alt = s.name || 'image'; img.title = list[i]; img.onclick = () => openLightbox(pendingPreviews, i); const x = el('button', 'pend-x'); x.title = clearTitle; x.setAttribute('aria-label', clearTitle); x.appendChild(icon('close')); x.onclick = clear; t.appendChild(img); t.appendChild(x); p.appendChild(t); }); } else { for (const line of list) { const chip = el('span', 'chip'); chip.appendChild(el('span', null, line)); const x = el('button', 'icon tiny'); x.appendChild(icon('close')); x.title = '清空待发图片'; x.onclick = clear; chip.appendChild(x); p.appendChild(chip); } } if (state.sees_images === 0) p.appendChild(el('span', 'chip warn', (state.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发')); } /* --- SSE ----------------------------------------------------------------- */ function connect() { const es = new EventSource('api/events' + qs()); /* a turn can also start outside this page (the CLI, another client), so the * clock starts on the first sign of activity, not only on send() */ const markBusy = () => { if (!turnStart) turnStart = Date.now(); }; const on = (name, fn) => es.addEventListener(name, (e) => { try { fn(JSON.parse(e.data)); } catch (err) { console.error(name, err); } }); on('state', applyState); on('status', (d) => { markBusy(); if (d.message) $('status').textContent = statusText(d.message); }); on('assistant_html', (d) => { markBusy(); const l = ensureLive(); l.html += d.text || ''; l.out.innerHTML = l.html; scrollDown(false); }); on('think_delta', (d) => { markBusy(); procThink(d.text || '', true); scrollDown(false); }); on('tool', (ev) => { markBusy(); onTool(ev); }); on('tool_result', onToolResult); on('todo', (d) => renderTodo(d.text)); on('session', (d) => { closePop(); live = null; proc = null; stepIndex = new Map(); turnErr = null; lastTurn = null; loadSession().then(loadSessions).catch(toastErr); closeDrawers(); if (d && d.id) $('status').textContent = '已切换会话'; }); on('permission', showGate); on('permission_done', hideGate); on('ask_user', showAsk); on('turn_end', (d) => { lastTurn = turnStart ? { session: state.session, ms: Date.now() - turnStart } : null; turnStart = 0; sealLive(); sealProc(); stepIndex = new Map(); /* the reload below rebuilds the log, so the reason is drawn after it */ turnErr = d && d.rc ? { text: turnError(d), stopped: /cancelled$/.test(d.error || ''), session: state.session, } : null; loadSession().then(() => { /* the rebuilt prompt shows the server's copies now */ if (!lb || !lb.srcs.some((s) => shownPreviews.includes(s))) { revokeAll(shownPreviews); shownPreviews = []; } }).then(loadSessions).catch(toastErr); }); /* the browser resumes from Last-Event-ID; a restarted server has none of * those events, so its state (and maybe session) is fetched afresh */ let dropped = false; es.onerror = () => { dropped = true; $('status').textContent = '连接中断,重连中…'; }; es.onopen = () => { if (!dropped) return; dropped = false; if (!busy) $('status').textContent = '就绪'; const was = state.session; getJSON('api/state').then((s) => { applyState(s); if (s.session !== was) loadSession().then(loadSessions).catch(toastErr); }).catch(toastErr); }; } /* --- approval gate ------------------------------------------------------- */ const GATE_KEYS = [ { v: 1, label: '允许一次', key: '1', cls: 'ok' }, { v: 2, label: '总是允许这个工具', key: '2', cls: '' }, { v: 3, label: '总是允许这个前缀', key: '3', cls: '' }, { v: 0, label: '拒绝', key: '4', cls: 'danger' }, ]; let gateTimer = null; function showGate(ev) { permReq = ev; $('gate').hidden = false; $('gate-title').textContent = ev.sandbox ? '沙箱拦下了这一步,要在沙箱外重跑吗?' : '允许 ' + (ev.tool || '工具') + ' 运行吗?'; $('gate-detail').textContent = ev.preview || ev.detail || ''; const row = $('gate-buttons'); row.textContent = ''; for (const a of GATE_KEYS) { const b = el('button', a.cls, a.label + ' (' + a.key + ')'); b.onclick = () => answerGate(a.v); row.appendChild(b); } $('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。'; $('status').textContent = '等待你的许可…'; let left = ev.timeout || state.perm_timeout || 600; clearInterval(gateTimer); const tick = () => { $('gate-timer').textContent = left >= 0 ? left + 's' : ''; if (left-- <= 0) clearInterval(gateTimer); }; tick(); gateTimer = setInterval(tick, 1000); scrollDown(true); } function hideGate() { clearInterval(gateTimer); gateTimer = null; permReq = null; $('gate').hidden = true; $('gate-timer').textContent = ''; if (!busy) $('status').textContent = '就绪'; } async function answerGate(value) { if (!permReq) return; const id = permReq.id; hideGate(); try { await post('api/permission', { id, answer: value }); $('status').textContent = value ? '已允许,继续执行…' : '已拒绝'; } catch (e) { toast('回答失败:' + errMsg(e)); } } window.addEventListener('keydown', (e) => { if (lb) { if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); stepLightbox(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); stepLightbox(1); } return; } /* typing "3" or "y" into the box must not grant a permission */ const typing = e.isComposing || e.ctrlKey || e.metaKey || e.altKey || (e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName))); if (permReq && !$('gate').hidden && !typing) { if (e.key === '1' || e.key.toLowerCase() === 'y') answerGate(1); else if (e.key === '2') answerGate(2); else if (e.key === '3') answerGate(3); else if (e.key === '4' || e.key.toLowerCase() === 'n') answerGate(0); } if (e.key === 'Escape') { if (!$('modal').hidden) return closeModal(); if (!$('pop').hidden) return closePop(); if (editing) return cancelEdit(); if (busy) return void post('api/cancel', {}).catch(toastErr); } if (e.ctrlKey && e.key.toLowerCase() === 'b') { e.preventDefault(); toggleSide(); } if (e.ctrlKey && e.key.toLowerCase() === 'j') { e.preventDefault(); togglePanel(); } if (e.ctrlKey && e.key.toLowerCase() === 'k') { e.preventDefault(); showSessions(); } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'p') { e.preventDefault(); showPalette(); } if (e.key === 'F1' || (e.key === '?' && !typing)) { e.preventDefault(); showHelp(); } /* the composer handles its own Shift+Tab; a popover keeps it for focus */ if (e.key === 'Tab' && e.shiftKey && !typing && $('modal').hidden && $('pop').hidden) { e.preventDefault(); cycleApproval(); } }); /* --- modal sheet (session list) ------------------------------------------ */ function openModal(node, wide) { const box = $('modal-box'); box.textContent = ''; box.classList.toggle('wide', !!wide); box.appendChild(node); $('modal').hidden = false; } function closeModal() { $('modal').hidden = true; $('modal-box').textContent = ''; askReq = null; } function showAsk(ev) { askReq = ev; const box = document.createDocumentFragment(); box.appendChild(el('h3', null, ev.question || '模型想确认一件事')); if (ev.detail && ev.detail !== ev.question) box.appendChild(el('pre', 'step-out', ev.detail)); const send = (text, dismiss) => { post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(toastErr); closeModal(); }; const row = el('div', 'buttons'); for (const opt of ev.options || []) row.appendChild(Object.assign(el('button', 'ok', opt), { onclick: () => send(opt, false) })); const free = el('input'); free.placeholder = '自定义回答'; row.appendChild(free); row.appendChild(Object.assign(el('button', 'ok', '回答'), { onclick: () => send(free.value, false) })); row.appendChild(Object.assign(el('button', 'danger', '忽略'), { onclick: () => send('', true) })); box.appendChild(row); openModal(box); setTimeout(() => free.focus(), 0); } /* --- sessions ------------------------------------------------------------ */ let sessionsCache = []; function timeLabel(id) { const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/.exec(id || ''); return m ? `${+m[2]}/${+m[3]} ${m[4]}:${m[5]}` : ''; } let sessionsCurrent = ''; const sessMatch = (s, q) => !q || (s.title || '').toLowerCase().includes(q) || s.id.includes(q); async function openSession(id) { if (id === (state.session || '')) return; await post('api/sessions/switch', { id }); await loadSession(); await loadSessions(); } function renameSession(s) { const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '重命名会话')); box.appendChild(el('p', 'muted tiny', s.id)); const input = el('input', 'key-input'); input.value = s.title || ''; input.placeholder = '留空则恢复为第一条消息'; box.appendChild(input); const err = el('div', 'key-err'); box.appendChild(err); const row = el('div', 'buttons'); const go = async () => { try { await post('api/sessions/rename', { id: s.id, title: input.value.trim() }); closeModal(); loadSessions(); } catch (e) { err.textContent = errMsg(e); } }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); }); row.appendChild(Object.assign(el('button', 'ok', '保存'), { onclick: go })); row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); box.appendChild(row); openModal(box); setTimeout(() => { input.focus(); input.select(); }, 0); } function deleteSession(s) { const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '删除会话?')); box.appendChild(el('p', null, '「' + (s.title || s.id) + '」会被永久删除' + (s.id === state.session ? ';这是当前会话,删除后会开一个新的。' : '。'))); const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', 'danger', '删除'), { onclick: async () => { try { await post('api/sessions/delete', { id: s.id }); closeModal(); toast('已删除', 'ok'); loadSessions(); } catch (e) { closeModal(); toastErr(e); } }, })); row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); box.appendChild(row); openModal(box); } function sessionMenu(anchor, s) { openPop(anchor, (pop) => { pop.appendChild(popRow({ icon: 'file', title: '重命名', onClick: () => renameSession(s) })); if (s.id === state.session) pop.appendChild(popRow({ icon: 'copy', title: '导出 Markdown', onClick: exportSession })); pop.appendChild(popRow({ icon: 'close', title: '删除', danger: true, onClick: () => deleteSession(s) })); }, { align: 'right' }); } function renderSessionList() { const box = $('sessions-list'); const q = ($('sess-search').value || '').trim().toLowerCase(); box.textContent = ''; const shown = sessionsCache.filter((s) => sessMatch(s, q)).slice(0, q ? 200 : 60); for (const s of shown) { const b = el('div', 'sess-item' + (s.id === sessionsCurrent ? ' on' : '')); b.tabIndex = 0; b.setAttribute('role', 'button'); const t = el('span', 't'); const ts = el('span', 'ts', s.title || '(空会话)'); t.appendChild(ts); b.appendChild(t); b.appendChild(el('span', 'n', s.messages + ' 条 · ' + (timeLabel(s.id) || ''))); const more = el('button', 'more'); more.title = '更多'; more.appendChild(icon('more')); more.onclick = (e) => { e.stopPropagation(); sessionMenu(more, s); }; b.appendChild(more); b.title = s.id + ' · ' + s.messages + ' 条消息'; const open = async () => { try { await openSession(s.id); closeDrawers(); } catch (e) { toastErr(e); } }; b.onclick = open; b.onkeydown = (e) => { if (e.key === 'Enter' && e.target === b) open(); }; b.oncontextmenu = (e) => { e.preventDefault(); sessionMenu(more, s); }; box.appendChild(b); } if (!shown.length) box.appendChild(el('div', 'muted tiny', q ? '没有匹配的会话' : '还没有历史会话')); /* measure after layout: only rows whose title really overflows get the scroll */ requestAnimationFrame(() => { for (const t of box.querySelectorAll('.t')) { const ts = t.firstChild; if (ts && ts.scrollWidth > t.clientWidth + 2) { t.classList.add('mq'); t.style.setProperty('--mq', (ts.scrollWidth - t.clientWidth + 6) + 'px'); } } }); } async function loadSessions() { let data; try { data = await getJSON('api/sessions'); } catch (e) { toastErr(e); return; } sessionsCache = data.sessions || []; sessionsCurrent = data.current || ''; renderSessionList(); } async function showSessions() { await loadSessions(); const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '会话')); const search = el('input', 'key-input'); search.placeholder = '搜索标题或 ID'; box.appendChild(search); const list = el('div', 'sess-list'); box.appendChild(list); const draw = () => { list.textContent = ''; const q = search.value.trim().toLowerCase(); const shown = sessionsCache.filter((s) => sessMatch(s, q)); for (const s of shown) { const row = el('div', 'sess'); row.appendChild(el('span', 'id', (s.id === state.session ? '● ' : ' ') + s.id.slice(0, 12))); row.appendChild(el('span', 't', s.title || '(空会话)')); row.appendChild(el('span', 'id', s.messages + ' 条')); row.appendChild(Object.assign(el('button', null, '打开'), { onclick: async () => { try { closeModal(); await openSession(s.id); } catch (e) { toastErr(e); } }, })); row.appendChild(Object.assign(el('button', null, '重命名'), { onclick: () => renameSession(s) })); row.appendChild(Object.assign(el('button', 'danger', '删除'), { onclick: () => deleteSession(s) })); list.appendChild(row); } if (!shown.length) list.appendChild(el('div', 'muted tiny', q ? '没有匹配的会话' : '还没有历史会话')); }; search.addEventListener('input', draw); draw(); const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', 'ok', '新建会话'), { onclick: async () => { try { await post('api/sessions/new', {}); closeModal(); await loadSession(); await loadSessions(); } catch (e) { toastErr(e); } }, })); row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row); openModal(box); setTimeout(() => search.focus(), 0); } /* --- pickers as popovers ------------------------------------------------- */ /* "1M" / "200K" for a context window in tokens */ function fmtCtx(n) { if (!n) return ''; if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + 'M'; return Math.round(n / 1000) + 'K'; } function modelDesc(m) { const parts = []; if (m.context) parts.push(fmtCtx(m.context) + ' 上下文'); if (m.vision === true) parts.push('识图'); else if (m.vision === false) parts.push('纯文本'); if (m.reasoning) parts.push('推理强度'); if (m.price) parts.push((m.price.currency || '') + m.price.miss + '/' + m.price.out + ' 每百万'); parts.push(m.api ? '提供商列出' : '内置'); return parts.join(' · '); } let modelsCache = null; /* {provider, data} of the last GET api/models */ async function fetchModels(refresh) { const r = await api('api/models' + (refresh ? '?refresh=1' : '')); let d = null; try { d = await readReply(r); } catch (e) { /* 401 = the key was rejected: the body still has the built-in list */ if (e.status !== 401) throw e; d = e.data || {}; } modelsCache = { provider: d.provider, data: d }; return d; } function setModel(id) { post('api/config', { model: id }) .then(() => { toast('模型 → ' + id, 'ok'); }) .catch(toastErr); } function modelPopover(anchor) { openPop(anchor, (pop) => { const head = el('div', 'pop-title pop-head'); head.appendChild(el('span', null, '模型 · ' + (state.provider || '-'))); const rb = el('button', 'icon tiny'); rb.title = '从提供商重新拉取模型列表'; rb.appendChild(icon('refresh')); head.appendChild(rb); pop.appendChild(head); const row = el('div', 'pop-input'); const input = el('input'); input.placeholder = '搜索,或直接输入模型名'; input.spellcheck = false; row.appendChild(input); pop.appendChild(row); const note = el('div', 'pop-note'); pop.appendChild(note); const list = el('div', 'pop-list'); pop.appendChild(list); let data = (modelsCache && modelsCache.provider === state.provider) ? modelsCache.data : null; let loading = false; const pick = (id) => { closePop(); if (id && id !== state.model) setModel(id); }; const render = () => { list.textContent = ''; const q = input.value.trim(); const ql = q.toLowerCase(); const ms = (data && data.models) || []; const shown = ms.filter((m) => !ql || m.id.toLowerCase().includes(ql)).slice(0, 300); for (const m of shown) list.appendChild(popRow({ title: m.id, desc: modelDesc(m), on: m.id === state.model, onClick: () => pick(m.id) })); if (q && !ms.some((m) => m.id === q)) list.appendChild(popRow({ icon: 'plus', title: '使用「' + q + '」', desc: '不在列表里也可以直接用', onClick: () => pick(q) })); if (!list.children.length) list.appendChild(el('div', 'pop-empty', loading ? '加载中…' : '没有匹配的模型')); }; const setNote = () => { note.textContent = ''; note.className = 'pop-note'; if (loading) { note.textContent = '正在向 ' + (state.provider || '提供商') + ' 拉取模型列表…'; return; } if (!data) return; if (data.auth) { note.classList.add('bad'); note.appendChild(el('span', null, data.error || 'API key 被拒绝')); const fix = el('button', 'link', '更换 key'); fix.onclick = (e) => { e.stopPropagation(); closePop(); changeKey(); }; note.appendChild(fix); } else if (data.api_error) { note.classList.add('warn'); note.textContent = data.api_error + ';下面是内置列表'; } else if (!data.key_set) { note.classList.add('warn'); note.textContent = '还没有 API key,只能显示内置列表'; } else if (data.api_count >= 0) { note.textContent = data.provider + ' 列出 ' + data.api_count + ' 个模型'; } }; const load = (refresh) => { loading = true; setNote(); render(); fetchModels(refresh) .then((d) => { data = d; }) .catch((e) => { data = data || { models: [] }; toastErr(e); }) .finally(() => { loading = false; setNote(); render(); }); }; rb.onclick = (e) => { e.stopPropagation(); load(true); }; input.addEventListener('input', render); input.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; const q = input.value.trim(); const ms = (data && data.models) || []; const hit = ms.filter((m) => m.id.toLowerCase().includes(q.toLowerCase())); pick(hit.length === 1 ? hit[0].id : q); }); if (data) { setNote(); render(); } else load(false); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(el('div', 'pop-title', '推理强度')); if (state.effort_applies === false) pop.appendChild(el('div', 'pop-note warn', '当前模型不接受推理强度参数:可以先选好,换到支持的模型时生效')); const LV = { low: 'lv1', medium: 'lv2', high: 'lv3', max: 'lv4' }; for (const e2 of EFFORTS) { const r = popRow({ icon: LV[e2.key] || 'lv1', title: e2.title, desc: e2.desc, on: e2.key === state.effort, onClick: () => post('api/config', { effort: e2.key }) .then((d) => { if (d && d.warning) toast(d.warning, 'warn'); }) .catch(toastErr), }); if (state.effort_applies === false) r.classList.add('dim'); pop.appendChild(r); } pop.appendChild(el('div', 'pop-sep')); pop.appendChild(popRow({ icon: 'cpu', title: '提供商:' + (state.provider || '-'), desc: (state.api_base || '') + ' · 点这里切换', onClick: showProviders, })); setTimeout(() => input.focus(), 0); }); } /* --- providers and keys -------------------------------------------------- */ function keyFromText(p) { if (!p.has_key) return '未设置 key'; if (p.key_from === 'saved') return 'key 已保存'; if (p.key_from === 'session') return 'key 仅本次运行'; return 'key 来自环境变量 ' + p.key_from; } /* one password field; submit(key) returns a promise, a rejection stays in * the dialog so the user can paste another key */ function keyDialog(title, sub, submit) { const box = document.createDocumentFragment(); box.appendChild(el('h3', null, title)); if (sub) box.appendChild(el('div', 'muted tiny', sub)); box.appendChild(el('div', 'muted tiny', 'key 会先用 /models 验证,再保存到配置目录的 credentials 文件(仅你可读),不会出现在页面、日志或事件里。')); const input = el('input', 'key-input'); input.type = 'password'; input.autocomplete = 'off'; input.placeholder = 'sk-…'; box.appendChild(input); const err = el('div', 'key-err'); box.appendChild(err); const row = el('div', 'buttons'); const ok = el('button', 'ok', '验证并保存'); const go = async () => { const k = input.value.trim(); if (!k) { err.textContent = '请先粘贴 key'; return; } ok.disabled = true; err.textContent = '验证中…'; try { await submit(k); input.value = ''; closeModal(); } catch (e) { err.textContent = errMsg(e); ok.disabled = false; input.select(); } }; ok.onclick = go; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); }); row.appendChild(ok); row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); box.appendChild(row); openModal(box); setTimeout(() => input.focus(), 0); } function afterSwitch(r) { modelsCache = null; toast('已切换到 ' + r.provider + (r.model ? ' · ' + r.model : '(请选一个模型)'), 'ok'); if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200); } function askKeyFor(p) { keyDialog('API key · ' + p.name, p.api_base, async (key) => { afterSwitch(await post('api/provider', { name: p.name, key })); }); } async function switchProvider(p) { if (!p.has_key) return askKeyFor(p); try { closeModal(); afterSwitch(await post('api/provider', { name: p.name })); } catch (e) { if (e.data && e.data.need_key) return askKeyFor(p); toastErr(e); } } async function showProviders() { let d; try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); } const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '切换提供商')); for (const p of d.providers || []) { const row = el('div', 'prov' + (p.current ? ' on' : '')); const m = el('div', 'pm'); m.appendChild(el('b', null, p.name + (p.current ? ' · 当前' : ''))); m.appendChild(el('span', 'muted tiny', p.api_base)); m.appendChild(el('span', 'tiny ' + (p.has_key ? 'ok-text' : 'warn-text'), keyFromText(p) + ' · ' + (p.model || '模型从 API 获取') + (p.source === 'user' ? ' · models.json' : ''))); row.appendChild(m); const acts = el('div', 'prov-acts'); if (p.current) acts.appendChild(Object.assign(el('button', null, '换 key'), { onclick: changeKey })); else acts.appendChild(Object.assign(el('button', 'ok', p.has_key ? '切换' : '设 key 并切换'), { onclick: () => switchProvider(p), })); row.appendChild(acts); box.appendChild(row); } const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row); openModal(box); } function changeKey() { keyDialog('更换 API key · ' + (state.provider || ''), state.api_base || '', async (key) => { const r = await post('api/key', { key }); modelsCache = null; toast('key 已验证并保存', 'ok'); if (r.warning) setTimeout(() => toast(r.warning, 'warn'), 1200); }); } function approvalPopover(anchor) { openPop(anchor, (pop) => { pop.appendChild(el('div', 'pop-title', '审批权限')); for (const a of APPROVALS) pop.appendChild(popRow({ icon: a.icon, title: a.title, desc: a.desc, on: a.key === (state.approval || 'auto'), danger: a.key === 'yolo', onClick: () => post('api/config', { approval: a.key }).catch(toastErr), })); }, { align: 'right' }); } function plusPopover(anchor) { openPop(anchor, (pop) => { pop.appendChild(popRow({ icon: 'image', title: '上传图片', desc: '也可以直接粘贴或拖进来', onClick: () => $('file').click(), })); pop.appendChild(popRow({ icon: 'clipboard', title: '粘贴剪贴板', desc: '从剪贴板里取一张图', onClick: async () => { try { const items = await navigator.clipboard.read(); for (const it of items) { const type = it.types.find((t) => t.startsWith('image/')); if (!type) continue; const blob = await it.getType(type); await upload(new File([blob], 'clipboard.png', { type })); return; } $('status').textContent = '剪贴板里没有图片'; } catch { $('status').textContent = '浏览器不允许读剪贴板,请直接粘贴'; } }, })); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(popRow({ icon: 'list', title: '计划模式', desc: '只读:先出方案,不改文件', on: !!state.plan_mode, onClick: () => post('api/config', { plan_mode: !state.plan_mode }).catch(toastErr), })); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(el('div', 'pop-title', '工作目录')); const wrow = el('div', 'pop-input'); const wpath = el('input'); wpath.value = state.workspace || ''; wpath.placeholder = '/绝对路径/到项目'; wpath.spellcheck = false; const wgo = el('button', null, '切换'); wgo.onclick = async () => { const p = wpath.value.trim(); if (!p) return; wgo.disabled = true; try { await post('api/workspace', { path: p }); closePop(); await loadSession(); await loadSessions(); $('status').textContent = '工作目录已切换'; } catch (e) { toastErr(e); wgo.disabled = false; } }; wrow.appendChild(wpath); wrow.appendChild(wgo); pop.appendChild(wrow); }); } function usagePopover(anchor) { const u = state.usage || {}; openPop(anchor, (pop) => { pop.appendChild(el('div', 'pop-title', '状态')); const kv = (k, v) => { const row = el('div', 'kv'); row.appendChild(el('span', null, k)); row.appendChild(el('b', null, v)); pop.appendChild(row); }; kv('工作区', state.workspace || '-'); kv('会话', state.session || '-'); const mi = state.model_info || {}; kv('上下文占用', num(state.context_tokens) + (mi.context ? ' / ' + fmtCtx(mi.context) : '') + ' tok'); kv('模型往返', String(u.requests || 0)); kv('Token', num(u.prompt_tokens) + ' / ' + num(u.completion_tokens)); kv('费用', (u.priced && u.cost) ? (u.currency || '') + u.cost.toFixed(4) : (u.requests ? '未定价' : '-')); const t = state.turn_usage || {}; if (t.requests) kv('上一轮', t.requests + ' 往返 · ' + num(t.prompt_tokens) + ' / ' + num(t.completion_tokens) + ((t.priced && t.cost) ? ' · ' + (t.currency || '') + t.cost.toFixed(4) : '')); kv('工具协议', state.protocol_tools || '-'); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') })); pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', desc: '把旧消息收成摘要', onClick: compact })); }, { align: 'right' }); } function menuPopover(anchor) { openPop(anchor, (pop) => { pop.appendChild(popRow({ icon: 'list', title: '会话列表', desc: 'Ctrl+K', onClick: showSessions })); pop.appendChild(popRow({ icon: 'bolt', title: '命令面板', desc: 'Ctrl+P · 输入框里打 / 也行', onClick: showPalette })); pop.appendChild(popRow({ icon: 'cpu', title: '扩展', desc: 'MCP、插件、skills、子代理', onClick: () => showExtensions() })); pop.appendChild(popRow({ icon: 'copy', title: '复制会话 ID', onClick: () => copyText(state.session || '') })); pop.appendChild(popRow({ icon: 'refresh', title: '重新生成上一轮', desc: '删掉上一轮回答,用同一句话再问', onClick: () => retry() })); pop.appendChild(popRow({ icon: 'file', title: '导出 Markdown', desc: '下载当前会话', onClick: exportSession })); pop.appendChild(popRow({ icon: 'compress', title: '压缩上下文', onClick: compact })); pop.appendChild(popRow({ icon: 'undo', title: '撤销上一次写入', desc: '把文件恢复到工具改写之前', onClick: undo, })); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(popRow({ icon: 'list', title: '思考过程默认展开', desc: '会记住,TUI 也用这个设置', on: !!state.show_thinking, onClick: () => setThinking(), })); pop.appendChild(popRow({ icon: 'list', title: '展开全部工具输出', desc: '工具调用默认展开到参数和输出', on: detailsAll, onClick: () => setDetails(), })); pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); pop.appendChild(popRow({ icon: 'alert', title: '快捷键与命令', desc: 'F1', onClick: showHelp })); pop.appendChild(popRow({ icon: 'close', title: '退出服务', danger: true, onClick: () => post('api/quit', {}).then(() => toast('服务已退出', 'ok')).catch(toastErr) })); }, { align: 'right' }); } function showSettings() { openPop($('btn-settings'), (pop) => { pop.appendChild(el('div', 'pop-title', '工作区')); const kv = (k, v) => { const row = el('div', 'kv'); row.appendChild(el('span', null, k)); row.appendChild(el('b', null, v || '-')); pop.appendChild(row); }; kv('目录', state.workspace); kv('会话', state.session); kv('提供商', state.provider); kv('端点', state.api_base); kv('API key', keyFromText({ has_key: state.key_set, key_from: state.key_from })); kv('模型', state.model); kv('工具协议', state.protocol_tools); kv('工具集', state.tool_profile); kv('每轮最多步数', String(state.max_steps || '-')); pop.appendChild(el('div', 'pop-sep')); pop.appendChild(popRow({ icon: 'cpu', title: '模型与推理', onClick: () => modelPopover($('chip-model')) })); pop.appendChild(popRow({ icon: 'refresh', title: '切换提供商', desc: '换端点,需要时输入 key', onClick: showProviders })); pop.appendChild(popRow({ icon: 'shield', title: '更换 API key', desc: '验证后保存,只对当前提供商', onClick: changeKey })); pop.appendChild(popRow({ icon: 'shield', title: '审批权限', onClick: () => approvalPopover($('chip-approval')) })); pop.appendChild(popRow({ icon: 'settings', title: '运行设置…', desc: '工具协议、步数、工具开关、确认项、沙箱、已保存的许可', onClick: showRunSettings })); pop.appendChild(popRow({ icon: 'file', title: '工作区改动', desc: 'git status + diff', onClick: () => showDiff() })); pop.appendChild(popRow({ icon: 'cpu', title: '扩展…', desc: 'MCP、插件、skills、子代理;信任工作区', onClick: () => showExtensions() })); pop.appendChild(popRow({ icon: 'moon', title: '切换主题', onClick: toggleTheme })); }); } const PERM_ZH = { read: '只读', write: '写文件', shell: '命令', net: '联网', ask: '提问', meta: '内部' }; async function showRunSettings() { let s, perms; try { [s, perms] = await Promise.all([getJSON('api/settings'), getJSON('api/permissions')]); } catch (e) { return toast('读取设置失败:' + errMsg(e)); } const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '运行设置')); box.appendChild(el('p', 'muted tiny', '只对这次运行生效,和 TUI 里的 /protocol、/tools、/max-steps 一样;有轮次在跑时不能改。')); const field = (label, input) => { const f = el('label', 'field'); f.appendChild(el('span', null, label)); f.appendChild(input); box.appendChild(f); return input; }; const select = (opts, cur) => { const sel = el('select'); for (const o of opts) sel.appendChild(Object.assign(el('option', null, o), { value: o, selected: o === cur })); return sel; }; const proto = field('工具协议', select(s.protocols || [], s.protocol)); const steps = field('每轮步数', Object.assign(el('input'), { type: 'number', min: 1, max: 1000, value: s.max_steps })); const prof = field('工具集', select(s.profiles || [], s.tool_profile)); box.appendChild(el('h4', 'sub', '需要确认(审批模式 ' + (s.approval || '-') + ')')); const conf = {}; const confRow = el('div', 'checks'); for (const [k, label] of [['shell', '运行命令'], ['write', '改文件'], ['net', '联网']]) { const l = el('label', 'check'); const cb = Object.assign(el('input'), { type: 'checkbox', checked: !!(s.confirm || {})[k] }); conf[k] = cb; l.appendChild(cb); l.appendChild(el('span', null, label)); confRow.appendChild(l); } box.appendChild(confRow); box.appendChild(el('h4', 'sub', '工具开关')); const toolBoxes = new Map(); const tl = el('div', 'tool-list'); for (const t of s.tools || []) { const l = el('label', 'tool-row' + (t.on ? '' : ' dim')); const cb = Object.assign(el('input'), { type: 'checkbox', checked: !t.off }); toolBoxes.set(t.name, cb); l.appendChild(cb); l.appendChild(el('code', null, t.name)); l.appendChild(el('span', 'perm', PERM_ZH[t.perm] || t.perm)); const desc = el('span', 'desc', (t.owner ? '[' + t.owner + '] ' : '') + (t.desc || '')); desc.title = desc.textContent; l.appendChild(desc); tl.appendChild(l); } box.appendChild(tl); const sb = s.sandbox || {}; box.appendChild(el('h4', 'sub', '命令沙箱')); box.appendChild(el('p', sb.active ? 'ok-text tiny' : 'warn-text tiny', sb.active ? '已启用:' + sb.kind + '(命令只能写工作区和临时目录)' : '不可用:按规则审批命令(' + (sb.kind || 'none') + ')')); if (sb.labels) box.appendChild(el('pre', 'step-out', sb.labels)); box.appendChild(el('h4', 'sub', '已保存的"总是允许"')); const rules = el('div', 'tiny'); const drawRules = (p) => { rules.textContent = ''; const tools = p.tools || [], pre = p.shell_prefixes || []; rules.appendChild(el('div', null, '工具:' + (tools.length ? tools.join('、') : '(无)'))); rules.appendChild(el('div', null, '命令前缀:' + (pre.length ? pre.map((x) => '`' + x + '`').join('、') : '(无)'))); rules.appendChild(el('div', 'muted', '保存在 ' + (p.file || '.agent/permissions.json'))); }; drawRules(perms); box.appendChild(rules); const clear = Object.assign(el('button', 'link danger-text', '全部清除'), { onclick: async () => { try { await post('api/permissions', undefined, 'DELETE'); drawRules(await getJSON('api/permissions')); toast('已清除保存的许可规则', 'ok'); } catch (e) { toastErr(e); } }, }); box.appendChild(clear); const err = el('div', 'key-err'); box.appendChild(err); const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', 'ok', '保存'), { onclick: async () => { const body = {}; if (proto.value !== s.protocol) body.protocol = proto.value; if (+steps.value !== s.max_steps) body.max_steps = +steps.value; if (prof.value !== s.tool_profile) body.tool_profile = prof.value; const c = {}; for (const k of Object.keys(conf)) if (conf[k].checked !== !!(s.confirm || {})[k]) c[k] = conf[k].checked; if (Object.keys(c).length) body.confirm = c; const tools = {}; for (const t of s.tools || []) { const on = toolBoxes.get(t.name).checked; if (on === !!t.off) tools[t.name] = on; } if (Object.keys(tools).length) body.tools = tools; if (!Object.keys(body).length) return closeModal(); try { await post('api/settings', body); closeModal(); toast('设置已更新', 'ok'); } catch (e) { err.textContent = errMsg(e); } }, })); row.appendChild(Object.assign(el('button', null, '取消'), { onclick: closeModal })); box.appendChild(row); openModal(box, true); } async function copyText(t) { try { await navigator.clipboard.writeText(t); $('status').textContent = '已复制:' + t; } catch { $('status').textContent = '复制失败'; } } /* --- composer ------------------------------------------------------------ */ function syncSend() { const v = $('input').value; /* commands also work while a turn runs (/stop, /help, …) */ $('btn-send').disabled = (busy && !v.startsWith('/')) || !v.trim(); } async function compact() { $('status').textContent = '压缩中…'; try { await post('api/compact', {}); $('status').textContent = '上下文已压缩'; } catch (e) { $('status').textContent = '就绪'; toast('压缩失败:' + errMsg(e)); } } async function undo() { try { const t = await post('api/undo', {}); $('status').textContent = '已撤销:' + t; } catch (e) { toast('撤销失败:' + errMsg(e)); } } let editing = false; /* the composer holds the last prompt, to replace it */ function setEditing(on) { editing = on; $('edit-bar').hidden = !on; } function cancelEdit() { setEditing(false); $('input').value = ''; autoGrow(); syncSend(); } function editLast(node) { if (busy) return toast('等这一轮结束再编辑'); const body = node.querySelector('.body'); const input = $('input'); input.value = body ? body.textContent : ''; setEditing(true); autoGrow(); syncSend(); input.focus(); } function freshTurn(text, srcs) { sealProc(); sealLive(); revokeAll(shownPreviews); shownPreviews = srcs || []; userBubble(text, shownPreviews); stepIndex = new Map(); files = new Map(); turnErr = null; lastTurn = null; renderFilesPanel(); turnStart = Date.now(); } /* drop the newest prompt and everything after it from the page, as the * server does with the session */ function cutLastTurn() { const prompts = log.querySelectorAll('.msg[data-prompt]'); const p = prompts[prompts.length - 1]; if (!p) return ''; const body = p.querySelector('.body'); const text = body ? body.textContent : ''; while (p.nextSibling) p.nextSibling.remove(); p.remove(); return text; } /* the page is cut before the request: the new turn's events can arrive * before its reply does */ async function retry(text) { if (busy) return toast('等这一轮结束再重试'); if (!log.querySelector('.msg[data-prompt]')) return toast('没有可以重试的消息'); const old = cutLastTurn(); freshTurn(text !== undefined ? text : old); try { await post('api/retry', text === undefined ? {} : { text }); if (busy) $('status').textContent = text === undefined ? '重新生成中…' : '已重发…'; } catch (e) { toast('重试失败:' + errMsg(e)); turnStart = 0; loadSession().catch(toastErr); } } async function send(text) { const input = $('input'); const raw = text !== undefined ? text : input.value; const body = raw.trim(); if (!body) return; if (raw.startsWith('/') && !editing) { const r = runSlash(body); if (r === 'keep') return; if (r) { if (text === undefined) input.value = ''; autoGrow(); syncSend(); return; } } if (busy) return; if (text === undefined) input.value = ''; autoGrow(); syncSend(); if (editing) { setEditing(false); return retry(body); } /* the images go out with this prompt: draw them now from the local copies */ const pend = (state.pending_images || []).length; let srcs = []; if (pend && pendingPreviews.length === pend) { srcs = pendingPreviews; pendingPreviews = []; } freshTurn(body, srcs); try { await post('api/turn', { text: body }); if (busy) $('status').textContent = '已开始…'; } catch (e) { if (srcs.length && !pendingPreviews.length && shownPreviews === srcs) { pendingPreviews = srcs; /* still waiting on the server: keep the thumbnails */ shownPreviews = []; renderPending(); } bubble('error').body.textContent = errMsg(e); } } async function exportSession() { try { const r = await api('api/export'); const md = await readReply(r); const a = el('a'); a.href = URL.createObjectURL(new Blob([md], { type: 'text/markdown' })); a.download = (state.session || 'session') + '.md'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } catch (e) { toast('导出失败:' + errMsg(e)); } } /* --- commands: "/name args" in the composer, Ctrl+P, F1 ------------------- */ async function newSession() { try { await post('api/sessions/new', {}); } catch (e) { return toastErr(e); } await loadSession(); await loadSessions(); closeDrawers(); } function setComposer(text) { const input = $('input'); input.value = text; autoGrow(); syncSend(); input.focus(); input.setSelectionRange(text.length, text.length); slashSuggest(); } function cycleApproval() { const keys = APPROVALS.map((a) => a.key); const next = keys[(keys.indexOf(state.approval || 'auto') + 1) % keys.length]; setApproval(next); } function setApproval(a) { if (!a) return approvalPopover($('chip-approval')); const ap = APPROVALS.find((x) => x.key === a); if (!ap) return toast('审批模式只能是 ask / auto / yolo'); post('api/config', { approval: a }) .then(() => toast('审批:' + ap.title + ' · ' + ap.desc, a === 'yolo' ? 'warn' : 'ok')) .catch(toastErr); } function setEffort(a) { if (!a) return modelPopover($('chip-model')); if (!EFFORTS.some((x) => x.key === a)) return toast('推理强度只能是 low / medium / high / max'); post('api/config', { effort: a }) .then((r) => toast(r && r.warning ? r.warning : '推理强度 → ' + a, r && r.warning ? 'warn' : 'ok')) .catch(toastErr); } function setPlan(on) { post('api/config', { plan_mode: on }) .then(() => toast(on ? '计划模式:只读,先出方案' : '构建模式:可以改文件', 'ok')) .catch(toastErr); } /* "on" / "off" / anything else flips */ const onOffArg = (a, cur) => (a === 'on' ? true : a === 'off' ? false : !cur); function setThinking(a) { const on = onOffArg(a, !!state.show_thinking); post('api/config', { show_thinking: on }) .then(() => toast(on ? '思考过程默认展开' : '思考过程默认收起', 'ok')) .catch(toastErr); } function setDetails(a) { detailsAll = onOffArg(a, detailsAll); localStorage.setItem('cst.details', detailsAll ? '1' : '0'); for (const d of log.querySelectorAll('details.step-line')) d.open = detailsAll; toast(detailsAll ? '工具输出全部展开' : '工具输出全部收起', 'ok'); } async function providerCmd(a) { if (!a) return showProviders(); let d; try { d = await getJSON('api/providers'); } catch (e) { return toastErr(e); } const p = (d.providers || []).find((x) => x.name.toLowerCase() === a.toLowerCase()); if (!p) return toast('没有叫 ' + a + ' 的提供商(/provider 打开列表)'); switchProvider(p); } function renameCurrent(a) { if (a) { return post('api/sessions/rename', { title: a }) .then(() => { toast('已重命名', 'ok'); loadSessions(); }) .catch(toastErr); } renameSession(sessionsCache.find((s) => s.id === state.session) || { id: state.session || '', title: '' }); } function editLastPrompt() { const ps = log.querySelectorAll('.msg[data-prompt]'); if (!ps.length) return toast('还没有可以编辑的消息'); editLast(ps[ps.length - 1]); } async function copyLastAnswer() { const as = log.querySelectorAll('.msg.assistant .body'); if (!as.length) return toast('还没有回答'); try { await navigator.clipboard.writeText(as[as.length - 1].innerText); toast('已复制最后一条回答', 'ok'); } catch { toast('浏览器不允许写剪贴板'); } } /* /init and /skill: the server builds the prompt and starts the turn */ async function startCmdTurn(label, path, body) { if (busy) return toast('等这一轮结束再试'); freshTurn(label); try { await post(path, body || {}); if (busy) $('status').textContent = '已开始…'; } catch (e) { toast(errMsg(e)); turnStart = 0; loadSession().catch(toastErr); } } function runSkill(a) { if (!a) return showExtensions('skills'); const name = a.split(/\s+/)[0]; startCmdTurn('/skill ' + a, 'api/skill', { name, task: a.slice(name.length).trim() }); } /* completions for the argument; null = none (or still loading) */ let extCache = null, providersCache = null, completing = false; const fetched = new Set(); /* one try per source: a failed fetch is not retried per keystroke */ function fetchOnce(key, load) { if (completing || fetched.has(key)) return; fetched.add(key); completing = true; load().catch(() => {}).finally(() => { completing = false; slashSuggest(); }); } const ON_OFF = () => [{ value: 'on', desc: '开' }, { value: 'off', desc: '关' }]; function completeSkill(arg) { if (/\s/.test(arg)) return null; if (!extCache) { fetchOnce('ext', async () => { try { extCache = await getJSON('api/extensions'); } catch { extCache = { skills: [] }; } }); return null; } return (extCache.skills || []).map((s) => ({ value: s.name, desc: s.desc })); } function completeModel() { if (!modelsCache || modelsCache.provider !== state.provider) { fetchOnce('models:' + state.provider, () => fetchModels(false)); return null; } return ((modelsCache.data || {}).models || []).map((m) => ({ value: m.id, desc: m.label || '' })); } function completeProvider() { if (!providersCache) { fetchOnce('providers', async () => { try { providersCache = (await getJSON('api/providers')).providers || []; } catch { providersCache = []; } }); return null; } return providersCache.map((p) => ({ value: p.name, desc: p.api_base })); } const COMMANDS = [ { name: 'help', aliases: ['keys'], desc: '快捷键和命令列表', key: 'F1', run: () => showHelp() }, { name: 'new', aliases: ['clear'], desc: '新建会话', run: newSession }, { name: 'sessions', desc: '会话列表:搜索、切换、重命名、删除', key: 'Ctrl+K', run: () => showSessions() }, { name: 'rename', args: '[标题]', desc: '重命名当前会话(不带标题打开对话框)', run: renameCurrent }, { name: 'retry', desc: '重新生成上一轮', run: () => retry() }, { name: 'edit', desc: '编辑最后一句再发', run: editLastPrompt }, { name: 'stop', desc: '停止当前一轮', key: 'Esc', run: () => post('api/cancel', {}).catch(toastErr) }, { name: 'undo', desc: '撤销上一次写入', run: undo }, { name: 'compact', desc: '把旧消息压成摘要,腾出上下文', run: compact }, { name: 'export', desc: '下载当前会话的 Markdown', run: exportSession }, { name: 'copy', desc: '复制最后一条回答', run: copyLastAnswer }, { name: 'diff', args: '[文件]', desc: '工作区改动(git status + diff)', run: (a) => showDiff(a || undefined) }, { name: 'model', args: '[模型]', desc: '换模型(不带参数打开列表)', complete: completeModel, run: (a) => (a ? setModel(a) : modelPopover($('chip-model'))) }, { name: 'effort', args: '[low|medium|high|max]', desc: '推理强度', complete: () => EFFORTS.map((e) => ({ value: e.key, desc: e.title + ' · ' + e.desc })), run: setEffort }, { name: 'provider', args: '[名字]', desc: '切换提供商(不带参数打开列表)', complete: completeProvider, run: providerCmd }, { name: 'key', desc: '更换当前提供商的 API key', run: changeKey }, { name: 'approval', args: '[ask|auto|yolo]', desc: '审批权限', key: 'Shift+Tab', complete: () => APPROVALS.map((a) => ({ value: a.key, desc: a.title + ' · ' + a.desc })), run: setApproval }, { name: 'plan', desc: '计划模式:只读,先出方案', run: () => setPlan(true) }, { name: 'build', desc: '构建模式:可以改文件', run: () => setPlan(false) }, { name: 'settings', aliases: ['protocol', 'tools', 'max-steps', 'permissions'], desc: '运行设置:工具协议、步数、工具开关、确认项、沙箱、许可规则', run: () => showRunSettings() }, { name: 'init', desc: '让 agent 分析仓库,写一份 AGENTS.md', run: () => startCmdTurn('/init', 'api/init') }, { name: 'skill', args: '<名字> [任务]', desc: '带着某个 skill 开始一轮', complete: completeSkill, more: true, run: runSkill }, { name: 'skills', desc: '查看可用的 skills', run: () => showExtensions('skills') }, { name: 'extensions', aliases: ['mcp', 'plugins', 'agents'], desc: 'MCP、插件、skills、子代理;信任工作区', run: (a, name) => showExtensions(name === 'extensions' ? undefined : name) }, { name: 'thinking', args: '[on|off]', desc: '思考过程默认展开/收起(会记住)', complete: ON_OFF, run: setThinking }, { name: 'details', args: '[on|off]', desc: '工具输出全部展开/收起(会记住)', complete: ON_OFF, run: setDetails }, { name: 'theme', desc: '切换深色/浅色', run: toggleTheme }, { name: 'palette', desc: '命令面板', key: 'Ctrl+P', run: () => showPalette() }, ]; const findCmd = (name) => COMMANDS.find((c) => c.name === name || (c.aliases || []).includes(name)); function invokeCmd(c, arg, name) { hideSlash(); try { const r = c.run(arg || '', name || c.name); if (r && r.catch) r.catch(toastErr); } catch (e) { toastErr(e); } } /* true = ran it, 'keep' = an unknown command (left in the box), false = not * a command at all ("/usr/bin 是什么" goes to the model) */ function runSlash(line) { const m = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(line); if (!m) return false; const name = m[1].toLowerCase(); const c = findCmd(name); if (!c) { toast('没有 /' + m[1] + ' 这个命令。F1 看全部命令;想当普通消息发,开头加个空格。', 'warn'); return 'keep'; } invokeCmd(c, (m[2] || '').trim(), name); return true; } /* the list above the composer: command names, then argument values */ let slashItems = [], slashSel = 0; function hideSlash() { $('slash').hidden = true; slashItems = []; } function slashSuggest() { const v = $('input').value; const m = /^\/([^\s/]*)(?: ([^\n]*))?$/.exec(v); if (!m) return hideSlash(); const name = m[1].toLowerCase(); let items; if (m[2] === undefined) { items = COMMANDS .filter((c) => c.name.startsWith(name) || (c.aliases || []).some((a) => a.startsWith(name))) .map((c) => ({ cmd: c, label: '/' + c.name, hint: c.args || '', desc: c.desc })); } else { const c = findCmd(name); const opts = c && c.complete ? c.complete(m[2]) : null; if (!opts) return hideSlash(); const q = m[2].toLowerCase(); items = opts .filter((o) => !q || o.value.toLowerCase().includes(q)) .slice(0, 30) .map((o) => ({ cmd: c, value: o.value, label: o.value, hint: '', desc: o.desc || '' })); } const prev = slashItems[slashSel]; slashItems = items; /* what was typed in full wins, then the row the arrows were on */ const exact = m[2] === undefined ? items.findIndex((i) => i.cmd.name === name || (i.cmd.aliases || []).includes(name)) : items.findIndex((i) => i.value === m[2]); const keep = prev ? items.findIndex((i) => i.label === prev.label) : -1; slashSel = exact >= 0 ? exact : keep >= 0 ? keep : 0; if (!items.length) return hideSlash(); renderSlash(); } function renderSlash() { const box = $('slash'); box.textContent = ''; slashItems.forEach((it, i) => { const b = el('button', 'slash-item' + (i === slashSel ? ' on' : '')); b.type = 'button'; b.appendChild(el('code', null, it.label)); if (it.hint) b.appendChild(el('span', 'hint', it.hint)); b.appendChild(el('span', 'desc', it.desc)); /* mousedown keeps the focus in the composer */ b.onmousedown = (e) => { e.preventDefault(); slashSel = i; acceptSlash(it, true); }; box.appendChild(b); }); box.hidden = false; const on = box.children[slashSel]; if (on) on.scrollIntoView({ block: 'nearest' }); } /* Enter runs the pick, Tab only completes the text */ function acceptSlash(it, run) { if (!it) return; const c = it.cmd; if (it.value === undefined) { if (run && !(c.more && c.complete)) { $('input').value = ''; autoGrow(); syncSend(); return invokeCmd(c, ''); } return setComposer('/' + c.name + (c.args ? ' ' : '')); } const line = '/' + c.name + ' ' + it.value; if (run && !c.more) { $('input').value = ''; autoGrow(); syncSend(); return invokeCmd(c, it.value); } setComposer(line + (c.more ? ' ' : '')); if (c.more) hideSlash(); } function cmdLabel(c) { return '/' + c.name + (c.args ? ' ' + c.args : ''); } function showPalette() { hideSlash(); const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '命令面板')); const input = el('input', 'key-input'); input.placeholder = '输入命令或说明过滤,↑↓ 选择,Enter 执行'; input.spellcheck = false; box.appendChild(input); const list = el('div', 'palette-list'); box.appendChild(list); let items = [], sel = 0; const pick = (c) => { closeModal(); /* a required argument goes back to the composer to be filled in */ if (c.args && c.args.startsWith('<')) return setComposer('/' + c.name + ' '); invokeCmd(c, ''); }; const render = () => { const q = input.value.trim().toLowerCase().replace(/^\//, ''); items = COMMANDS.filter((c) => !q || c.name.includes(q) || c.desc.toLowerCase().includes(q) || (c.aliases || []).some((a) => a.includes(q))); sel = Math.min(sel, Math.max(0, items.length - 1)); list.textContent = ''; items.forEach((c, i) => { const b = el('button', 'palette-item' + (i === sel ? ' on' : '')); b.appendChild(el('code', null, cmdLabel(c))); b.appendChild(el('span', 'desc', c.desc)); if (c.key) b.appendChild(el('kbd', null, c.key)); b.onclick = () => pick(c); list.appendChild(b); }); if (!items.length) list.appendChild(el('div', 'muted tiny', '没有匹配的命令')); const on = list.children[sel]; if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' }); }; input.addEventListener('input', () => { sel = 0; render(); }); input.addEventListener('keydown', (e) => { const n = Math.max(1, items.length); if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % n; render(); } else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel + n - 1) % n; render(); } else if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); if (items[sel]) pick(items[sel]); } }); openModal(box); render(); setTimeout(() => input.focus(), 0); } const KEYS_HELP = [ ['Enter', '发送'], ['Shift+Enter', '换行'], ['/', '输入命令:Tab 补全,↑↓ 选择,Enter 执行'], ['Ctrl+P', '命令面板'], ['Ctrl+K', '会话列表'], ['Shift+Tab', '切换审批:每次询问 → 自动 → 完全放行'], ['Esc', '关闭弹窗 / 取消编辑 / 停止当前一轮'], ['Ctrl+B · Ctrl+J', '收起或展开左栏 · 右栏'], ['y · n · 1-4', '回答许可请求(焦点不在输入框时)'], ['F1 · ?', '这个帮助'], ]; function showHelp() { hideSlash(); const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '快捷键')); const keys = el('div', 'keys-grid'); for (const [k, d] of KEYS_HELP) { keys.appendChild(el('kbd', null, k)); keys.appendChild(el('span', null, d)); } box.appendChild(keys); box.appendChild(el('h4', 'sub', '命令(在输入框里打,或者 Ctrl+P)')); const cmds = el('div', 'keys-grid cmds'); for (const c of COMMANDS) { cmds.appendChild(el('code', null, cmdLabel(c))); cmds.appendChild(el('span', null, c.desc + ((c.aliases || []).length ? '(也可以 /' + c.aliases.join(' /') + ')' : ''))); } box.appendChild(cmds); const row = el('div', 'buttons'); row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(row); openModal(box, true); } /* --- extensions: MCP servers, plugins, skills, sub-agents ----------------- */ const MCP_ZH = { starting: '启动中', ready: '就绪', failed: '失败', exited: '已退出', off: '未启动' }; const RUN_ZH = { done: '完成', error: '出错', cancelled: '已取消', running: '运行中' }; function reportZh(r) { return String(r || '') .replace(/(\d+) skills?/, '$1 个 skill') .replace(/(\d+) plugins? \((\d+) tools?\)/, '$1 个插件($2 个工具)') .replace(/(\d+) MCP servers?/, '$1 个 MCP 服务器') .replace(/untrusted workspace: \/plugins trust/, '工作区未信任,它自带的插件和 MCP 没有加载'); } async function showExtensions(focus) { let d; try { d = extCache = await getJSON('api/extensions'); } catch (e) { return toast('读取扩展失败:' + errMsg(e)); } const box = document.createDocumentFragment(); box.appendChild(el('h3', null, '扩展')); if (!d.enabled) box.appendChild(el('p', 'warn-text tiny', '当前工具集是 minimal:MCP、插件、skills 和子代理都不加载。在运行设置里换回 default,再点"重新加载"。')); box.appendChild(el('p', 'muted tiny', (d.report ? '已加载:' + reportZh(d.report) + '。' : '没有加载任何扩展。') + (d.trusted ? '这个工作区已信任:它的 .agent/plugins 和 .agent/mcp.json 会加载。' : '这个工作区未信任:它的 .agent/plugins 和 .agent/mcp.json 不会加载(配置目录里的照常加载)。'))); const acts = el('div', 'buttons left'); const act = async (path, body, done) => { try { const r = await post(path, body); toast(done + (r.report ? ':' + reportZh(r.report) : ''), 'ok'); showExtensions(focus); } catch (e) { toastErr(e); } }; acts.appendChild(Object.assign(el('button', null, '重新加载'), { title: '停掉 MCP 服务器和插件,重新读取(有轮次在跑时不行)', onclick: () => act('api/extensions/reload', {}, '已重新加载'), })); acts.appendChild(Object.assign(el('button', d.trusted ? 'danger' : 'ok', d.trusted ? '取消信任这个工作区' : '信任这个工作区'), { title: '信任后会运行工作区里 .agent/plugins 和 .agent/mcp.json 指定的程序', onclick: () => act('api/extensions/trust', { on: !d.trusted }, d.trusted ? '已取消信任' : '已信任'), })); box.appendChild(acts); const secs = {}; const section = (key, title, rows, empty, draw) => { const h = el('h4', 'sub', title + ' · ' + rows.length); secs[key] = h; box.appendChild(h); const list = el('div', 'ext-list'); if (!rows.length) list.appendChild(el('div', 'muted tiny', empty)); for (const r of rows) list.appendChild(draw(r)); box.appendChild(list); }; const row = (name, badge, badgeCls, meta, sub, button) => { const r = el('div', 'ext-row'); const m = el('div', 'pm'); const t = el('div', 'ext-title'); t.appendChild(el('b', null, name)); if (badge) t.appendChild(el('span', 'ext-badge ' + (badgeCls || ''), badge)); if (meta) t.appendChild(el('span', 'muted tiny', meta)); m.appendChild(t); if (sub) m.appendChild(el('div', 'tiny ' + (badgeCls === 'bad' ? 'danger-text' : 'muted'), sub)); r.appendChild(m); if (button) r.appendChild(button); return r; }; const useBtn = (label, text) => Object.assign(el('button', null, label), { onclick: () => { closeModal(); setComposer(text); }, }); section('mcp', 'MCP 服务器', d.mcp || [], '没有 MCP 服务器。在配置目录的 mcp.json(或已信任工作区的 .agent/mcp.json)里写 {"mcpServers": {"名字": {"command": …, "args": […]}}},再点重新加载。', (s) => row(s.name, MCP_ZH[s.state] || s.state, s.state === 'ready' ? 'ok' : s.state === 'failed' || s.state === 'exited' ? 'bad' : '', (s.state === 'ready' ? s.tools + ' 个工具(mcp__' + s.name + '__*)· ' : '') + s.source, s.error || s.command)); section('plugins', '插件', d.plugins || [], '没有插件。在配置目录的 plugins/(或已信任工作区的 .agent/plugins/)下放 <目录>/plugin.json,再点重新加载。', (p) => row(p.name, p.error ? '出错' : p.tools + ' 个工具', p.error ? 'bad' : '', p.source + (p.mcp ? ' · MCP' : '') + (p.skills ? ' · skills' : ''), p.error || p.desc)); section('skills', 'Skills', d.skills || [], '没有 skill。在工作区的 .agent/skills/ 或配置目录的 skills/ 下放 <目录>/SKILL.md(frontmatter 写 name 和 description),再点重新加载。', (s) => row(s.name, null, null, s.source, s.desc, useBtn('使用', '/skill ' + s.name + ' '))); section('agents', '子代理角色', d.roles || [], d.task_tool ? '没有角色。' : 'task 工具没开(工具集是 minimal)。', (r) => row(r.name, r.read_only ? '只读' : null, null, r.source + (r.model ? ' · ' + r.model : ''), r.desc, d.task_tool ? useBtn('交给它', '用 ' + r.name + ' 子代理(task 工具)来 ') : null)); section('runs', '本会话的子代理运行', d.runs || [], '这个会话还没有用过子代理。', (r) => row(r.role + ' · ' + r.file, RUN_ZH[r.status] || r.status, r.status === 'done' ? 'ok' : r.status === 'error' ? 'bad' : '', r.steps + ' 步 · ' + r.tools + ' 次工具 · ' + fmtSecs(r.ms || 0), r.desc)); const close = el('div', 'buttons'); close.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal })); box.appendChild(close); openModal(box, true); const target = secs[focus === 'skill' ? 'skills' : focus]; if (target) setTimeout(() => target.scrollIntoView({ block: 'start' }), 0); } function autoGrow() { const t = $('input'); t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 240) + 'px'; } async function upload(file) { const r = await api('api/attach?name=' + encodeURIComponent(file.name || 'pasted.png'), { method: 'POST', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, }); try { await readReply(r); } catch (e) { toast('上传失败:' + errMsg(e)); return; } pendingPreviews.push({ src: URL.createObjectURL(file), name: file.name || 'pasted.png' }); renderPending(); if (state.sees_images === 0) toast((state.model || '当前模型') + ' 不识图,这张图不会发给模型', 'warn'); } /* --- layout -------------------------------------------------------------- */ function toggleSide() { if (window.innerWidth <= 900) { const open = document.body.classList.toggle('side-open'); document.body.classList.toggle('drawer-open', open); document.body.classList.remove('panel-open'); return; } localStorage.setItem('cst.side', document.body.classList.toggle('no-side') ? '0' : '1'); } function togglePanel() { if (window.innerWidth <= 900) { const open = document.body.classList.toggle('panel-open'); document.body.classList.toggle('drawer-open', open); document.body.classList.remove('side-open'); return; } localStorage.setItem('cst.panel', document.body.classList.toggle('no-panel') ? '0' : '1'); } function closeDrawers() { document.body.classList.remove('side-open', 'panel-open', 'drawer-open'); } function applyTheme(t) { document.documentElement.dataset.theme = t; localStorage.setItem('cst.theme', t); } function toggleTheme() { applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); } /* --- wiring -------------------------------------------------------------- */ $('btn-send').onclick = () => send(); $('btn-stop').onclick = () => post('api/cancel', {}).catch(toastErr); $('btn-new').onclick = newSession; $('btn-refresh-sessions').onclick = () => loadSessions(); $('sess-search').addEventListener('input', renderSessionList); $('btn-diff').onclick = () => showDiff(); $('btn-edit-cancel').onclick = cancelEdit; $('chip-model').onclick = () => modelPopover($('chip-model')); $('chip-approval').onclick = () => approvalPopover($('chip-approval')); $('btn-plus').onclick = () => plusPopover($('btn-plus')); $('cost').onclick = () => usagePopover($('cost')); $('btn-menu').onclick = () => menuPopover($('btn-menu')); $('usage').onclick = () => usagePopover($('cost')); $('btn-settings').onclick = showSettings; $('btn-collapse').onclick = toggleSide; $('btn-side').onclick = toggleSide; $('btn-drawer').onclick = toggleSide; $('btn-panel').onclick = togglePanel; $('btn-panel-close').onclick = togglePanel; $('btn-theme').onclick = toggleTheme; $('btn-undo').onclick = undo; $('to-bottom').onclick = () => scrollDown(true); for (const b of $('seg-mode').children) { b.onclick = async () => { try { await post('api/config', { plan_mode: b.dataset.mode === 'plan' }); $('status').textContent = b.dataset.mode === 'plan' ? '计划模式:只读' : '构建模式:可写'; } catch (e) { toastErr(e); } }; } $('input').addEventListener('keydown', (e) => { if (e.isComposing) return; if (!$('slash').hidden && slashItems.length) { const n = slashItems.length; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); slashSel = (slashSel + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; renderSlash(); return; } if ((e.key === 'Tab' && !e.shiftKey) || (e.key === 'Enter' && !e.shiftKey)) { e.preventDefault(); acceptSlash(slashItems[slashSel], e.key === 'Enter'); return; } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); hideSlash(); return; } } if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); cycleApproval(); return; } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); $('input').addEventListener('input', () => { autoGrow(); syncSend(); slashSuggest(); }); $('input').addEventListener('blur', () => setTimeout(hideSlash, 150)); $('scroll').addEventListener('scroll', updateJump); $('scroll').addEventListener('wheel', closePop, { passive: true }); $('file').addEventListener('change', async (e) => { for (const f of e.target.files) await upload(f); e.target.value = ''; }); document.addEventListener('paste', async (e) => { const items = (e.clipboardData || {}).items || []; for (const item of items) { if (item.kind === 'file' && item.type.startsWith('image/')) { e.preventDefault(); await upload(item.getAsFile()); } } }); document.addEventListener('dragover', (e) => e.preventDefault()); document.addEventListener('drop', async (e) => { if (!e.dataTransfer.files.length) return; e.preventDefault(); for (const f of e.dataTransfer.files) await upload(f); }); $('backdrop').onclick = closeDrawers; $('lightbox').onclick = (e) => { if (e.target === $('lightbox')) closeLightbox(); }; $('lb-close').onclick = closeLightbox; $('lb-prev').onclick = () => stepLightbox(-1); $('lb-next').onclick = () => stepLightbox(1); /* --- go ------------------------------------------------------------------ */ /* the rail/panel/theme classes are already on 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);