refactor(dev): 前端模块化 Step 2(起)— 抽出 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 测试全过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a7e0cd233
commit
cbb16b896f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue