diff --git a/PROGRESS.md b/PROGRESS.md index e73ad94..dca86bb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-05(新增 standard skill:国标/行标/团标起草) +最后更新:2026-06-06(前端模块化 Step 2:抽出 layout.js) --- @@ -23,6 +23,7 @@ ### 2026-06-06 +- **前端模块化 Step 2(起):从 main.js 抽出 `layout.js`**:三栏布局(pane 折叠 rail + 拖拽 splitter + 手机单列视图)是 main.js 里唯一对其他功能节零出边的干净段,用它打样增量剥离。`layout.js`(121 行):import `$` + 4 个 `LS_*_COLLAPSED/WIDTH`,只导出 `mqPhone`/`setMobileView`(后者供 selectTask 在手机宽下选中任务自动切对话面板,是唯一跨模块调用);折叠/splitter/mobile-tab 的顶层事件绑定原样保留(ES module 默认 defer,import 时 DOM 已就绪)。main.js 删 114 行 → 2606 行,加 layout import 并清掉随之不再用的 4 个 `LS_*` import。**逻辑零改动,纯剪切+连线**;`node --check` 过、main 残留 layout 私有符号清零。**顺手修 Step 1 遗留测试失败**:`test_static_vendor` 第二用例原只 grep `dev.html` 找 `formatContextStats`/`context_original_chars`/`cache_hit_tokens`,模块化后这些搬进 `js/*.js` → 改为扫 `dev.html + js/*.js` 合并源,2 测试全过。后续按干净度继续剥(下一个 auth = login+加用户+改密码,会引入 main↔auth 的 ES 环,靠 live binding 解)。 - **修 deepseek-v4-flash 大参数工具调用 arguments 损坏 → loop 畸形重试 + 非流式兜底**:用户报"测试docx"任务里 zcbot 回 `[Error] bad arguments to write: WriteTool.execute() missing 2 required positional arguments`。实证定位(dump 失败 task 全量 messages):**大参数(≈7–10K 字符)的 write/run_python 偶发把别处内容碎片错位粘进 `arguments` 开头**(如 `].cells[1].merge(...{"path":...}`),`json.loads` 直接失败;有时退化成空 `{}` → execute 缺参报 TypeError。**根因双层**:① 上游 deepseek-v4-flash 流式 delta 偶发错位(隔离复现 16/16 全干净,说明概率低);② 真正放大成灾的是 **loop 把损坏的 assistant 消息原样入库 + 每轮重发 → 模型学坏的投毒级联**(失败 task 里大半 write 连锁失败)。读 litellm `stream_chunk_builder` 源码排除"content 混进 args"(content 与 tool_args 两趟独立合并);批量验证非流式 8/8、流式 8/8 在干净上下文均不复现 → 确认是间歇上游抖动 + loop 零容错。**修法**(`core/loop.py`):`_stream_llm` 重构成「拉一轮 → `_malformed_tool_calls` 校验 tool_call arguments 能否 `json.loads` → 不能则**丢弃整轮(不 append/不记账)重 roll**」,最多 3 次;最后一次降级 `_nonstream_once`(provider 服务端拼 tool_calls,绕开流式错位,content 整段补 emit)。断投毒环 + 不依赖猜准上游成因 + 不动正常路径。**backstop**:`executor_host.py` / `sandbox/tool_runner.py` 缺必填参数(空 `{}`)早返 `缺少必填参数 [...];请带齐 [...] 重新调用`,替掉暴露内部签名的 `missing N required positional arguments`。重试消耗 token 不单独记账(罕见路径)。tests 全过(唯一失败 `test_static_vendor::formatContextStats` 是前端 ES module 化遗留,与本改无关)。 ### 2026-06-05 diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 8319e3f..3c2b818 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -3,8 +3,17 @@ import unittest ROOT = Path(__file__).resolve().parents[1] -DEV_HTML = ROOT / "web" / "static" / "dev.html" -VENDOR_DIR = ROOT / "web" / "static" / "vendor" / "markdown" +STATIC_DIR = ROOT / "web" / "static" +DEV_HTML = STATIC_DIR / "dev.html" +JS_DIR = STATIC_DIR / "js" +VENDOR_DIR = STATIC_DIR / "vendor" / "markdown" + + +def _frontend_source() -> str: + """dev.html + 拆出的 ES module 源(路径 1 模块化后逻辑散落 js/*.js)合并文本。""" + parts = [DEV_HTML.read_text(encoding="utf-8")] + parts += [p.read_text(encoding="utf-8") for p in sorted(JS_DIR.glob("*.js"))] + return "\n".join(parts) class StaticVendorTests(unittest.TestCase): @@ -27,12 +36,12 @@ class StaticVendorTests(unittest.TestCase): self.assertTrue(path.exists(), f"missing vendored asset: {path}") self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}") - def test_dev_html_surfaces_context_and_cache_stats(self) -> None: - html = DEV_HTML.read_text(encoding="utf-8") + def test_frontend_surfaces_context_and_cache_stats(self) -> None: + src = _frontend_source() - self.assertIn("formatContextStats", html) - self.assertIn("context_original_chars", html) - self.assertIn("cache_hit_tokens", html) + self.assertIn("formatContextStats", src) + self.assertIn("context_original_chars", src) + self.assertIn("cache_hit_tokens", src) if __name__ == "__main__": diff --git a/web/static/js/layout.js b/web/static/js/layout.js new file mode 100644 index 0000000..32a9161 --- /dev/null +++ b/web/static/js/layout.js @@ -0,0 +1,120 @@ +// 三栏布局:pane 折叠(rail 模式 + localStorage)+ 拖拽 splitter + 手机单列视图。 +// 顶层即绑定 toggle/splitter/mobile-tab 事件并应用初始状态(import 时执行,DOM 已就绪)。 +// 只 setMobileView / mqPhone 对外(selectTask 在手机端选中任务时切到对话面板)。 +import { $ } from "./dom.js"; +import { LS_LEFT_COLLAPSED, LS_RIGHT_COLLAPSED, LS_LEFT_WIDTH, LS_RIGHT_WIDTH } from "./state.js"; + +// ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ───── +const PANE_W = { left: { min: 220, max: 560, def: 320 }, right: { min: 220, max: 560, def: 320 } }; +function clampPaneWidth(side, value) { + const cfg = PANE_W[side]; + const n = Number(value); + if (!Number.isFinite(n)) return cfg.def; + return Math.max(cfg.min, Math.min(cfg.max, Math.round(n))); +} +function applyPaneWidth(side, value) { + const width = clampPaneWidth(side, value); + $("app").style.setProperty(side === "left" ? "--left-pane-width" : "--right-pane-width", width + "px"); + localStorage.setItem(side === "left" ? LS_LEFT_WIDTH : LS_RIGHT_WIDTH, String(width)); +} +function restorePaneWidths() { + applyPaneWidth("left", localStorage.getItem(LS_LEFT_WIDTH) || PANE_W.left.def); + applyPaneWidth("right", localStorage.getItem(LS_RIGHT_WIDTH) || PANE_W.right.def); +} + +// 折叠 = pane 收成 40px rail,只留 toggle 一直可点;按钮符号根据状态翻向 +function applyLeftCollapsed(collapsed) { + document.body.classList.toggle("left-collapsed", collapsed); + const btn = $("pane-toggle-left"); + btn.textContent = collapsed ? "›" : "‹"; + btn.title = collapsed ? "展开任务列表" : "折叠任务列表"; +} +function applyRightCollapsed(collapsed) { + document.body.classList.toggle("right-collapsed", collapsed); + const btn = $("pane-toggle-right"); + btn.textContent = collapsed ? "‹" : "›"; + btn.title = collapsed ? "展开文件列表" : "折叠文件列表"; +} +$("pane-toggle-left").onclick = () => { + const next = !document.body.classList.contains("left-collapsed"); + localStorage.setItem(LS_LEFT_COLLAPSED, next ? "1" : ""); + applyLeftCollapsed(next); +}; +$("pane-toggle-right").onclick = () => { + const next = !document.body.classList.contains("right-collapsed"); + localStorage.setItem(LS_RIGHT_COLLAPSED, next ? "1" : ""); + applyRightCollapsed(next); +}; +restorePaneWidths(); +applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); +applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); + +function attachPaneSplitter(id, side) { + const el = $(id); + let dragging = false; + el.addEventListener("pointerdown", (ev) => { + if (mqPhone.matches) return; + ev.preventDefault(); + dragging = true; + el.classList.add("active"); + document.body.classList.add("resizing-panes"); + el.setPointerCapture(ev.pointerId); + if (side === "left" && document.body.classList.contains("left-collapsed")) { + localStorage.setItem(LS_LEFT_COLLAPSED, ""); + applyLeftCollapsed(false); + } + if (side === "right" && document.body.classList.contains("right-collapsed")) { + localStorage.setItem(LS_RIGHT_COLLAPSED, ""); + applyRightCollapsed(false); + } + }); + el.addEventListener("pointermove", (ev) => { + if (!dragging) return; + const rect = $("app").getBoundingClientRect(); + const width = side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX; + applyPaneWidth(side, width); + }); + const stop = (ev) => { + if (!dragging) return; + dragging = false; + el.classList.remove("active"); + document.body.classList.remove("resizing-panes"); + try { el.releasePointerCapture(ev.pointerId); } catch (_) {} + }; + el.addEventListener("pointerup", stop); + el.addEventListener("pointercancel", stop); +} +attachPaneSplitter("split-left", "left"); +attachPaneSplitter("split-right", "right"); + +// ───── 手机视图切换(单列 + tab) ───── +// body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class +// 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) +export const mqPhone = window.matchMedia("(max-width: 640px)"); +export function setMobileView(view) { + // view ∈ "mv-left" | "mv-mid" | "mv-right" + document.body.classList.remove("mv-left", "mv-mid", "mv-right"); + document.body.classList.add(view); + for (const b of document.querySelectorAll(".mobile-tabs button")) { + b.classList.toggle("active", b.dataset.mv === view); + } +} +function applyMobileMode() { + if (mqPhone.matches) { + // 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过) + document.body.classList.remove("left-collapsed"); + document.body.classList.remove("right-collapsed"); + if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) { + setMobileView("mv-left"); + } + } else { + // 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class) + applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); + applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); + } +} +mqPhone.addEventListener("change", applyMobileMode); +applyMobileMode(); +for (const b of document.querySelectorAll(".mobile-tabs button")) { + b.onclick = () => setMobileView(b.dataset.mv); +} diff --git a/web/static/js/main.js b/web/static/js/main.js index 809d645..f7de78d 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -1,10 +1,9 @@ // zcbot dev 控制台主逻辑(login / 任务 / 流式 / 文件 / 预览 / embed / boot)。 -// 路径 1 模块化:叶子(state/format/dom/api/markdown)已抽出为独立模块; +// 路径 1 模块化:叶子(state/format/dom/api/markdown)+ layout 已抽成独立模块; // 本文件是剩余主体,后续步骤会继续从这里把各功能段逐个剥成独立模块。 import { state, LS_TOKEN, LS_UID, LS_NAME, - LS_LEFT_COLLAPSED, LS_RIGHT_COLLAPSED, LS_LEFT_WIDTH, LS_RIGHT_WIDTH, EMBED, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, } from "./state.js"; import { @@ -14,6 +13,7 @@ import { import { $, showMenu } from "./dom.js"; import { api } from "./api.js"; import { renderMd, highlightIn } from "./markdown.js"; +import { mqPhone, setMobileView } from "./layout.js"; // embed 首个 task 自动定位的一次性标志(仅 embed 段使用) let _embedInitialTaskHandled = false; @@ -226,120 +226,6 @@ async function doChangePassword() { } $("cp-go").onclick = doChangePassword; -// ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ───── -const PANE_W = { left: { min: 220, max: 560, def: 320 }, right: { min: 220, max: 560, def: 320 } }; -function clampPaneWidth(side, value) { - const cfg = PANE_W[side]; - const n = Number(value); - if (!Number.isFinite(n)) return cfg.def; - return Math.max(cfg.min, Math.min(cfg.max, Math.round(n))); -} -function applyPaneWidth(side, value) { - const width = clampPaneWidth(side, value); - $("app").style.setProperty(side === "left" ? "--left-pane-width" : "--right-pane-width", width + "px"); - localStorage.setItem(side === "left" ? LS_LEFT_WIDTH : LS_RIGHT_WIDTH, String(width)); -} -function restorePaneWidths() { - applyPaneWidth("left", localStorage.getItem(LS_LEFT_WIDTH) || PANE_W.left.def); - applyPaneWidth("right", localStorage.getItem(LS_RIGHT_WIDTH) || PANE_W.right.def); -} - -// 折叠 = pane 收成 40px rail,只留 toggle 一直可点;按钮符号根据状态翻向 -function applyLeftCollapsed(collapsed) { - document.body.classList.toggle("left-collapsed", collapsed); - const btn = $("pane-toggle-left"); - btn.textContent = collapsed ? "›" : "‹"; - btn.title = collapsed ? "展开任务列表" : "折叠任务列表"; -} -function applyRightCollapsed(collapsed) { - document.body.classList.toggle("right-collapsed", collapsed); - const btn = $("pane-toggle-right"); - btn.textContent = collapsed ? "‹" : "›"; - btn.title = collapsed ? "展开文件列表" : "折叠文件列表"; -} -$("pane-toggle-left").onclick = () => { - const next = !document.body.classList.contains("left-collapsed"); - localStorage.setItem(LS_LEFT_COLLAPSED, next ? "1" : ""); - applyLeftCollapsed(next); -}; -$("pane-toggle-right").onclick = () => { - const next = !document.body.classList.contains("right-collapsed"); - localStorage.setItem(LS_RIGHT_COLLAPSED, next ? "1" : ""); - applyRightCollapsed(next); -}; -restorePaneWidths(); -applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); -applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); - -function attachPaneSplitter(id, side) { - const el = $(id); - let dragging = false; - el.addEventListener("pointerdown", (ev) => { - if (mqPhone.matches) return; - ev.preventDefault(); - dragging = true; - el.classList.add("active"); - document.body.classList.add("resizing-panes"); - el.setPointerCapture(ev.pointerId); - if (side === "left" && document.body.classList.contains("left-collapsed")) { - localStorage.setItem(LS_LEFT_COLLAPSED, ""); - applyLeftCollapsed(false); - } - if (side === "right" && document.body.classList.contains("right-collapsed")) { - localStorage.setItem(LS_RIGHT_COLLAPSED, ""); - applyRightCollapsed(false); - } - }); - el.addEventListener("pointermove", (ev) => { - if (!dragging) return; - const rect = $("app").getBoundingClientRect(); - const width = side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX; - applyPaneWidth(side, width); - }); - const stop = (ev) => { - if (!dragging) return; - dragging = false; - el.classList.remove("active"); - document.body.classList.remove("resizing-panes"); - try { el.releasePointerCapture(ev.pointerId); } catch (_) {} - }; - el.addEventListener("pointerup", stop); - el.addEventListener("pointercancel", stop); -} -attachPaneSplitter("split-left", "left"); -attachPaneSplitter("split-right", "right"); - -// ───── 手机视图切换(单列 + tab) ───── -// body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class -// 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) -const mqPhone = window.matchMedia("(max-width: 640px)"); -function setMobileView(view) { - // view ∈ "mv-left" | "mv-mid" | "mv-right" - document.body.classList.remove("mv-left", "mv-mid", "mv-right"); - document.body.classList.add(view); - for (const b of document.querySelectorAll(".mobile-tabs button")) { - b.classList.toggle("active", b.dataset.mv === view); - } -} -function applyMobileMode() { - if (mqPhone.matches) { - // 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过) - document.body.classList.remove("left-collapsed"); - document.body.classList.remove("right-collapsed"); - if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) { - setMobileView("mv-left"); - } - } else { - // 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class) - applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); - applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); - } -} -mqPhone.addEventListener("change", applyMobileMode); -applyMobileMode(); -for (const b of document.querySelectorAll(".mobile-tabs button")) { - b.onclick = () => setMobileView(b.dataset.mv); -} // ───── enter app ───── function enterApp() {