Refine workspace reading and mobile layout

This commit is contained in:
caoqianming 2026-07-29 14:59:10 +08:00
parent 07eba5c402
commit 369c74483c
7 changed files with 300 additions and 59 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.9 — 2026-07-29
- 网页工作台改用更温和的暖白配色,任务与文件列表的分隔更加清楚;助手回复按完整轮次形成统一阅读块,手机端输入区也改为更紧凑的图标操作布局。
## 0.60.8 — 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 工作台 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 项按环境跳过)。

View File

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

View File

@ -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()

View File

@ -1841,10 +1841,31 @@
<span id="chat-attach"></span>
<div class="row">
<span class="hint" id="chat-hint">就绪</span>
<span style="flex:1;"></span>
<button type="button" class="small" id="chat-attach-btn" title="添加附件:手机可拍照 / 选照片,电脑直接开文件选择器(也可粘贴 / 拖拽)">📎 附件</button>
<button type="button" class="small" id="chat-mic" title="语音输入:点击开始录音,再点结束并转写填入输入框(最长 60 秒)">🎙 语音</button>
<button type="button" class="small" id="chat-optimize" disabled title="用当前对话模型润色草稿(参考已选生图模型偏好)— 替换为更清晰可执行的 prompt,Ctrl+Z 可撤销">✨ 润色</button>
<span class="composer-spacer"></span>
<button type="button" class="small composer-tool" id="chat-attach-btn"
aria-label="添加附件" title="添加附件:手机可拍照 / 选照片,电脑直接开文件选择器(也可粘贴 / 拖拽)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m21.4 11.6-8.9 8.9a6 6 0 0 1-8.5-8.5l9.6-9.6a4 4 0 0 1 5.7 5.7l-9.6 9.6a2 2 0 1 1-2.8-2.8l8.9-8.9"/>
</svg>
<span class="composer-label">附件</span>
</button>
<button type="button" class="small composer-tool" id="chat-mic"
aria-label="语音输入" title="语音输入:点击开始录音,再点结束并转写填入输入框(最长 60 秒)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5M8 22h8"/>
</svg>
<span class="composer-label">语音</span>
</button>
<button type="button" class="small composer-tool" id="chat-optimize" disabled
aria-label="润色提示词" title="用当前对话模型润色草稿(参考已选生图模型偏好)— 替换为更清晰可执行的 prompt,Ctrl+Z 可撤销">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 4 5 5L8 21H3v-5L15 4Z"/><path d="m13 6 5 5M5 3v4M3 5h4M19 16v5M16.5 18.5h5"/>
</svg>
<span class="composer-label">润色</span>
</button>
<button type="submit" class="primary" id="chat-action">发送</button>
</div>
</form>

View File

@ -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 = `<div class="role">助手</div>`;
card.innerHTML = `<div class="role">科研助手</div>`;
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 = `<div class="role">${roleLabel}</div>`;
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 += `<details class="reasoning"><summary>思考过程</summary><div class="reason-body">${escapeHtml(rc)}</div></details>`;
hasVisibleAssistantContent = true;
}
}
if (typeof p.content === "string" && p.content) {
html += `<div class="body">${renderMd(p.content)}</div>`;
if (role === "assistant") hasVisibleAssistantContent = true;
// assistant 正文里 echo 的 <wd>/... 路径**永远**挂 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 } = {}) {
<details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
${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 = `<div class="role">助手</div><div class="body streaming"></div>`;
asstCard.innerHTML = `<div class="role">科研助手</div><div class="body streaming"></div>`;
wrap.appendChild(asstCard);
wrap.scrollTop = wrap.scrollHeight;

View File

@ -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 {