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 @@
+
+
![]()
+
+
+
+
+