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 {