Streamline conversation activity UI

This commit is contained in:
caoqianming 2026-07-29 15:59:00 +08:00
parent 02b278b175
commit bcdbdd4738
6 changed files with 199 additions and 76 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.11 — 2026-07-29
- 网页工作台进一步回归清爽白色界面:助手回复改为开放式文档流,工具调用与结果合并成可展开的紧凑活动项;同时修复语音输入停止后按钮仍显示“停止”的问题。
## 0.60.10 — 2026-07-29
- 网页工作台恢复清爽的中性白色配色,助手回复回到左侧阅读轴;手机端进一步缩小顶部页签、操作按钮和输入区,并移除默认的文件保存位置提示,为对话正文留出更多空间。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-29(Web 工作台移动端空间优化,bump 0.60.10)
最后更新:2026-07-29(Web 对话流去卡片化,bump 0.60.11)
---
@ -23,6 +23,7 @@
### 2026-07-29
- **07-29 / 0.60.11 / Web 对话流去卡片化 + 工具活动合并 + 语音状态修复**:工作台背景统一以纯白为主,浅灰仅保留在 hover、代码块等局部状态助手历史/实时回复移除重复角色标签、大卡片边框、左色条与运行态红框,改为左锚开放文档流。历史态按 `tool_call_id` 将 assistant 调用与 tool result 合并,实时态复用同一 details 原位更新结果,统一为默认折叠单行活动项(执行中/成功/失败状态明确),产物 chip、媒体 banner 与后台进程活化继续保留;顶部 task progress dock 的位置和逻辑未改。另修短按语音转连续录音时直接覆写按钮 `textContent`、销毁 SVG/label 导致停止后无法恢复“语音”的问题,收口为 class/label/ARIA 状态函数。相关 Python 24 项 + Node 5 项及 JS 语法、diff 格式检查通过;无 API、schema、migration 或依赖变化。
- **07-29 / 0.60.10 / Web 工作台移动端空间优化**:根据第二轮手机实图反馈把暖白/浅米灰改回中性白色桌面助手回答、工具卡与任务进度恢复左侧阅读轴手机端压缩顶栏、Tab、任务操作区、模型区、消息间距和 composer高频操作键由 44px 收至 40px、输入框最低高度由 64px 收至 52px并隐藏空闲态“请选择文件保存位置/就绪”占位行,运行、上传与错误等有效状态提示继续按需显示。相关 Python 21 项 + Node 5 项及 JS 语法、diff 格式检查通过;无 API、schema、migration 或依赖变化。
- **07-29 / 0.60.9 / Web 工作台视觉复核修正(桌面 + 手机实图)**:据上线截图把冷蓝灰改为暖白/浅米灰;任务与文件行恢复明确分隔,助手消息收进居中 52rem 文档型回答块,工具与进度对齐同一阅读轨道;同时修掉 `task_progress`-only assistant 被过滤工具后仍留下空“助手”壳。手机端 composer 改为独立状态行 + 3 个 44px SVG 图标键 + 发送键,缩短 placeholder、加入安全区并压缩操作/模型区;录音与润色状态改只更新 label/ARIA不再覆盖 SVG DOM。相关 Python 21 项 + Node 5 项及 JS 语法、HTML/SVG 结构、CSS 括号检查通过;无 API、schema、migration 或依赖变化。
- **07-29 / 0.60.8 / Web 工作台 Visual Polish v1**:新增零构建 `web/static/ui.css` 作为内部轻量视觉基础层,集中颜色、间距、圆角、阴影与 focus/disabled 等控件状态;收敛三栏层级、任务/资源导航、对话正文与工具过程块、输入区、文件栏、modal/toast 及 ≤640px 触控尺寸,不改 DOM 契约、API 或业务交互。新增 CSS 文件接入、MIME 与 `no-cache` 回归测试;相关 Python 20 项 + Node 5 项通过,浏览器连接不可用故截图级视觉验收留真实页面复核。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.60.10"
__version__ = "0.60.11"

View File

