diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae6fd4f..08da960 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
+## 0.60.11 — 2026-07-29
+
+- 网页工作台进一步回归清爽白色界面:助手回复改为开放式文档流,工具调用与结果合并成可展开的紧凑活动项;同时修复语音输入停止后按钮仍显示“停止”的问题。
+
## 0.60.10 — 2026-07-29
- 网页工作台恢复清爽的中性白色配色,助手回复回到左侧阅读轴;手机端进一步缩小顶部页签、操作按钮和输入区,并移除默认的文件保存位置提示,为对话正文留出更多空间。
diff --git a/PROGRESS.md b/PROGRESS.md
index 7ac37c8..5a1a949 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -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 项通过,浏览器连接不可用故截图级视觉验收留真实页面复核。
diff --git a/core/__init__.py b/core/__init__.py
index 14b105e..f061a6d 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
-__version__ = "0.60.10"
+__version__ = "0.60.11"
diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py
index 296c1ce..6b79b30 100644
--- a/tests/test_static_vendor.py
+++ b/tests/test_static_vendor.py
@@ -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('
科研助手
', 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()
diff --git a/web/static/js/chat.js b/web/static/js/chat.js
index b40eb58..71a079e 100644
--- a/web/static/js/chat.js
+++ b/web/static/js/chat.js
@@ -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 = `科研助手
`;
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 = `
- 工具调用 · ${escapeHtml(p.name || "")}
- 结果(${(txt || "").length} 字符)${banner}
${escapeHtml(txt || "")}
+
+ ${failed ? "!" : "✓"}${escapeHtml(activityLabel)}${(txt || "").length} 字符${banner}
+ ${escapeHtml(txt || "")}
+
${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 = `${roleLabel}
`;
+ const roleLabel = { user: "我", error: "错误" }[role] || role;
+ let html = role === "assistant" ? "" : `${roleLabel}
`;
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 += `
- ${escapeHtml(label)}
${escapeHtml(args)}
+ …${escapeHtml(label)}
${escapeHtml(args)}
${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 = `科研助手
`;
+ asstCard.innerHTML = ``;
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 = `${escapeHtml(label)}${escapeHtml(argsStr)}`;
+ det.className = "tool-call activity running";
+ det.innerHTML = `…${escapeHtml(label)}${escapeHtml(argsStr)}`;
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 = `工具结果${banner}${escapeHtml(txtStr)}`;
- asstCard.appendChild(det);
+ 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 = `${failed ? "!" : "✓"}${escapeHtml(toolName ? `已执行 ${toolName}` : "工具执行完成")}`;
+ 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
diff --git a/web/static/ui.css b/web/static/ui.css
index 33cebd9..0959f99 100644
--- a/web/static/ui.css
+++ b/web/static/ui.css
@@ -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 {