From 369c74483c7ed147c264d256a72fcccc278f24a4 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 29 Jul 2026 14:59:10 +0800 Subject: [PATCH] Refine workspace reading and mobile layout --- CHANGELOG.md | 4 + PROGRESS.md | 3 +- core/__init__.py | 2 +- tests/test_static_vendor.py | 14 +++ web/static/dev.html | 29 ++++- web/static/js/chat.js | 62 +++++++-- web/static/ui.css | 245 ++++++++++++++++++++++++++++++------ 7 files changed, 300 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f976465..7497295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.9 — 2026-07-29 + +- 网页工作台改用更温和的暖白配色,任务与文件列表的分隔更加清楚;助手回复按完整轮次形成统一阅读块,手机端输入区也改为更紧凑的图标操作布局。 + ## 0.60.8 — 2026-07-29 - 网页工作台完成一轮整体视觉优化:任务、对话和文件三栏层级更清晰,长回复阅读更舒适,按钮、输入框、弹窗、状态反馈及手机端触控尺寸也更加统一。 diff --git a/PROGRESS.md b/PROGRESS.md index 3f2886a..c57047c 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 工作台 Visual Polish v1,bump 0.60.8) +最后更新:2026-07-29(Web 工作台视觉复核修正,bump 0.60.9) --- @@ -23,6 +23,7 @@ ### 2026-07-29 +- **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 项通过,浏览器连接不可用故截图级视觉验收留真实页面复核。 - **07-29 / 0.60.7 / 组合框内直接新建工作目录**:新对话目录 combobox 增加 allow-create 语义:输入时继续过滤已有目录,无完全同名项时在同一下拉内提供“创建工作目录「名称」”,明确选择后才进入新建状态,失焦搜索词不会误提交;删除外部新建按钮与二级输入框。纯前端改动,无 API、schema、migration 或目录绑定语义变化;全量 387 测试通过(17 项按环境跳过)。 - **07-29 / 0.60.6 / 新对话目录选择改可搜索组合框**:将上一版独立搜索框 + 原生 select 合并为单一 combobox,同一输入区完成已选目录展示、即时过滤和下拉选择,并补方向键、Enter、Esc、失焦恢复与 ARIA 状态;最近目录置顶、明确选择 working_dir 和新建目录入口均保持不变。纯前端改动,无依赖、API、schema 或 migration;全量 387 测试通过(17 项按环境跳过)。 diff --git a/core/__init__.py b/core/__init__.py index 7b55ac0..a63294f 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.8" +__version__ = "0.60.9" diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 682ecdf..506fc16 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -53,6 +53,20 @@ class StaticVendorTests(unittest.TestCase): self.assertIn(":focus-visible", css) self.assertIn("@media (max-width: 640px)", css) + def test_ui_polish_keeps_mobile_composer_and_reading_rail_contracts(self) -> None: + html = DEV_HTML.read_text(encoding="utf-8") + css = UI_CSS.read_text(encoding="utf-8") + frontend = _frontend_source() + + self.assertIn("--list-divider:", css) + self.assertIn(".msg.assistant", css) + self.assertIn(".composer-tool .composer-label", css) + self.assertIn('aria-label="添加附件"', html) + self.assertIn('aria-label="语音输入"', html) + self.assertIn('aria-label="润色提示词"', html) + self.assertIn("hasVisibleAssistantContent", frontend) + self.assertIn('input.placeholder = mqPhone.matches', frontend) + def test_frontend_surfaces_context_and_cache_stats(self) -> None: src = _frontend_source() diff --git a/web/static/dev.html b/web/static/dev.html index e96db2f..8adde94 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1841,10 +1841,31 @@
就绪 - - - - + + + +
diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 1a3b366..ea2d9f2 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -620,7 +620,7 @@ export async function showNewConversationDraft() { $("chat-form").style.display = "flex"; $("chat-input").readOnly = false; $("chat-input").classList.remove("readonly-locked"); - $("chat-input").placeholder = "输入消息…(Enter 发送,Shift+Enter 换行)"; + setComposerPlaceholder(null); $("chat-attach-btn").disabled = true; // task_dir 尚不存在,首发建 task 后开放 $("chat-mic").disabled = false; $("btn-done").disabled = true; @@ -1275,7 +1275,7 @@ function finishRunningTool(run) { function createLiveAssistantCard(run) { const card = document.createElement("div"); card.className = "msg assistant live-run"; - card.innerHTML = `
助手
`; + card.innerHTML = `
科研助手
`; run.card = card; run.curSeg = null; if (run.acc) { @@ -1509,18 +1509,21 @@ 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; + const roleLabel = { user: "我", assistant: "科研助手", error: "错误" }[role] || role; let html = `
${roleLabel}
`; + let hasVisibleAssistantContent = false; // 思考过程(thinking 模型持久化的 reasoning_content):默认折叠,与直播态同构 if (role === "assistant") { const rc = (typeof p.reasoning_content === "string" && p.reasoning_content) || ((p.provider_specific_fields || {}).reasoning_content || ""); if (rc) { html += `
思考过程
${escapeHtml(rc)}
`; + hasVisibleAssistantContent = true; } } if (typeof p.content === "string" && p.content) { html += `
${renderMd(p.content)}
`; + if (role === "assistant") hasVisibleAssistantContent = true; // assistant 正文里 echo 的 /... 路径**永远**挂 chip(绕开 seenRels —— 上面 // tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可 // 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。 @@ -1555,6 +1558,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) { } } html += buildAskUserCard(argsObj, { interactive: !answered, chosenLabel: chosen }).outerHTML; + hasVisibleAssistantContent = true; continue; } const label = toolActivityLabel(fn, argsObj); @@ -1564,6 +1568,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
${escapeHtml(label)}
${escapeHtml(args)}
${renderArtifactBarHtml(rels, isProducer)} `; + hasVisibleAssistantContent = true; } // 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress) } @@ -1571,6 +1576,9 @@ function renderMessages(msgs, { stickBottom = true } = {}) { const hasCopy = role === "user" || (role === "assistant" && typeof p.content === "string" && p.content.trim()); if (hasCopy) html += msgActionsHtml(); + // task_progress-only assistant rows feed the persistent progress dock. + // Once that tool call is filtered, do not leave an empty assistant shell. + if (role === "assistant" && !hasVisibleAssistantContent) continue; card.innerHTML = html; highlightIn(card); wrap.appendChild(card); @@ -1828,9 +1836,7 @@ function applyChannelComposerLock(meta) { const cfg = meta && channelCfg(meta.channel); // 微信 / 企业微信镜像 → 只读 input.readOnly = !!cfg; input.classList.toggle("readonly-locked", !!cfg); - input.placeholder = cfg - ? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史` - : "输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"; + setComposerPlaceholder(cfg); if (cfg) { const opt = $("chat-optimize"); if (opt) opt.disabled = true; @@ -1841,6 +1847,24 @@ function applyChannelComposerLock(meta) { if (attach) attach.disabled = !!cfg; // 附件按钮同口径(与拖拽/粘贴的 _composerLocked 一致) } +function setComposerPlaceholder(cfg) { + const input = $("chat-input"); + if (!input) return; + if (cfg) { + input.placeholder = mqPhone.matches + ? `${cfg.label}对话请在${cfg.label}中进行` + : `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史`; + return; + } + input.placeholder = mqPhone.matches + ? "输入消息…" + : "输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"; +} + +mqPhone.addEventListener("change", () => { + setComposerPlaceholder(channelCfg(state.taskMeta && state.taskMeta.channel)); +}); + // 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText') // 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端 // 不与主对话 run 互斥,各跑各的 LLM)。 @@ -1861,8 +1885,11 @@ async function optimizePrompt() { const btn = $("chat-optimize"); state.optimizing = true; btn.disabled = true; - const oldLabel = btn.textContent; - btn.textContent = "润色中…"; + const label = btn.querySelector(".composer-label"); + const oldLabel = label ? label.textContent : "润色"; + const oldAriaLabel = btn.getAttribute("aria-label") || "润色提示词"; + if (label) label.textContent = "润色中"; + btn.setAttribute("aria-label", "正在润色"); const oldHint = $("chat-hint").textContent; $("chat-hint").textContent = "润色中…"; try { @@ -1891,7 +1918,8 @@ async function optimizePrompt() { $("chat-hint").textContent = `润色失败:${e.message}`; } finally { state.optimizing = false; - btn.textContent = oldLabel; + if (label) label.textContent = oldLabel; + btn.setAttribute("aria-label", oldAriaLabel); syncOptimizeBtn(); } } @@ -2064,7 +2092,11 @@ async function startVoiceInput(mode, startY) { cv.height = Math.max(1, cv.clientHeight * dpr); _vrHint(mode === "hold" ? "松开转写 · 上滑取消" : "说完按 Enter 结束 · Esc 取消"); btn.classList.add("recording"); - if (mode === "click") btn.textContent = "■ 停止"; + if (mode === "click") { + const label = btn.querySelector(".composer-label"); + if (label) label.textContent = "停止"; + btn.setAttribute("aria-label", "停止录音"); + } const tick = () => { if (!_voice) return; const sec = Math.floor((Date.now() - _voice.t0) / 1000); @@ -2080,7 +2112,9 @@ async function startVoiceInput(mode, startY) { if (mode === "hold" && _holdReleased) { if (Date.now() - _pressT0 < HOLD_TOGGLE_MS && !_voice.cancelArmed) { _voice.mode = "click"; - btn.textContent = "■ 停止"; + const label = btn.querySelector(".composer-label"); + if (label) label.textContent = "停止"; + btn.setAttribute("aria-label", "停止录音"); _vrHint("说完按 Enter 结束 · Esc 取消"); } else { stopVoiceInput(_voice.cancelArmed); @@ -2096,7 +2130,9 @@ async function stopVoiceInput(discard) { cancelAnimationFrame(v.raf); const btn = $("chat-mic"); btn.classList.remove("recording"); - btn.textContent = "🎙 语音"; + const label = btn.querySelector(".composer-label"); + if (label) label.textContent = "语音"; + btn.setAttribute("aria-label", "语音输入"); $("chat-form").classList.remove("recording"); $("voice-rec-panel").classList.remove("cancel-armed"); $("vr-time").textContent = "0:00"; @@ -2382,7 +2418,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; diff --git a/web/static/ui.css b/web/static/ui.css index f125b8d..0e67fa1 100644 --- a/web/static/ui.css +++ b/web/static/ui.css @@ -11,15 +11,16 @@ color-scheme: light; /* Neutral workspace palette */ - --bg: #f3f5f7; + --bg: #f6f4f1; --panel: #ffffff; - --panel-subtle: #f8f9fb; - --panel-muted: #f4f6f8; - --border: #dde2e7; - --border-soft: #e9edf1; - --text: #20242a; - --text-strong: #15181d; - --muted: #6f7782; + --panel-subtle: #fbfaf8; + --panel-muted: #f7f4f0; + --border: #e2ddd7; + --border-soft: #ede8e2; + --list-divider: #ddd7d0; + --text: #292623; + --text-strong: #1c1a18; + --muted: #756f69; /* Institute red is deliberately reserved for primary emphasis. */ --accent: #b83a31; @@ -27,9 +28,9 @@ --accent-soft: #fbecea; --accent-ring: rgba(184, 58, 49, 0.16); - --hover: #f0f3f5; - --code-bg: #f3f5f7; - --user-bg: #f1f5fa; + --hover: #f5f1ed; + --code-bg: #f6f3ef; + --user-bg: #f5f1eb; --asst-bg: #ffffff; --c-green: #238653; @@ -97,7 +98,7 @@ button.small { } button:hover:not(:disabled) { - border-color: #cbd2d9; + border-color: #cdc5bd; } button.primary { @@ -136,7 +137,7 @@ select { input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):hover, textarea:hover, select:hover { - border-color: #c7ced5; + border-color: #c9c1b9; } input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):focus, @@ -148,7 +149,7 @@ select:focus { } ::placeholder { - color: #9aa1aa; + color: #9b958f; } /* Application shell */ @@ -202,7 +203,7 @@ header .who { } .splitter::before { - background: #d7dde3; + background: #d9d3cc; } .pane-head { @@ -224,7 +225,7 @@ header .who { #task-scroll, #file-list { - scrollbar-color: #cbd1d7 transparent; + scrollbar-color: #cec7bf transparent; scrollbar-width: thin; } @@ -240,27 +241,28 @@ header .who { } #task-list { - padding: 4px 8px 8px; + padding: 0; } .task-row { - margin: 3px 0; - padding: 9px 10px; - border: 1px solid transparent; - border-radius: var(--r-md); + margin: 0; + padding: 10px 12px 10px 9px; + border: 0; + border-left: 3px solid transparent; + border-bottom: 1px solid var(--list-divider); + border-radius: 0; } .task-row:hover { background: var(--panel); - border-color: var(--border-soft); - box-shadow: 0 1px 3px rgba(24, 32, 41, 0.04); + box-shadow: none; } .task-row.active { - padding-left: 8px; + padding-left: 9px; background: var(--accent-soft); - border-color: #f0d2cf; border-left: 3px solid var(--accent); + border-bottom-color: #e8c6c3; box-shadow: none; } @@ -335,7 +337,8 @@ header .who { line-height: 1.7; } -/* Conversation: assistant content reads like a document, not a bubble stack. */ +/* Conversation: one centred reading rail, with each assistant turn grouped as + a document block rather than a floating chat label. */ #chat-meta { min-height: 40px; @@ -345,7 +348,7 @@ header .who { } #chat-stream { - gap: 14px; + gap: 16px; padding: 20px clamp(16px, 3vw, 36px) 24px; background: var(--panel); scrollbar-color: #cbd1d7 transparent; @@ -364,14 +367,21 @@ header .who { } .msg.assistant { - padding: 8px 0; - border-color: transparent; - border-radius: 0; + width: min(100%, 52rem); + max-width: min(100%, 52rem); + align-self: center; + padding: 16px 18px; + border: 1px solid var(--border-soft); + border-left: 3px solid #cbc3bb; + border-radius: var(--r-lg); + background: #fffdfa; } .msg.assistant.live-run { - padding: 12px 14px; + 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), @@ -387,6 +397,42 @@ header .who { letter-spacing: 0.2px; } +.msg.assistant > .role { + display: flex; + align-items: center; + gap: 7px; + color: #6d665f; +} + +.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); +} + +.msg.tool { + width: min(100%, 52rem); + max-width: min(100%, 52rem); + align-self: center; + padding: 0 18px; + border: 0; + background: transparent; +} + +.msg.tool > .role { + margin-bottom: 5px; +} + +#task-progress-dock .task-progress { + width: min(100%, 52rem); + max-width: 52rem; + margin: 0 auto; +} + .msg .body { color: var(--text); font-size: 15px; @@ -410,7 +456,7 @@ header .who { .tool-call, .reasoning { - color: #4f5863; + color: #5f5851; } .tool-call summary, @@ -444,13 +490,30 @@ header .who { padding: 10px 12px; line-height: 1.6; border-radius: var(--r-lg); - background: #fcfcfd; + background: #fffdfa; } #chat-form .row { align-items: center; } +.composer-spacer { + flex: 1; +} + +.composer-tool { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; +} + +.composer-tool svg { + width: 15px; + height: 15px; + flex: none; +} + #chat-action { min-width: 64px; } @@ -467,7 +530,7 @@ header .who { .file-row { min-height: 38px; padding: 7px 12px; - border-bottom-color: var(--border-soft); + border-bottom: 1px solid var(--list-divider); } .file-row:hover { @@ -475,7 +538,7 @@ header .who { } .file-row .name { - color: #30363d; + color: #332f2b; } .storage-foot { @@ -486,7 +549,7 @@ header .who { .storage-foot .bar { height: 5px; - background: #e5e9ed; + background: #e8e3de; } /* Dialogs and notifications */ @@ -557,13 +620,69 @@ header .who { min-height: 42px; } + .mobile-tabs { + gap: 6px; + } + + .mobile-tabs button { + min-height: 38px; + padding: 6px 8px; + } + + #pane-mid > .pane-head { + min-height: 38px; + padding: 5px 10px; + } + + #pane-mid > .pane-head > button { + min-height: 30px; + } + + #chat-meta { + min-height: auto; + padding: 7px 10px; + column-gap: 7px; + row-gap: 7px; + } + + #chat-meta > .spacer { + flex: 1 0 100%; + height: 0; + } + + #chat-meta .mdl-wrap { + flex: 1 1 auto; + min-width: 0; + } + + #chat-meta select { + width: 100% !important; + max-width: none; + } + + #task-progress-dock { + padding: 6px 10px; + } + #chat-stream { - gap: 12px; + gap: 14px; padding: 14px 12px 18px; } .msg { - max-width: 97%; + max-width: 100%; + } + + .msg.assistant { + width: 100%; + max-width: 100%; + padding: 13px 14px; + } + + .msg.tool { + width: 100%; + max-width: 100%; + padding: 0 14px; } .msg .body { @@ -573,14 +692,60 @@ header .who { #chat-form { padding: 10px; + padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)); } #chat-input { min-height: 64px; } - #chat-form .row button { - min-height: 36px; + #chat-form .row { + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + } + + #chat-hint { + order: -1; + flex: 1 0 100%; + min-height: 18px; + line-height: 18px; + } + + .composer-spacer { + display: none; + } + + #chat-form .row .composer-tool { + width: 44px; + min-width: 44px; + height: 44px; + min-height: 44px; + padding: 0; + } + + .composer-tool svg { + width: 19px; + height: 19px; + } + + .composer-tool .composer-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + #chat-action { + width: 72px; + min-width: 72px; + height: 44px; + min-height: 44px; } .modal {