@ -68,6 +68,13 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('input.placeholder = mqPhone.matches', frontend)
self.assertIn("#chat-hint:empty", css)
self.assertNotIn("请选择文件保存位置", frontend)
self.assertIn("function setMicButtonState(", frontend)
self.assertNotIn('_micBtn.textContent = "■ 停止"', frontend)
self.assertIn("const toolCallsById = new Map()", frontend)
self.assertIn("const runningDet = finishRunningTool(ctx)", frontend)
self.assertNotIn('<div class="role">科研助手</div>', frontend)
self.assertIn(".tool-call.activity > summary", css)
self.assertIn("#task-progress-dock .task-progress", css)
def test_frontend_surfaces_context_and_cache_stats(self) -> None:
src = _frontend_source()

View File

@ -1267,16 +1267,24 @@ function tickRunStatus(run) {
if (run.toolElapsedEl && suffix) run.toolElapsedEl.textContent = suffix;
}
function isToolResultFailure(text) {
const value = String(text || "").trim();
return /^(?:\[ERR(?:OR)?\]|\[Error|Error executing|Traceback \(|Command failed)/i.test(value)
|| /\bexit code\s+[1-9]\d*\b/i.test(value);
}
// 工具执行收尾:去 spinner,elapsed 定格(留着当执行耗时看)
function finishRunningTool(run) {
if (run.runningToolEl) { run.runningToolEl.classList.remove("running"); run.runningToolEl = null; }
const el = run.runningToolEl;
if (el) el.classList.remove("running");
run.runningToolEl = null;
run.toolElapsedEl = null;
return el;
}
function createLiveAssistantCard(run) {
const card = document.createElement("div");
card.className = "msg assistant live-run";
card.innerHTML = `<div class="role">科研助手</div>`;
run.card = card;
run.curSeg = null;
if (run.acc) {
@ -1458,6 +1466,33 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
const seenRels = new Set();
// 历史态把 assistant tool_call 与紧随其后的 tool result 合成一条活动项。
// tool result 才是最终可追溯记录;已得到结果的 call 不再额外渲一条“准备调用”,避免双行噪声。
const toolCallsById = new Map();
const resolvedToolCallIds = new Set();
for (const item of msgs) {
const payload = item.payload || {};
if (payload.role === "assistant" && Array.isArray(payload.tool_calls)) {
for (const tc of payload.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let argsObj = {};
let args = "";
try {
argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}");
args = JSON.stringify(argsObj, null, 2);
} catch (e) {
args = (tc.function && tc.function.arguments) || "";
}
if (tc.id) toolCallsById.set(tc.id, {
fn,
args,
label: toolActivityLabel(fn, argsObj),
});
}
} else if (payload.role === "tool" && payload.tool_call_id) {
resolvedToolCallIds.add(payload.tool_call_id);
}
}
let currentProgressSteps = [];
const pickFresh = (rels) => {
const fresh = [];
@ -1492,13 +1527,18 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const banner = extractMediaBanner(p.name || "", txt || "");
const callMeta = toolCallsById.get(p.tool_call_id);
const activityLabel = callMeta ? callMeta.label : `已执行 ${p.name || "工具"}`;
const failed = isToolResultFailure(txt);
// 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
// (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "");
const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : [];
card.innerHTML = `
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
<details class="tool-call activity${failed ? " failed" : ""}">
<summary><span class="tool-state" aria-hidden="true">${failed ? "!" : "✓"}</span><span class="tool-label">${escapeHtml(activityLabel)}</span><span class="tool-result-meta">${(txt || "").length} </span>${banner}</summary>
<pre>${escapeHtml(txt || "")}</pre>
</details>
${renderArtifactBarHtml(rels, isProducer)}
`;
// bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态
@ -1510,8 +1550,8 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const card = document.createElement("div");
card.className = "msg " + role;
card.dataset.idx = m.idx;
const roleLabel = { user: "我", assistant: "科研助手", error: "错误" }[role] || role;
let html = `<div class="role">${roleLabel}</div>`;
const roleLabel = { user: "我", error: "错误" }[role] || role;
let html = role === "assistant" ? "" : `<div class="role">${roleLabel}</div>`;
let hasVisibleAssistantContent = false;
// 思考过程(thinking 模型持久化的 reasoning_content):默认折叠,与直播态同构
if (role === "assistant") {
@ -1562,11 +1602,12 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
hasVisibleAssistantContent = true;
continue;
}
if (tc.id && resolvedToolCallIds.has(tc.id)) continue;
const label = toolActivityLabel(fn, argsObj);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
html += `
<details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
<details class="tool-call activity"><summary><span class="tool-state" aria-hidden="true"></span><span class="tool-label">${escapeHtml(label)}</span></summary><pre>${escapeHtml(args)}</pre></details>
${renderArtifactBarHtml(rels, isProducer)}
`;
hasVisibleAssistantContent = true;
@ -1951,6 +1992,17 @@ let _voice = null; // 录音态:{stream,ctx,source,node,chunks,levels,rate,t0,t
let _holdReleased = false; // 手指在 getUserMedia 授权完成前已松开 → 启动完成后立即收尾
let _suppressClickUntil = 0; // touch 序列结束后浏览器补发的合成 click,不当 toggle 用
// 只更新状态 class / label / ARIA绝不改 button.textContent
// 按钮内含 SVG直接写 textContent 会销毁图标和 .composer-label随后停止录音也无法恢复文案。
function setMicButtonState(recording, stoppable = false) {
const btn = $("chat-mic");
if (!btn) return;
btn.classList.toggle("recording", recording);
const label = btn.querySelector(".composer-label");
if (label) label.textContent = stoppable ? "停止" : "语音";
btn.setAttribute("aria-label", stoppable ? "停止录音" : "语音输入");
}
// 线性插值降采样到 16k 并量化 Int16 小端(讯飞只收 16k/16bit/mono raw)
function _pcm16kFromChunks(chunks, srcRate) {
let total = 0;
@ -2021,7 +2073,6 @@ function _drawWave() {
async function startVoiceInput(mode, startY) {
if (_voice) return;
const btn = $("chat-mic");
if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
$("chat-hint").textContent = "此环境不支持录音(需 HTTPS 或 localhost 访问)";
return;
@ -2092,12 +2143,7 @@ async function startVoiceInput(mode, startY) {
cv.width = Math.max(1, cv.clientWidth * dpr); // 面板刚显示,读 clientWidth 强制同步布局
cv.height = Math.max(1, cv.clientHeight * dpr);
_vrHint(mode === "hold" ? "松开转写 · 上滑取消" : "说完按 Enter 结束 · Esc 取消");
btn.classList.add("recording");
if (mode === "click") {
const label = btn.querySelector(".composer-label");
if (label) label.textContent = "停止";
btn.setAttribute("aria-label", "停止录音");
}
setMicButtonState(true, mode === "click");
const tick = () => {
if (!_voice) return;
const sec = Math.floor((Date.now() - _voice.t0) / 1000);
@ -2113,9 +2159,7 @@ async function startVoiceInput(mode, startY) {
if (mode === "hold" && _holdReleased) {
if (Date.now() - _pressT0 < HOLD_TOGGLE_MS && !_voice.cancelArmed) {
_voice.mode = "click";
const label = btn.querySelector(".composer-label");
if (label) label.textContent = "停止";
btn.setAttribute("aria-label", "停止录音");
setMicButtonState(true, true);
_vrHint("说完按 Enter 结束 · Esc 取消");
} else {
stopVoiceInput(_voice.cancelArmed);
@ -2129,11 +2173,7 @@ async function stopVoiceInput(discard) {
_voice = null;
clearTimeout(v.timer);
cancelAnimationFrame(v.raf);
const btn = $("chat-mic");
btn.classList.remove("recording");
const label = btn.querySelector(".composer-label");
if (label) label.textContent = "语音";
btn.setAttribute("aria-label", "语音输入");
setMicButtonState(false);
$("chat-form").classList.remove("recording");
$("voice-rec-panel").classList.remove("cancel-armed");
$("vr-time").textContent = "0:00";
@ -2226,7 +2266,7 @@ const _holdEnd = (e) => {
if (e.type !== "pointercancel" && !_voice.cancelArmed && Date.now() - _pressT0 < HOLD_TOGGLE_MS) {
// 短按 → 连续录音(免持):说完 Enter / 再点一下结束
_voice.mode = "click";
_micBtn.textContent = "■ 停止";
setMicButtonState(true, true);
_vrHint("说完按 Enter 结束 · Esc 取消");
return;
}
@ -2419,7 +2459,7 @@ async function sendMessage(overrideText) {
// assistant 流式占位卡
const asstCard = document.createElement("div");
asstCard.className = "msg assistant live-run";
asstCard.innerHTML = `<div class="role">科研助手</div><div class="body streaming"></div>`;
asstCard.innerHTML = `<div class="body streaming"></div>`;
wrap.appendChild(asstCard);
wrap.scrollTop = wrap.scrollHeight;
@ -2745,8 +2785,8 @@ function handleSseEvent(ev, asstCard, ctx) {
}
const label = toolActivityLabel(fn, args);
const det = document.createElement("details");
det.className = "tool-call running";
det.innerHTML = `<summary>${escapeHtml(label)}<span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
det.className = "tool-call activity running";
det.innerHTML = `<summary><span class="tool-state" aria-hidden="true">…</span><span class="tool-label">${escapeHtml(label)}</span><span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
// 运行态:spinner + 跳秒,tool_result 到达时定格(区分"在执行"和"卡死")
ctx.runningToolEl = det;
@ -2769,13 +2809,24 @@ function handleSseEvent(ev, asstCard, ctx) {
const toolName = (ev.data && ev.data.name) || "";
if (toolName === "task_progress") return;
if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
finishRunningTool(ctx); // 去 spinner,elapsed 定格
const runningDet = finishRunningTool(ctx); // 去 spinner,elapsed 定格
closeTextSeg(ctx); // 结果卡追加到当前文字段之下,之后新文字另起底部段
const banner = extractMediaBanner(toolName, txtStr);
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
const failed = isToolResultFailure(txtStr);
const det = runningDet || document.createElement("details");
det.classList.add("tool-call", "activity");
det.classList.toggle("failed", failed);
if (!runningDet) {
det.innerHTML = `<summary><span class="tool-state" aria-hidden="true">${failed ? "!" : "✓"}</span><span class="tool-label">${escapeHtml(toolName ? `已执行 ${toolName}` : "工具执行完成")}</span></summary><pre></pre>`;
asstCard.appendChild(det);
} else {
const stateEl = det.querySelector(".tool-state");
if (stateEl) stateEl.textContent = failed ? "!" : "✓";
}
const pre = det.querySelector("pre");
if (pre) pre.textContent = txtStr;
const summary = det.querySelector("summary");
if (summary && banner) summary.insertAdjacentHTML("beforeend", banner);
const wd = _workingDirName(ctx.workingDir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
const fresh = isProducer

View File

@ -11,10 +11,10 @@
color-scheme: light;
/* Neutral workspace palette */
--bg: #f7f7f7;
--bg: #ffffff;
--panel: #ffffff;
--panel-subtle: #fafafa;
--panel-muted: #f5f5f5;
--panel-subtle: #fcfcfc;
--panel-muted: #f8f8f8;
--border: #e1e3e5;
--border-soft: #eceeef;
--list-divider: #d9dde0;
@ -28,9 +28,9 @@
--accent-soft: #fbecea;
--accent-ring: rgba(184, 58, 49, 0.16);
--hover: #f2f3f4;
--code-bg: #f5f5f5;
--user-bg: #f2f6fa;
--hover: #f7f7f7;
--code-bg: #f7f7f7;
--user-bg: #f6f8fa;
--asst-bg: #ffffff;
--c-green: #238653;
@ -191,7 +191,7 @@ header .who {
#pane-left,
#pane-right {
background: var(--panel-subtle);
background: var(--panel);
}
#pane-mid {
@ -209,7 +209,7 @@ header .who {
.pane-head {
min-height: 44px;
padding: 8px 12px;
background: rgba(248, 249, 251, 0.96);
background: var(--panel);
border-bottom-color: var(--border-soft);
}
@ -254,7 +254,7 @@ header .who {
}
.task-row:hover {
background: var(--panel);
background: var(--hover);
box-shadow: none;
}
@ -370,22 +370,19 @@ header .who {
width: min(100%, 52rem);
max-width: min(100%, 52rem);
align-self: flex-start;
padding: 16px 18px;
border: 1px solid var(--border-soft);
border-left: 3px solid #cbd0d4;
border-radius: var(--r-lg);
background: #ffffff;
padding: 3px 0 18px;
border: 0;
border-bottom: 1px solid var(--border-soft);
border-radius: 0;
background: transparent;
}
.msg.assistant.live-run {
padding: 16px 18px;
border-color: rgba(184, 58, 49, 0.2);
border-left-color: var(--accent);
border-left-width: 3px;
border-radius: var(--r-lg);
box-shadow:
0 0 0 1px rgba(184, 58, 49, 0.05),
0 8px 24px rgba(184, 58, 49, 0.06);
padding: 3px 0 18px;
border: 0;
border-bottom: 1px solid var(--border-soft);
border-radius: 0;
box-shadow: none;
}
.msg .role {
@ -398,33 +395,20 @@ header .who {
}
.msg.assistant > .role {
display: flex;
align-items: center;
gap: 7px;
color: #656b70;
}
.msg.assistant > .role::before {
content: "";
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
display: none;
}
.msg.tool {
width: min(100%, 52rem);
max-width: min(100%, 52rem);
align-self: flex-start;
padding: 0 18px;
padding: 0;
border: 0;
background: transparent;
}
.msg.tool > .role {
margin-bottom: 5px;
display: none;
}
#task-progress-dock .task-progress {
@ -459,7 +443,6 @@ header .who {
color: #555b60;
}
.tool-call summary,
.reasoning summary {
padding: 6px 8px;
border: 1px solid var(--border-soft);
@ -467,11 +450,88 @@ header .who {
background: var(--panel-muted);
}
.tool-call summary:hover,
.reasoning summary:hover {
background: var(--hover);
}
.tool-call.activity {
margin: 3px 0;
}
.tool-call.activity > summary {
display: flex;
align-items: center;
min-height: 28px;
gap: 7px;
padding: 3px 6px;
color: var(--muted);
border: 0;
border-radius: var(--r-md);
background: transparent;
list-style: none;
}
.tool-call.activity > summary::-webkit-details-marker {
display: none;
}
.tool-call.activity > summary::after {
content: "";
flex: none;
margin-left: 2px;
color: var(--muted);
font-size: 15px;
line-height: 1;
transform: rotate(0deg);
transition: transform 0.15s ease;
}
.tool-call.activity[open] > summary::after {
transform: rotate(90deg);
}
.tool-call.activity > summary:hover,
.tool-call.activity[open] > summary {
color: var(--text);
background: var(--hover);
}
.tool-call.activity .tool-state {
width: 16px;
flex: none;
color: var(--c-green);
font-family: var(--mono);
font-weight: 700;
text-align: center;
}
.tool-call.activity.running .tool-state {
color: var(--muted);
}
.tool-call.activity.failed > summary,
.tool-call.activity.failed .tool-state {
color: var(--c-red);
}
.tool-call.activity .tool-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call.activity .tool-result-meta {
margin-left: auto;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.tool-call.activity > pre {
margin: 3px 0 5px 23px;
}
.tool-call pre {
border: 1px solid var(--border-soft);
background: var(--code-bg);
@ -538,7 +598,7 @@ header .who {
}
.file-row:hover {
background: var(--panel);
background: var(--hover);
}
.file-row .name {
@ -697,13 +757,13 @@ header .who {
.msg.assistant {
width: 100%;
max-width: 100%;
padding: 10px 12px;
padding: 2px 0 14px;
}
.msg.tool {
width: 100%;
max-width: 100%;
padding: 0 14px;
padding: 0;
}
.msg .body {