Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf7707912 |
+20
-3
@@ -209,6 +209,22 @@ Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),
|
||||
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
|
||||
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
|
||||
|
||||
对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 720px(`--content`);
|
||||
左右两栏随屏宽在 `clamp()` 范围内变宽,超宽屏下中间列不会无限拉开。
|
||||
|
||||
过程时间线:一段**连续**的思考和工具调用合成一个过程块,折叠时只有一行
|
||||
("思考 4 次 · 工具 6 次";运行中显示当前动作,比如"执行 run_shell…"),展开后是一条
|
||||
细竖线串起的时间线,每次思考、每个工具调用各占一行,思考只露第一行,点开看全文。
|
||||
出现可见的正文(或引擎提示、下一句提问)时过程块就此结束,后面的思考/工具另起一块——
|
||||
历史重建(`/api/session`)和实时事件流用的是同一条规则,所以刷新前后看到的一样。
|
||||
`/thinking on|off`(`show_thinking`)决定过程块和其中的思考默认展开还是收起,流式输出时也照此执行;
|
||||
`/details` 只管工具行是否展开。
|
||||
|
||||
图片:提问附带的图片显示在气泡**上方**、靠右,一张保持原比例(最长 260px),多张是
|
||||
152px 的圆角方块;点开是大图预览(Esc 关闭,←/→ 切换)。从本页上传/粘贴/拖入的图片在
|
||||
输入框上方显示缩略图,发送后立刻出现在新提问上(本地预览,本轮结束后换成 `/api/attach/<rel>`);
|
||||
从别的客户端加的待发图片仍然显示为文字条。
|
||||
|
||||
### 命令与快捷键
|
||||
|
||||
输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,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. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
|
||||
|
||||
## 限制与已知问题
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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();
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<symbol id="i-lv4" viewBox="0 0 24 24"><path d="M4 18.5V13h2.6v5.5zM8.7 18.5V10h2.6v8.5zM13.4 18.5V6.8h2.6v11.7zM18.1 18.5V4h2.6v14.5z" class="f"/></symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24"><path d="M13.5 3.5H7A2.5 2.5 0 0 0 4.5 6v12A2.5 2.5 0 0 0 7 20.5h10A2.5 2.5 0 0 0 19.5 18V9.5z"/><path d="M13.5 3.5v6h6"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.2V12l3.2 2"/></symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M14.8 5.2l4 4L9 19H5v-4z"/><path d="M12.6 7.4l4 4"/></symbol>
|
||||
<symbol id="i-retry" viewBox="0 0 24 24"><path d="M4.5 12a7.5 7.5 0 0 1 13-5.1M19.5 12a7.5 7.5 0 0 1-13 5.1"/><path d="M17.8 3.6v3.6h-3.6M6.2 20.4v-3.6h3.6"/></symbol>
|
||||
<symbol id="i-left" viewBox="0 0 24 24"><path d="M14.5 6l-6 6 6 6"/></symbol>
|
||||
<symbol id="i-right" viewBox="0 0 24 24"><path d="M9.5 6l6 6-6 6"/></symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3.5 6.5A1.5 1.5 0 0 1 5 5h4l2 2.5h8A1.5 1.5 0 0 1 20.5 9v9A1.5 1.5 0 0 1 19 19.5H5A1.5 1.5 0 0 1 3.5 18z"/></symbol>
|
||||
</svg>
|
||||
|
||||
@@ -216,6 +220,13 @@
|
||||
<div id="backdrop"></div>
|
||||
<div id="pop" class="pop" hidden></div>
|
||||
<div id="modal" hidden><div id="modal-box"></div></div>
|
||||
<div id="lightbox" hidden>
|
||||
<img id="lb-img" alt="">
|
||||
<button id="lb-close" class="lb-btn" title="关闭 (Esc)"><svg class="i"><use href="#i-close"/></svg></button>
|
||||
<button id="lb-prev" class="lb-btn" title="上一张 (←)"><svg class="i"><use href="#i-left"/></svg></button>
|
||||
<button id="lb-next" class="lb-btn" title="下一张 (→)"><svg class="i"><use href="#i-right"/></svg></button>
|
||||
<span id="lb-count"></span>
|
||||
</div>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
<script>
|
||||
/* Layout is decided here, before app.js runs and before the first paint: the
|
||||
|
||||
+158
-62
@@ -35,6 +35,7 @@
|
||||
--composer-surface-secondary: #202026;
|
||||
|
||||
--bg-code: #08080a;
|
||||
--bubble-user: var(--main-surface-tertiary);
|
||||
|
||||
/* every hover/active tint is alpha, so it lands correctly on any surface */
|
||||
--hover: color-mix(in srgb, var(--text-primary) 7%, transparent);
|
||||
@@ -68,8 +69,11 @@
|
||||
--accent: var(--accent-primary);
|
||||
--danger: var(--text-error);
|
||||
|
||||
--rail: 252px;
|
||||
--panel: 296px;
|
||||
/* the side columns grow with the screen so a wide window does not turn into
|
||||
* one wide middle: the text stays near the run panel it is read against */
|
||||
--rail: clamp(240px, 14vw, 280px);
|
||||
--panel: clamp(296px, 20vw, 380px);
|
||||
--content: 720px; /* text column and composer share this edge */
|
||||
--radius: 12px;
|
||||
--radius-lg: 22px;
|
||||
--shadow: 0 16px 40px rgba(0, 0, 0, .45);
|
||||
@@ -103,6 +107,7 @@
|
||||
--composer-surface-secondary: #f4f4f6;
|
||||
|
||||
--bg-code: #f5f5f7;
|
||||
--bubble-user: #eef0f4;
|
||||
|
||||
--accent-primary: #0f9d70;
|
||||
--accent-fg: #ffffff;
|
||||
@@ -306,7 +311,7 @@ body.no-side #side { pointer-events: none; }
|
||||
border-radius: 99px; padding: 2px; position: relative;
|
||||
}
|
||||
.seg button {
|
||||
height: 25px; padding: 0 12px; border-radius: 99px; color: var(--fg-dim); font-size: 12.5px;
|
||||
height: 25px; padding: 0 12px; white-space: nowrap; border-radius: 99px; color: var(--fg-dim); font-size: 12.5px;
|
||||
transition: color var(--t-fast), background var(--t);
|
||||
}
|
||||
.seg button.on { background: var(--bg-lift); color: var(--fg); font-weight: 600; }
|
||||
@@ -316,7 +321,7 @@ body.no-side #side { pointer-events: none; }
|
||||
flex: 1 1 auto; overflow-y: auto; padding: 22px 0 10px;
|
||||
scroll-behavior: smooth; scrollbar-gutter: stable;
|
||||
}
|
||||
#log { max-width: 760px; margin: 0 auto; padding: 0 20px; }
|
||||
#log { max-width: calc(var(--content) + 40px); margin: 0 auto; padding: 0 20px; }
|
||||
#log:empty { display: none; }
|
||||
|
||||
/* --- empty state ---------------------------------------------------------- */
|
||||
@@ -330,16 +335,22 @@ body.no-side #side { pointer-events: none; }
|
||||
}
|
||||
|
||||
/* --- transcript ----------------------------------------------------------- */
|
||||
.msg { display: flex; gap: 12px; padding: 13px 0 0; animation: fade-up var(--t) var(--ease) both; }
|
||||
.msg .gutter { flex: 0 0 24px; padding-top: 2px; color: var(--fg-faint); font-size: 11px; text-align: center; user-select: none; }
|
||||
.msg .body { min-width: 0; flex: 1 1 auto; }
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .gutter { display: none; }
|
||||
/* one left edge for everything (answer, process block, notes), the same edge
|
||||
* as the composer; prompts hang on the right edge with their images above */
|
||||
.msg { display: flex; flex-direction: column; align-items: stretch; padding: 14px 0 0; animation: fade-up var(--t) var(--ease) both; }
|
||||
.msg .body { min-width: 0; max-width: 100%; }
|
||||
.msg.user { align-items: flex-end; padding-top: 24px; }
|
||||
#log > .msg.user:first-child { padding-top: 6px; }
|
||||
.msg.user .body {
|
||||
background: var(--bg-lift); border-radius: 18px 18px 6px 18px;
|
||||
padding: 9px 15px; max-width: 76%; white-space: pre-wrap; word-break: break-word;
|
||||
background: var(--bubble-user); border-radius: 24px;
|
||||
padding: 11px 18px; max-width: min(76%, 560px); white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.msg.error .body { color: var(--danger); white-space: pre-wrap; }
|
||||
.msg.error .body::before {
|
||||
content: "!"; display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; margin-right: 8px; border-radius: 50%; vertical-align: 1px;
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent); font-size: 12px; font-weight: 800;
|
||||
}
|
||||
.body > :first-child, .live-html > :first-child { margin-top: 0; }
|
||||
.body > :last-child, .live-html > :last-child { margin-bottom: 0; }
|
||||
.body h1, .body h2, .body h3, .body h4 { line-height: 1.35; margin: 1.1em 0 .5em; }
|
||||
@@ -370,63 +381,102 @@ body.no-side #side { pointer-events: none; }
|
||||
background: var(--accent); animation: blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
.attach-strip { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; justify-content: flex-end; }
|
||||
.attach-strip img {
|
||||
width: 108px; height: 108px; object-fit: cover; border-radius: 12px;
|
||||
border: 1px solid var(--line); animation: rise var(--t) var(--ease) both;
|
||||
/* a prompt's images: above the bubble and outside it, square tiles when there
|
||||
* are several, the picture's own shape when there is one */
|
||||
.attach-strip { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 12px; margin: 0 0 10px; max-width: 100%; }
|
||||
.attach-strip .thumb {
|
||||
display: block; width: 152px; height: 152px; padding: 0; overflow: hidden; cursor: zoom-in;
|
||||
border-radius: 20px; border: 1px solid var(--line); background: var(--bg-soft);
|
||||
animation: rise var(--t) var(--ease) both;
|
||||
transition: border-color var(--t-fast), transform 90ms var(--ease);
|
||||
}
|
||||
.attach-strip .thumb:hover { border-color: var(--line-strong); }
|
||||
.attach-strip .thumb:active { transform: scale(.985); }
|
||||
.attach-strip .thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.attach-strip.one .thumb { width: auto; height: auto; }
|
||||
.attach-strip.one .thumb img {
|
||||
width: auto; height: auto; min-width: 72px; min-height: 72px;
|
||||
max-width: min(260px, 70vw); max-height: 260px; object-fit: contain;
|
||||
}
|
||||
|
||||
.turn-meta { display: flex; align-items: center; gap: 10px; padding: 10px 0 2px 36px; }
|
||||
.turn-meta { display: flex; align-items: center; gap: 10px; padding: 8px 0 2px; }
|
||||
.turn-meta details > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12px; transition: color var(--t-fast); }
|
||||
.turn-meta details > summary::-webkit-details-marker { display: none; }
|
||||
.turn-meta details > summary:hover { color: var(--fg-dim); }
|
||||
.msg:has(> .msg-actions) { flex-wrap: wrap; row-gap: 0; }
|
||||
.msg-actions { display: flex; flex-basis: 100%; gap: 2px; padding: 4px 0 0 36px; opacity: 0; transition: opacity var(--t) var(--ease); }
|
||||
/* the actions take a row only under messages that have text to act on */
|
||||
.msg-actions { display: flex; gap: 2px; padding-top: 4px; margin-left: -7px; opacity: 0; transition: opacity var(--t) var(--ease); }
|
||||
.msg.user .msg-actions { margin: 0 -7px 0 0; }
|
||||
.msg:hover .msg-actions, .msg-actions:focus-within { opacity: 1; }
|
||||
.msg-actions button {
|
||||
height: 26px; padding: 0 9px; border-radius: 8px; color: var(--fg-faint); font-size: 12px;
|
||||
.act {
|
||||
width: 30px; height: 28px; border-radius: 8px; color: var(--fg-faint);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
transition: background var(--t-fast), color var(--t-fast), transform 90ms var(--ease);
|
||||
}
|
||||
.act svg.i { width: 16px; height: 16px; }
|
||||
.act:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.act:active { transform: scale(.94); }
|
||||
|
||||
/* --- process block: a run of thinking and tool calls, folded to one line --- */
|
||||
.proc { margin: 10px 0 0; }
|
||||
.proc-sum {
|
||||
display: inline-flex; align-items: center; gap: 7px; max-width: 100%; height: 28px;
|
||||
margin-left: -7px; padding: 0 10px 0 6px; border-radius: 8px;
|
||||
cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 13px; user-select: none;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.msg-actions button:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
|
||||
.think { margin: .1em 0 .5em; }
|
||||
.think summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); }
|
||||
.think summary::-webkit-details-marker { display: none; }
|
||||
.think summary::before { content: "▸ "; }
|
||||
.think[open] summary::before { content: "▾ "; }
|
||||
.think summary:hover { color: var(--fg-dim); }
|
||||
.think > div {
|
||||
color: var(--fg-dim); font-size: 13.5px; white-space: pre-wrap;
|
||||
padding: 6px 0 2px 14px; border-left: 2px solid var(--line);
|
||||
animation: fade-in var(--t) var(--ease) both;
|
||||
.proc-sum::-webkit-details-marker { display: none; }
|
||||
.proc-sum:hover { background: var(--bg-hover); color: var(--fg-dim); }
|
||||
.proc-chev { width: 14px; height: 14px; transform: rotate(-90deg); transition: transform var(--t) var(--ease); }
|
||||
.proc[open] > .proc-sum .proc-chev { transform: none; }
|
||||
.proc-lbl { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.proc-meta { flex: 0 0 auto; color: var(--fg-quat); font-size: 12px; white-space: nowrap; }
|
||||
.proc-meta:empty { display: none; }
|
||||
/* the running block's label shimmers instead of spinning */
|
||||
.proc.run .proc-lbl {
|
||||
background: linear-gradient(90deg, var(--fg-faint) 0%, var(--fg) 45%, var(--fg-faint) 90%);
|
||||
background-size: 220% 100%; -webkit-background-clip: text; background-clip: text;
|
||||
color: transparent; animation: shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
/* tool calls: one line, opens on demand (like "已执行 python · <<PY>") */
|
||||
.turn-steps { padding: 6px 0 0 36px; }
|
||||
.turn-steps > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); }
|
||||
.turn-steps > summary::-webkit-details-marker { display: none; }
|
||||
.turn-steps > summary::before { content: "⌘ "; }
|
||||
.turn-steps > summary:hover { color: var(--fg-dim); }
|
||||
.steps-box { padding: 2px 0 0 12px; margin-top: 4px; border-left: 2px solid var(--line); }
|
||||
.step-line { animation: fade-up var(--t) var(--ease) both; }
|
||||
.step-line > summary {
|
||||
display: flex; align-items: baseline; gap: 8px; cursor: pointer; list-style: none;
|
||||
padding: 5px 0; font-size: 13px; color: var(--fg-dim);
|
||||
/* the timeline: one hairline, one dot per entry */
|
||||
.tl { position: relative; margin: 2px 0 0; padding: 2px 0 2px 20px; animation: fade-in var(--t) var(--ease) both; }
|
||||
.tl::before {
|
||||
content: ""; position: absolute; left: 4px; top: 14px; bottom: 14px; width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.tl-item { position: relative; animation: fade-up var(--t) var(--ease) both; }
|
||||
.tl-item > summary {
|
||||
display: flex; align-items: center; gap: 8px; min-height: 28px; padding: 2px 0;
|
||||
cursor: pointer; list-style: none; font-size: 13px; color: var(--fg-dim);
|
||||
transition: color var(--t-fast);
|
||||
}
|
||||
.step-line > summary::-webkit-details-marker { display: none; }
|
||||
.step-line > summary:hover { color: var(--fg); }
|
||||
.step-line .name { font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
|
||||
.step-line .args { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12px; }
|
||||
.step-line .phase { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.step-line.run .phase { color: var(--warn); }
|
||||
.step-line.run .phase::before {
|
||||
content: ""; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--warn); animation: pulse 1.1s var(--ease) infinite;
|
||||
.tl-item > summary::-webkit-details-marker { display: none; }
|
||||
.tl-item > summary:hover { color: var(--fg); }
|
||||
.tl-dot {
|
||||
position: absolute; left: -20px; top: 9px; width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--bg); border: 1.5px solid var(--line-strong);
|
||||
}
|
||||
.tl-item.think .tl-dot { border-color: color-mix(in srgb, var(--violet) 75%, transparent); }
|
||||
.tl-name { flex: 0 0 auto; font-size: 12.5px; color: var(--fg-dim); }
|
||||
.tl-prev {
|
||||
flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: var(--fg-faint); font-size: 12.5px;
|
||||
}
|
||||
.tl-item.think[open] .tl-prev { visibility: hidden; }
|
||||
.think-text {
|
||||
color: var(--fg-dim); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word;
|
||||
padding: 2px 0 8px; animation: fade-in var(--t) var(--ease) both;
|
||||
}
|
||||
|
||||
/* tool calls: one line each, open for arguments and output */
|
||||
.step-line .name { flex: 0 0 auto; font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
|
||||
.step-line .args { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12px; color: var(--fg-faint); }
|
||||
.step-line .phase { flex: 0 0 auto; font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; }
|
||||
.step-line.run .phase { color: var(--warn); }
|
||||
.step-line.run .tl-dot { border-color: var(--warn); background: color-mix(in srgb, var(--warn) 30%, var(--bg)); animation: pulse 1.1s var(--ease) infinite; }
|
||||
.step-line.done .tl-dot { border-color: var(--accent-line); background: var(--accent-weak); }
|
||||
.step-line.bad .tl-dot { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 20%, var(--bg)); }
|
||||
.step-line.bad .phase, .step-line.bad .name { color: var(--danger); }
|
||||
.step-line.done .phase::before { content: "✓"; }
|
||||
.step-line.bad .phase::before { content: "✕"; }
|
||||
/* a sub-agent's latest steps, next to its task */
|
||||
.step-line .step-detail {
|
||||
flex: 0 1 40%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
@@ -435,7 +485,7 @@ body.no-side #side { pointer-events: none; }
|
||||
.step-line.done .step-detail { display: none; }
|
||||
/* "[cst] plan mode is now ON" and other engine notes in the history */
|
||||
.note-line {
|
||||
padding: 6px 0 2px 36px; font-size: 12px; color: var(--fg-faint);
|
||||
padding: 8px 0 2px; font-size: 12px; color: var(--fg-faint);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.note-line::before { content: "· "; }
|
||||
@@ -459,9 +509,24 @@ body.no-side #side { pointer-events: none; }
|
||||
|
||||
/* --- composer ------------------------------------------------------------- */
|
||||
#composer { padding: 8px 20px 12px; }
|
||||
#composer > * { max-width: 760px; margin: 0 auto; }
|
||||
.pending { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 0 8px; }
|
||||
#composer > * { max-width: var(--content); margin: 0 auto; }
|
||||
.pending { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 6px 0 10px; }
|
||||
.pending .chip { background: var(--bg-soft); border-color: var(--line); }
|
||||
/* images waiting for the next prompt, the way they will look once sent */
|
||||
.pend-thumb { position: relative; width: 56px; height: 56px; flex: 0 0 auto; animation: rise var(--t) var(--ease) both; }
|
||||
.pend-thumb img {
|
||||
display: block; width: 100%; height: 100%; object-fit: cover; cursor: zoom-in;
|
||||
border-radius: 12px; border: 1px solid var(--line);
|
||||
}
|
||||
.pend-x {
|
||||
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--main-surface-tertiary); border: 1px solid var(--line-strong); color: var(--fg-dim);
|
||||
opacity: 0; transition: opacity var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.pend-x svg.i { width: 12px; height: 12px; stroke-width: 2.2; }
|
||||
.pend-thumb:hover .pend-x, .pend-x:focus-visible { opacity: 1; }
|
||||
.pend-x:hover { color: var(--fg); }
|
||||
.edit-bar {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 10px; margin-bottom: 8px;
|
||||
border-radius: 10px; border: 1px solid var(--line); background: var(--bg-soft);
|
||||
@@ -698,9 +763,6 @@ body.no-panel #panel { pointer-events: none; }
|
||||
.todo-list .boxmark { color: var(--fg-faint); }
|
||||
.todo-list li.done { color: var(--fg-faint); text-decoration: line-through; }
|
||||
.todo-list li.doing { color: var(--fg); font-weight: 600; }
|
||||
#steps-body .step-line > summary { font-size: 12.5px; }
|
||||
#steps-body .step-line .phase { font-size: 11px; }
|
||||
|
||||
/* --- modal sheet (session list) ------------------------------------------- */
|
||||
#modal {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 40;
|
||||
@@ -808,11 +870,44 @@ body.drawer-open #backdrop { display: block; }
|
||||
body.side-open #side, body.panel-open #panel { transform: none; }
|
||||
.only-narrow { display: inline-flex; }
|
||||
#to-bottom { bottom: 150px; right: 16px; }
|
||||
.hero h1 { font-size: 21px; }
|
||||
.msg.user .body { max-width: 88%; }
|
||||
.turn-meta, .msg-actions, .turn-steps, .note-line { padding-left: 0; }
|
||||
.attach-strip .thumb { width: 120px; height: 120px; }
|
||||
#lightbox { padding: 56px 12px; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
#chip-approval .lbl { display: none; }
|
||||
.seg button { padding: 0 9px; }
|
||||
}
|
||||
@media (min-width: 901px) { #backdrop { display: none !important; } }
|
||||
/* touch screens have no hover: the actions stay visible */
|
||||
@media (hover: none) {
|
||||
.msg-actions, .pend-x { opacity: 1; }
|
||||
}
|
||||
|
||||
/* --- lightbox ------------------------------------------------------------- */
|
||||
#lightbox {
|
||||
position: fixed; inset: 0; z-index: 70; padding: 56px 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, .84); animation: fade-in var(--t) var(--ease) both;
|
||||
}
|
||||
#lb-img {
|
||||
max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, .5); animation: pop-in 180ms var(--ease) both;
|
||||
}
|
||||
.lb-btn {
|
||||
position: absolute; width: 40px; height: 40px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255, 255, 255, .1); color: #fff;
|
||||
transition: background var(--t-fast), transform 90ms var(--ease);
|
||||
}
|
||||
.lb-btn:hover { background: rgba(255, 255, 255, .2); }
|
||||
#lb-close { top: 14px; right: 14px; }
|
||||
#lb-prev { left: 14px; top: calc(50% - 20px); }
|
||||
#lb-next { right: 14px; top: calc(50% - 20px); }
|
||||
#lb-count {
|
||||
position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255, 255, 255, .75); font-size: 13px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- motion --------------------------------------------------------------- */
|
||||
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@@ -821,6 +916,7 @@ body.drawer-open #backdrop { display: block; }
|
||||
@keyframes pop-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } to { opacity: 1; transform: none; } }
|
||||
@keyframes pulse { 0%, 100% { opacity: .3; transform: scale(.85); } 50% { opacity: 1; transform: scale(1); } }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: -120% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
|
||||
#scroll { scroll-behavior: auto; }
|
||||
|
||||
Reference in New Issue
Block a user