v0.8.5: WebUI process timeline, Gemini-style images, aligned layout
Process timeline: a run of consecutive thinking and tool calls is one
block that folds to a single line ("思考 4 次 · 工具 6 次"; while running
it shows the current action) and opens into a compact timeline, one row
per thought or call. Visible text, a note or the next prompt closes the
block; history and the live stream follow the same rule. show_thinking
now also applies while streaming instead of forcing thoughts open.
Layout: the avatar gutter and the flex-wrap that dropped it under long
answers are gone, so answer, process blocks, notes and composer share one
left edge. The text column and composer are both 720px (--content); the
rail and run panel grow with clamp() on wide screens. A rebuild after
each turn no longer replays every entrance animation, and the turn time
survives it. Message actions are icon buttons and only appear under text.
Images: a prompt's images sit above the bubble, right-aligned (one keeps
its shape, several are 152px rounded tiles) and open in a lightbox (Esc,
arrow keys). Images uploaded from the page show as thumbnails over the
composer and appear on the prompt as soon as it is sent.
Also: ?theme= is no longer overridden by app.js, the user bubble is
visible in the light theme, and the narrow top bar no longer wraps.
This commit is contained in:
+297
-96
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user