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 || "")}
+
+ ${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 = `${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 {