diff --git a/docs/webui.md b/docs/webui.md index 37e10f1..6c00d23 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -209,6 +209,22 @@ Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API), 对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文 占用与 prompt cache 命中率。布局类在页面脚本之前就写进 ``,避免首帧闪一下再收起。 +对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 720px(`--content`); +左右两栏随屏宽在 `clamp()` 范围内变宽,超宽屏下中间列不会无限拉开。 + +过程时间线:一段**连续**的思考和工具调用合成一个过程块,折叠时只有一行 +("思考 4 次 · 工具 6 次";运行中显示当前动作,比如"执行 run_shell…"),展开后是一条 +细竖线串起的时间线,每次思考、每个工具调用各占一行,思考只露第一行,点开看全文。 +出现可见的正文(或引擎提示、下一句提问)时过程块就此结束,后面的思考/工具另起一块—— +历史重建(`/api/session`)和实时事件流用的是同一条规则,所以刷新前后看到的一样。 +`/thinking on|off`(`show_thinking`)决定过程块和其中的思考默认展开还是收起,流式输出时也照此执行; +`/details` 只管工具行是否展开。 + +图片:提问附带的图片显示在气泡**上方**、靠右,一张保持原比例(最长 260px),多张是 +152px 的圆角方块;点开是大图预览(Esc 关闭,←/→ 切换)。从本页上传/粘贴/拖入的图片在 +输入框上方显示缩略图,发送后立刻出现在新提问上(本地预览,本轮结束后换成 `/api/attach/`); +从别的客户端加的待发图片仍然显示为文字条。 + ### 命令与快捷键 输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全, @@ -259,10 +275,11 @@ sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl-heredoc ctl-heredoc 1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。 2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。 3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。 -4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):工具调用折叠成"本轮工具调用", - 点开能看到参数和输出;搜索、重命名、删除可用。 +4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):连续的思考和工具调用折叠成一行 + 过程块,展开是时间线,工具行点开能看到参数和输出;搜索、重命名、删除可用。 + `/thinking off` 时流式输出的思考也保持收起。 4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。 -5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。 +5. 拖入一张图片 → 输入框上方出现缩略图,发送后图片立刻出现在提问气泡上方,点开是大图预览。 6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。 ## 限制与已知问题 diff --git a/include/cst_version.h b/include/cst_version.h index df1a902..4cce114 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 4 +#define CST_VERSION_PATCH 5 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/web/app.js b/web/app.js index ea58837..24eb458 100644 --- a/web/app.js +++ b/web/app.js @@ -165,8 +165,11 @@ const log = $('log'); const scroll = $('scroll'); let state = {}; let live = null; /* streaming assistant block */ -let steps = null; /* this turn's tool list */ +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; @@ -277,58 +280,61 @@ function updateJump() { 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)); - /* no avatar per message: the assistant reads as plain text in the column, - * the way the reference transcript does; only errors get a mark */ - const g = el('div', 'gutter', role === 'error' ? '!' : ''); const b = el('div', 'body'); - m.appendChild(g); m.appendChild(b); log.appendChild(m); showEmpty(false); scrollDown(true); return { root: m, body: b }; } -function copyButton(text) { - const b = el('button', null, '复制'); - b.onclick = async () => { - try { - await navigator.clipboard.writeText(text); - b.textContent = '已复制'; - setTimeout(() => { b.textContent = '复制'; }, 1200); - } catch { b.textContent = '复制失败'; } - }; +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 attachStrip(atts) { - const strip = el('div', 'attach-strip'); - for (const a of atts) { +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 = a.name || 'image'; + img.alt = s.name || 'image'; img.loading = 'lazy'; - img.src = attachURL(a.rel); - strip.appendChild(img); - } + img.decoding = 'async'; + img.src = s.src; + b.appendChild(img); + b.onclick = () => openLightbox(srcs, i); + strip.appendChild(b); + }); return strip; } -/* reasoning belongs inside .body: as a sibling it becomes a second flex item - * and squeezes the answer into a narrow column */ -function thinkInto(body, text, open) { - let d = body.querySelector(':scope > details.think'); - let t; - if (!d) { - d = el('details', 'think'); - d.appendChild(el('summary', null, '思考过程')); - t = el('div'); - d.appendChild(t); - body.insertBefore(d, body.firstChild); - } else { - t = d.querySelector('div'); - } - if (open) d.open = true; - t.textContent += text; - return d; +function 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'); @@ -336,6 +342,119 @@ function actionsFor(root, 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: '完全放行' }; @@ -355,6 +474,7 @@ function noteLine(text) { .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, @@ -381,15 +501,23 @@ function renderMessage(m) { if (m.note) return noteLine(m.content); if (m.role === 'tool' || m.tool_result) return historyResult(m); const isUser = m.role === 'user'; - if (isUser) steps = null; /* each prompt starts its own step list */ const text = m.text !== undefined ? m.text : (m.content || ''); - if (isUser || text.trim() || m.reasoning || (m.attach && m.attach.length)) { - const b = bubble(isUser ? 'user' : 'assistant'); - if (!isUser && m.html) b.body.innerHTML = m.html; + 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; - if (m.reasoning) thinkInto(b.body, m.reasoning, !!state.show_thinking); - if (m.attach && m.attach.length) b.body.insertBefore(attachStrip(m.attach), b.body.firstChild); - if (isUser) b.root.dataset.prompt = '1'; actionsFor(b.root, text); } for (const c of m.calls || []) historyCall(c); @@ -400,12 +528,12 @@ function lastPromptActions() { const p = prompts[prompts.length - 1]; if (!p) return; const row = p.querySelector('.msg-actions'); - if (row) row.appendChild(Object.assign(el('button', null, '编辑重发'), { onclick: () => editLast(p) })); + 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(Object.assign(el('button', null, '重新生成'), { onclick: () => retry() })); + if (r) r.appendChild(actBtn('retry', '重新生成', () => retry())); } } @@ -415,17 +543,23 @@ async function loadSession() { stepIndex = new Map(); files = new Map(); renderFilesPanel(); - steps = null; + proc = null; live = null; for (const m of data.messages) renderMessage(m); - for (const d of log.querySelectorAll('details.turn-steps')) d.open = detailsAll; - steps = null; + 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'; @@ -435,28 +569,22 @@ async function loadSession() { /* --- 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'); - /* the answer gets its own box: rewriting body.innerHTML would drop the - * reasoning
that thinkInto keeps at the top of body */ const out = el('div', 'live-html'); b.body.appendChild(out); live = { root: b.root, body: b.body, out, html: '' }; return live; } -function ensureSteps() { - if (steps) return steps; - const d = el('details', 'turn-steps'); - d.open = true; - const s = el('summary', 'steps-sum', '本轮工具调用'); - d.appendChild(s); - const box = el('div', 'steps-box'); - d.appendChild(box); - log.appendChild(d); - steps = { root: d, sum: s, box, count: 0 }; - return steps; +function sealLive() { + if (!live) return; + live.body.classList.remove('live'); + live = null; } function phaseText(p) { return { open: '准备中', ready: '待执行', run: '执行中', progress: '进行中', done: '完成' }[p] || p || ''; @@ -471,24 +599,26 @@ function argsLine(args) { function stepFor(ev) { let st = stepIndex.get(ev.id); if (st) return st; - const c = ensureSteps(); - const node = el('details', 'step-line run'); + 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); - c.box.appendChild(node); - st = { root: node, sum, name, args, phase, body, t0: Date.now(), bad: false }; + 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); - c.count++; - c.sum.textContent = '本轮工具调用 · ' + c.count + ' 次'; + p.cur = st; + p.tools++; + procLabel(p); return st; } @@ -630,6 +760,10 @@ function onTool(ev) { 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) { @@ -642,6 +776,7 @@ function onToolResult(ev) { 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) { @@ -741,29 +876,62 @@ function applyState(s) { 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 n = (s.pending_images || []).length; + const list = state.pending_images || []; + const n = list.length; + if (!n && pendingSeen && pendingPreviews.length) { + revokeAll(pendingPreviews); + pendingPreviews = []; + } + pendingSeen = n; p.hidden = !n; - if (n) { - p.textContent = ''; - for (const line of s.pending_images) { + 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 = () => post('api/attach', undefined, 'DELETE').catch(toastErr); + x.onclick = clear; chip.appendChild(x); p.appendChild(chip); } - if (s.sees_images === 0) - p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发')); - } - renderTodo(s.todo); - if ('show_thinking' in s && s.show_thinking !== shownThinking) { - shownThinking = s.show_thinking; - for (const d of log.querySelectorAll('details.think')) d.open = !!s.show_thinking; } + if (state.sees_images === 0) + p.appendChild(el('span', 'chip warn', (state.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发')); } /* --- SSE ----------------------------------------------------------------- */ @@ -787,7 +955,7 @@ function connect() { }); on('think_delta', (d) => { markBusy(); - thinkInto(ensureLive().body, d.text || '', true); + procThink(d.text || '', true); scrollDown(false); }); on('tool', (ev) => { markBusy(); onTool(ev); }); @@ -795,8 +963,9 @@ function connect() { on('todo', (d) => renderTodo(d.text)); on('session', (d) => { closePop(); - live = null; steps = null; stepIndex = new Map(); + live = null; proc = null; stepIndex = new Map(); turnErr = null; + lastTurn = null; loadSession().then(loadSessions).catch(toastErr); closeDrawers(); if (d && d.id) $('status').textContent = '已切换会话'; @@ -805,16 +974,24 @@ function connect() { on('permission_done', hideGate); on('ask_user', showAsk); on('turn_end', (d) => { - if (turnStart) { turnMeta(Date.now() - turnStart); turnStart = 0; } - if (live) live.body.classList.remove('live'); - live = null; steps = null; stepIndex = new Map(); + 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(loadSessions).catch(toastErr); + 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 */ @@ -887,6 +1064,12 @@ async function answerGate(value) { } } 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))); @@ -1668,15 +1851,16 @@ function editLast(node) { syncSend(); input.focus(); } -function freshTurn(text) { - const b = bubble('user'); - b.body.textContent = text; - b.root.dataset.prompt = '1'; - steps = null; +function freshTurn(text, srcs) { + sealProc(); + sealLive(); + revokeAll(shownPreviews); + shownPreviews = srcs || []; + userBubble(text, shownPreviews); stepIndex = new Map(); files = new Map(); - live = null; turnErr = null; + lastTurn = null; renderFilesPanel(); turnStart = Date.now(); } @@ -1731,11 +1915,23 @@ async function send(text) { setEditing(false); return retry(body); } - freshTurn(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); } } @@ -1805,7 +2001,7 @@ function setThinking(a) { function setDetails(a) { detailsAll = onOffArg(a, detailsAll); localStorage.setItem('cst.details', detailsAll ? '1' : '0'); - for (const d of log.querySelectorAll('details.turn-steps, details.step-line')) d.open = detailsAll; + for (const d of log.querySelectorAll('details.step-line')) d.open = detailsAll; toast(detailsAll ? '工具输出全部展开' : '工具输出全部收起', 'ok'); } async function providerCmd(a) { @@ -2220,6 +2416,8 @@ async function upload(file) { 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'); } @@ -2335,10 +2533,13 @@ document.addEventListener('drop', async (e) => { 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 ------------------------------------------------------------------ */ -applyTheme(localStorage.getItem('cst.theme') || 'dark'); /* the rail/panel/theme classes are already on from the inline script in * index.html, so nothing here has to re-derive them */ autoGrow(); diff --git a/web/index.html b/web/index.html index 0a86b01..a1b440e 100644 --- a/web/index.html +++ b/web/index.html @@ -41,6 +41,10 @@ + + + + @@ -216,6 +220,13 @@
+