v0.8.5: WebUI process timeline, Gemini-style images, aligned layout
ci / linux (clang) (push) Successful in 6m15s
ci / linux (gcc) (push) Successful in 7m14s
ci / musl (push) Successful in 7m14s
ci / windows-cross (push) Successful in 11m4s

Process timeline: a run of consecutive thinking and tool calls is one
block that folds to a single line ("思考 4 次 · 工具 6 次"; while running
it shows the current action) and opens into a compact timeline, one row
per thought or call. Visible text, a note or the next prompt closes the
block; history and the live stream follow the same rule. show_thinking
now also applies while streaming instead of forcing thoughts open.

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

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

Also: ?theme= is no longer overridden by app.js, the user bubble is
visible in the light theme, and the narrow top bar no longer wraps.
This commit is contained in:
WHC
2026-10-10 14:54:36 +08:00
parent 7e1dbd9afa
commit 9bf7707912
5 changed files with 487 additions and 162 deletions
+297 -96
View File
@@ -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 <details> that thinkInto keeps at the top of body */
const out = el('div', 'live-html');
b.body.appendChild(out);
live = { root: b.root, body: b.body, out, html: '' };
return live;
}
function ensureSteps() {
if (steps) return steps;
const d = el('details', 'turn-steps');
d.open = true;
const s = el('summary', 'steps-sum', '本轮工具调用');
d.appendChild(s);
const box = el('div', 'steps-box');
d.appendChild(box);
log.appendChild(d);
steps = { root: d, sum: s, box, count: 0 };
return steps;
function 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 <body> from the inline script in
* index.html, so nothing here has to re-derive them */
autoGrow();