diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md index 81a7ce7..3bf43db 100644 --- a/plugins/avatasia/mmc-trajectory/README.md +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -6,6 +6,8 @@ Browse the model trajectory of a MiniMax Code conversation: messages, reasoning, Author: [avatasia](https://github.com/avatasia) · Version: `1.0.0` +Before changing the page, read [docs/LAYERS.md](docs/LAYERS.md): which functional layer a control belongs to, what it may read, and the four questions to answer before implementing a new one. It exists because this page's filter logic grew one feature at a time, and the defects that produced are all traceable to that. + ## Install and use Copy this entire directory into your active MiniMax Code data directory, including the hidden `.minimax-plugin` directory. By default, `` is the `.minimax` directory in your home folder (`~/.minimax`), so plugins go in `~/.minimax/plugins/`. If you have configured a different data directory, use that directory instead: diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md index bb26af5..927d87d 100644 --- a/plugins/avatasia/mmc-trajectory/README.zh-CN.md +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -6,6 +6,8 @@ 作者:[avatasia](https://github.com/avatasia) · 版本:`1.0.0` +改动这个页面前,先读 [docs/LAYERS.md](docs/LAYERS.md):一个控件属于哪一层、能读什么、以及实现新功能之前必须先答的四个问题。它存在的原因是这个页面的筛选逻辑是一次加一个功能长出来的,产出的缺陷都能追溯到这一点。 + ## 安装与使用 把整个目录(含隐藏的 `.minimax-plugin`)复制到你的 MiniMax Code 活跃数据目录下。默认情况下 `` 是家目录下的 `.minimax`(即 `~/.minimax`),所以插件放在 `~/.minimax/plugins/`。如果你配置过其它数据目录,请用那个目录: diff --git a/plugins/avatasia/mmc-trajectory/docs/LAYERS.md b/plugins/avatasia/mmc-trajectory/docs/LAYERS.md new file mode 100644 index 0000000..17531a2 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/docs/LAYERS.md @@ -0,0 +1,486 @@ +# 前端分层:这个页面的功能类型 → 层 → 归属 + +这份文档对应的是一类**已经付出过代价**的错误,不是风格偏好。下面每一条「代价」都能在本仓库里指到具体代码或具体缺陷。 + +## 0. 要解决什么 + +症状是「功能不断堆叠,联动时出现异常」。拆开看是两件事: + +1. **堆叠**:新控件被放到「离它作用对象最近的地方」,而不是「离它所属的层最近的地方」。于是同一层的控件散落在页面的两处。 +2. **联动异常**:没有分层的共享模型,每加一个跨控件的能力,就只能在各个渲染函数里手写互斥分支。分支之间必然互相看不见对方在做什么。 + +第 2 点是重点。上一轮修的三处异常轴缺陷——合计指向另一个选择、`重置` 在多选下自称已是默认、单选说「已叠加 4 条规则」——**没有一处是算错**,三处都是「两个筛选器各自推理自己」的症状。它们的共同根因只有一个:筛选这件事在这个页面里没有一层。 + +--- + +## 1. 现状:实际的结构(不是设计意图) + +按文档顺序,逐层对照。**这一节描述现状,不是方案**;方案在第 3 节。 + +| 文档位置 | DOM | 控件 | 写的 state | 它回答的问题 | +| --- | --- | --- | --- | --- | +| 1 | `header.page-header` | 跟随最新、刷新 | `followLatest` | 跟不跟 | +| 2 | `#error-bar` `#stale-bar` | — | `hardError` `stale` | 出错了吗 | +| 3 | `form.toolbar#toolbar` row1 | 会话、上下文代、搜索 | `sessionId` `generation` `query` | **看哪段数据** + **看哪些记录** | +| 3 | `form.toolbar#toolbar` row2 | 时间区间 + 4 个快捷 + 更新到最新 + 清空区间 | `rangeFrom` `rangeTo` `rangeCutoff` | **看哪些记录** | +| 4 | `section.kpi-section` | 5 张 KPI 卡 | —(只读) | 整场会话的统计 | +| 5 | `div.filter-sticky` | 类型轴 | `kinds` | **看哪些记录** | +| 5 | `div.filter-sticky` | 异常轴 + 多选 + 提示行 | `anomalyRules` `multiAnomaly` `anomalyLast` | **看哪些记录** | +| 6 | `section.overview` | 总览轴切换、拖拽刷选、清除区间 | `actualDuration` `overviewSelection` `overviewViewport` | 同一批记录的图形投影 | +| 7 | `main.columns` → `panel` 轨迹 | 窗口控制(最新 200 / 全部 / 向上加载) | `limit` `allRecords` | 渲染多少 | +| 7 | `main.columns` → `panel` 检视 | 四个 tab | `activeTab` `selectedId` | 这一条记录长什么样 | + +**「看哪些记录」这一件事,现在住在文档位置的 3、5 两处**,中间隔着整块 KPI 区。 + +--- + +## 2. 已经付过的代价 + +### D1 — ~~同一层的控件被劈成两半~~ **设计如此,不是缺陷** + +**这一条撤回。** 它曾经被写成需要偿还的结构债,理由是「看哪些记录」这一个职责住在文档位置 3(`form.toolbar`)和位置 5(`.filter-sticky`)两处、中间隔着 KPI 区。 + +**这个判断是错的。** 查询条件是一个**聚合**,不是一个控件:前端 UI 与最终请求参数本来就该拆开,两块甚至更多块 UI 各拼一部分参数是正常形态(是否 MVVM 与此无关)。要求它们在文档里连续,是把「层要连续」当成了设计洁癖,而它不是——KPI 区答的是「这场会话一共发生了什么」,筛选区答的是「我现在看其中哪些」,两者本来就不必挨着。 + +当时提的搬动方案(把搜索框和时间区间移进 sticky 筛选带)也被否决:**窄屏上 sticky 会占满视口,可用区域反而更小。** 搜索框尤其如此。 + +所以这一条留在这里,是为了让下一个读到这里的人知道**这条路已经走过、结论是不动**,而不是把它当成待办。 + +真正该盯的不是控件在文档里的位置,而是**位置变了之后由谁负责重算**——见 D9。 + +### D2 — 同一份 state 里,有两句互相矛盾的设计声明 + +```js +// kinds +// The type chips are the only filter, so this array is the only definition of what is on +// screen. + +// rangeFrom +// This is the one filter the KPI follows. The type chips and the search box narrow +// what is *drawn*; a range answers a different question … +``` + +两句话都对,各自描述的是一个**更早的**页面。合起来,这个对象对「筛选是什么」给出了两种互不兼容的模型。这不是笔误,是堆叠留下的沉积:**没有一层来裁决哪句话作废。** + +`README.md` 其实已经把这件事说对了一半——它写的是「the only filter **over kinds**」,并明确说明时间区间与异常筛选是横跨类型、且不改写 `kinds`。带限定词的那句现在仍然成立;代码注释里**没有限定词**的那句不成立。分层不是要推翻 README 的模型,是给它一个归属:**层不新增字段,只是命名谁拥有它们。** + +### D3 — 汇总层用了筛选层的视觉语言 + +`kpiStats()` 只跟时间区间(`hasTimeRange() ? scopedStats() : payload.stats`),不跟类型轴、不跟搜索、不跟异常轴——即 KPI 讲的是「这一段时间里的整场会话」,而它的卡片和筛选控件共用同一套视觉语言、同一个滚动流里的相邻位置。读者无法从视觉上知道这两者口径不同。 + +### D4 — 跨层回写没有声明 + +全页唯一一条「从下往上写」的数据流是总览条的区间刷选 → `rangeFrom/rangeTo`,以及它自带的「清除区间」「更新到最新」。它写的是 L2 的字段,但持有者是 L3 的控件,而且 `overviewSelection` 另存了一份区间。没有声明,于是每次动它都要重新推演谁该刷新。 + +### D5 — 一个字段多个写者 + +`state.kinds` 4 处写入、`state.anomalyRules` 4 处、`multiAnomaly` 3 处、`query` 3 处、`limit` 3 处。互斥不变量只能靠一个协调函数 `resetBaseFilters()` 去维持,而协调函数本身就是「没有共享模型」的产物。 + +### D6 — 控件的位置和它写入的层不是一回事 + +窗口控制(`显示最新 200 / 显示全部 / 向上加载`)在**列表面板里**,但它写的是「渲染多少」这一层的事,不改筛选结果。同类还有总览条:它既是投影,又持有写入筛选的控件。位置即归属的直觉在这里已经不成立,而文档里没有一处声明它。 + +### D7 — 函数名跨层 + +`applyFilterScope()` 的名字说的是 L2(筛选作用域),函数体第一行却是 `if (!dom.kpiScope) return;` —— 它写的是 L4 的口径行。真正的作用域处理(异常模式下把 `[data-filter-scope]` 标 inert)在 `syncAnomalyMode()` 里。 + +一个以 L2 命名、只写 L4 的函数,会让下一次读代码的人把口径声明当成筛选行为的一部分。这不是笔误级的问题:它是 D4 的同一个根因——**跨层关系没有被声明,于是命名先替它做了个假声明。** + +### D8 — L6 读的是「整场会话」,不是「表里有什么」 **已修** + +L6 检视面板该读 L5(列表),实际读的是 L0:`selectedRecord()` 和 `revealSelection()` 扫的是 `state.flat`——未经筛选的整场会话;而 L5 的行是拿 `visibleEntries()` 建的。两边对「选中」的定义不是同一个。 + +读者看到的是:选中一条记录 → 点「工具失败」→ 账本从 61 条变成「已显示 2 条」,检视面板纹丝不动仍显示原来那条,而且**表里没有任何一行带选中标记**。页面内部没有任何一处自相矛盾——只是面板在描述一个屏幕上看不到的东西。 + +更糟的是能点的按钮也一起撒谎:`复制 JSON`、`交给 AI 分析这条` 读的是同一个旧记录。 + +唯一能回收 `state.selectedId` 的地方是 `applyPayload()`,而且只在**数据签名变化**时。本地改筛选一次都不走那里,所以没有任何一条筛选轴能救它。 + +**修法**:判定权收进 `revealSelection(entries)` 一个函数,参数就是 L5 即将拿去建行的那个数组;调用点收进 `renderLedger()`——所有会改变「表里有什么」的路径的**唯一出口**(9 处直接调它,其中就包括类型轴本身)。`selectedRecord()` 降级成纯查找,它的 5 个读者(面板 / 复制 / 交接 / 交接按钮 / …)不再各自重新回答「什么算选中」。守卫:`tests/verify-selection.mjs` + `tests/mutate-selection.py`。 + +### D9 — 钉住偏移的重算责任只挂在一根线上 **已修** + +四个 sticky 层的偏移由 3 个 CSS 变量串起来,唯一写手 `syncStickyOffsets()` 的唯一调用点是 `render()` 末尾。而 `renderLedger()` 被 9 处**直接**调用绕过 `render()`(类型轴、搜索、总览刷选、加载更早、窗口模式、选中行…)。今天这 9 处恰好都不改 band 高度所以看不出来——**不变量靠约定撑着,不靠结构**:再加一个条件时照这个写法走,就会静默错位。 + +同时没有任何 resize 挂钩(无 `resize` 监听、无 `ResizeObserver`)。而稳定会话下每次轮询都是 304,`render()` 根本不跑(304 分支只在 `state.loading` 时才 render)——窗口一变窄、类型行换行、band 长高,**永远没人重测**。 + +**修法**:测量收进 `renderLedger()` 出口(与 D8 同一个出口),`render()` 里不再重复调;补 `resize` 挂钩,按 `requestAnimationFrame` 合并成一帧一次(每次测量读 3 个 `offsetHeight`,会强制布局,拖窗口时逐事件测量就是布局抖动)。守卫:`tests/verify-sticky.mjs` 新增 5 条断言 + `tests/mutate-sticky.py` 新增 7 条变异。 + +> 顺带说明 D1 与 D9 的关系:D1 说的「层被劈开」是**位置**问题,结论是不动;D9 说的是**位置变了之后由谁重算**,这才是真问题。两者不要混。 + +### D10 — 每轮轮询把整个 lineage 重读重解析一遍 **已修** + +这一条不在前端,在 L0。`handleTrajectory()` 命中 `trajectoryCache` 时直接短路返回(好);**未命中时**那个循环(`server.mjs:1881-1919`)对 lineage 上**每一个** generation 文件做 `readHead()` 全量读 + `parseMessagesJsonl()` 全量解析,然后重建全部 row。 + +而 `trajectoryCacheKey()` 把每一代的 `byteLength` 拼进了键,所以**只要会话还在追加,键就变,就必然 miss**——缓存是全有或全无的。 + +本机实测(会话 `mvs_2fd90165…`,23 代): + +| | | +| --- | --- | +| 每轮读入的源字节 | `payload.session.sizeBytes` = **99,986,501** | +| 其中冻结的 snapshot(22 个) | 99,889 KB = **99.90%** | +| 只有 `messages.jsonl` 在长 | 97,526 字节 = **0.10%** | +| 重建耗时 | **1,138 ms**(拟合 31 ms/MB) | +| 占 2,500 ms 轮询间隔 | **46%** | + +同一份数据在三个层各自独立地表现出同一个比例:**服务端输入 99.90% 冗余、线上字节 99.99% 冗余(5 分钟 1.31 GB 换 118,875 字节真实新增,放大 11,006×)、页面解析 ~99.99% 冗余**。会话在忙碌时每 3–6 秒变一次,而轮询每 2.5 秒问一次——所以**几乎每次轮询都撞上全量重发**。 + +**曾被考虑并否决的两条**: + +- **响应开 gzip**(36.0 MB → 7.8 MB,4.6×)。实测**净亏损**:同机 loopback、同样 `await response.json()`,裸传 201 ms vs gzip 268 ms,页面反而慢 67 ms;服务端每个 cacheKey 还要多付 458 ms deflate,浏览器付 80 ms inflate。**本机不是带宽瓶颈,压缩换不来东西。** +- **缓存每代的序列化片段**(用字符串而非对象图,内存友好)。**不成立**:`buildTrajectoryPayload()` 里 `turn.index = turns.length + 1` 是**全局序号**,且同一个 `turnId` 会跨代出现(`turnById` 复用该 turn 并继续追加记录)。所以每一代既不能独立缓存也不能独立拼接。 + +**修法**(四问): + +1. **归层** — L0,服务端。第二个缓存,与 `trajectoryCache` **并列而非替代**:整场缓存在命中时仍直接短路,新缓存只在整场 miss 后才被读写。**互斥由控制流保证**,不靠约定。 +2. **关联** — 新缓存按**单文件**粒度,键与整场缓存不同,两者互不写入对方的键。不新增任何前端 state,不改任何页面行为。 +3. **布局** — `start()` 闭包内、`trajectoryCache` 旁边。**不做模块级 Map**(跨 `start()` 泄漏)。自带按**字节预算**的 LRU,默认 256 MB。 +4. **口径** — **不改变任何对外承诺的数字**。唯一要守的不变量:**缓存命中必须产出与冷读逐字节相同的 body**。守卫 `tests/verify-parsed-rows.mjs` 断言这一点。 + +**缓存键的三个正确性前提**(任一条错了,缓存就会供出陈旧数据——这是它唯一不能出的错): + +- **键用 `filePath` 而不是 `fileName`。** `messages.jsonl` **不是唯一的**,每个会话都有一个;只用文件名,两个会话的同名文件可能撞车。 +- **键用 `stat` 的 `size` + `mtimeMs`,不用 catalog 里的 `byteLength`。** 代码自己在 `server.mjs:1889-1894` 注释了那个值是快照、会过期,而且 active 文件每轮都在它之外继续增长。 +- **键含 `limit`。** `readHead` 会按 `limit` 截断,截断与否是读出来的事实的一部分,缓存值里带着 `truncated` 判定。 + +**共享带来的写入风险**:`rows` 跨请求共享同一批对象。已逐个查过下游——`redactStructure()` 构造新对象(`out = {}`)而不原地改,`record = { ...base }` 也是新建,`splitRawRecords` 改的是新建的 record。唯一写缓存对象的是 `row[ROW_GENERATION] = link.generation`,而同一 `filePath` 恒定映射同一 generation,幂等。 + +**内存代价(实测,不是估计)**:`JSON.parse` 保留率实测 **1.36×**(34.3 MB 文本 → 46.7 MB 堆),且这是**下限**——payload 已被 `splitRawRecords` 剥掉消息正文,真实的 rows 图更大。外推到 95.4 MB 源文件是 **130–290 MB 常驻**。所以上界必须显式:换掉的是每 2.5 s 约 350 MB 的分配 churn,换来一次性常驻。**预算按字节而非条数**,因为条数与体积无关(一条可能是 40 字节也可能是 40 MB)。 + +> 真正把这件事做干净的是增量协议(`?since=`,服务端只发新增尾部)。它连这 130–290 MB 都不需要,而且额外消掉那 139 ms 的 `JSON.stringify` 和页面的 200 ms 传输+解析。**按代缓存胜在不动协议**,性能上它被增量协议严格支配。这一条留作后续。 + +### D11 — 为「量一下大小」把整场消息体序列化一遍,然后扔掉 **已修** + +D10 修完之后实测发现重建时间没动,于是回头查 D10 的成本拆分,发现 D10 里写错了一个数。实测(真实 22 个冻结文件): + +``` +冷读(读 + 解析全部) 306 ms ← 不是我在 D10 里写的 ~1,000 ms +命中缓存 2 ms +解析吞吐 311 MB/s +``` + +**更正 D10**:读+解析约 306 ms,不是约 1,000 ms。当时那个数是用「1,138 − 139(stringify)」减出来的,把没有查过的 `buildTrajectoryPayload` 和 `buildRawPayload` 一起算进了「读+解析」里。按代缓存的真实收益是**约 300 ms,占一轮重建的约 21%**,不是 3.5×。常驻堆实测 **116 MB**(95.3 MB 源文件 × 1.22×),低于当时 130–290 MB 的外推。 + +> 顺带修了 D10 自己发的代码里的一个缺陷:预算代理写成 3×,于是 95.3 MB × 3 = 286 MB 计费压过 256 MB 预算,LRU 把放得下的条目也赶走了。实测保留率 1.22×,代理改为 2×。**预算的好坏完全取决于这个数**。 + +剩下的 ~1,100 ms 在下游,其中最大的一块是个纯浪费: + +`buildRawPayload(message, redact)` 对**每一条记录**执行 `JSON.stringify(message)`——18,337 次、合计约 100 MB——只为回答两个问题:序列化后是否超过 `MAX_RAW_BYTES`(65536),以及是否含 `.minimax` 需要脱敏。**那个字符串用完就扔**:`splitRawRecords()` 在最终序列化之前把 `raw` 从每条记录上摘掉(`server.mjs:1954` 的注释自己写着「from here on the body carries no message bodies at all」),而 `raw.value` 真正带走的其实是**原消息对象**,不是这个字符串。 + +于是每一轮重建都要把 100 MB 消息体完整序列化一遍、量一下、再扔。而 `raw` 只有打开某条记录的原文页、复制、交接时才读得到——18,337 条里几乎从不读。 + +**修法**(四问): + +1. **归层** — L0 服务端。`raw` 的计算从「构建 payload 时」移到「`/raw` 被请求时」。不新增任何 state,不改筛选/渲染层。 +2. **关联** — `raw` 与 bulk payload 的关系不变(它本来就不在 bulk 里)。变的是**判定「过大」的时机**:从构建时移到请求时,两者必须在同一处写,不能一个函数判、另一个函数用。 +3. **布局** — bulk payload 仍然不带 `raw`;`/raw` 仍然按 record index 服务。**新增的只是一个定位符**:每条记录记下它来自 `rows` 的第几条,由 `splitRawRecords()` 摘进缓存项,`/raw` 用它回到那一条消息。`handleRaw()` 再调用 `buildRawPayload()`——一次只算一条。 +4. **口径** — **`rawOmitted` 从 bulk payload 迁到 `/raw` 响应**。这是本项目第一次做**契约级**的口径变更,所以逐个读点列在这里,并在 `tests/verify-raw-lazy.mjs` 里逐个加断言: + +| # | 位置 | 现在读 | 改后读 | +| --- | --- | --- | --- | +| 1 | `index.html:2867` 交接元信息 | `record.rawOmitted` | `rawText === null`(交接本来就先取 `rawSourceFor`,`copySelected` 同样) | +| 2 | `index.html:5989` `rawSourceFor` | `record.rawOmitted` → 短路 `null` | 无条件取 `/raw`,省略由服务端给出 | +| 3 | `index.html:5998-5999` 原文页提示 | `record.rawOmitted` | `/raw` 的 `omitted` | +| 4 | `index.html:6007-6008` 原文页内容 | `record.rawOmitted` → `—` | 同上 | +| 5 | `index.html:6059` `copySelected` | `source === null` → `stringify(record)` | 不变(`rawSourceFor` 仍可能返回 `null`) | + +**5 个读点里没有一个在台账行上**——全部在「打开这条记录」的路径上。所以这条口径不需要在列表里出现,只需要在读者真的去读的时候给出同一句话。 + +**读者可见的变化只有一处**:原文页对「过大」的记录会多发一次 `/raw` 请求(之前是零次)。这是一条用户主动打开才走的路径,用一次请求换每轮轮询少序列化 100 MB,是划算的。 + +**正文里不再有 `rawOmitted` 字段**,18,337 条记录每条少一个键。 + +### D12 — 「显示全部」把两万行塞进一次同步循环,页面当场冻住 **已修** + +**归层** L5 的建行路径。写的是 L5 的 `state` 吗?不是——它一个字段都不写,只建 DOM。真正要守的是 **D8 的出口不变**:`renderLedger()` 仍然是唯一出口。 + +**关联** 与 `updateWindowBar()` 从属:进度必须**经过**它输出,不能自己碰那个 count 节点,否则 D8 的「一个写手」当场破掉(见 R1)。 + +**布局** 不新增容器。建行循环拆批,仍按原来的 `section.turn-group` 逐轮追加,`.row-list` 结构一字未改——所以 `client-check.mjs` 里从 `renderLedger()` 里读压缩带路由的那条断言不用改。进度显示在已有的窗口条上,不另起一个面板。 + +**口径** **不变**。「显示全部」仍然是全部。这条下面专门有一节说明为什么不把它变小。 + +**实测(本机,会话 150 轮 / 18,924 条记录 / 视口 735×900)**: + +``` +分批建行(25 轮/帧)总耗时 3,956 ms + 每批建行 JS 中位 141 ms / 最大 151 ms + 帧间(浏览器对整篇文档的 layout+ paint) ≈ 617 ms/帧 +``` + +关键结论是:**卡的不是建行 JS,是每帧对整篇两万行文档做的 layout**。六批 JS 加起来约 870 ms,剩下的 3,086 ms 是 5 个帧间隙。所以单纯把批调小**治不了**——批越小,帧次数越多,对不断变长的文档重复 layout 的总代价越高。 + +**实测并否决的替代方案:`content-visibility: auto`**。它直接掐掉那 3,086 ms: + +``` +同一套分批 + .turn-group { content-visibility: auto; contain-intrinsic-size: auto 900px } + 总耗时 979 ms (3,956 → 979,4.0×) + 每批最大 151 ms → 161 ms 的 layout 也一并消失 +``` + +行数一致(18,845 vs 18,864,差值是两次测量之间会话自己长的 19 条),所以 4 倍是真的。**CSS 常量版没有采用**,理由是一个数对不上一张表: + +``` +150 个轮组的真实高度 p25 1,011 px 中位 2,667 px p75 6,627 px 最大 29,754 px +``` + +跨度 **11 倍**。`contain-intrinsic-size` 只能填一个数:填 900(我最初拍的值)会让初始文档高度只有真实值的 **20%**,滚动条比实际短 5 倍,滚下去页面还会一边滚一边长;填任何一个单一值都是拿「滚动条说谎」换速度——正是这份文件反复禁止的那类交易(「凡是宽度由数据决定的列,宽度必须从实测推导」)。`auto` 关键字只让浏览器**记住**已渲染过的尺寸,救不了从未渲染过的那 148 个。 + +**没有采用,也写在这里**,因为下一次有人量到这 4 倍时会再问一遍。 + +> **2026-10-12 实测更正:上面这条否决只对「CSS 常量」成立。** +> 11 倍是**轮组之间**的跨度;组内高度完全由 (行数, 压缩带数) 决定。实测(152 组 / 19,315 条 / 视口 735×900 / dpr 2): +> +> | 量 | n | min | 中位 | max | +> | --- | --- | --- | --- | --- | +> | 普通行 `.row-list > li` | 19,292 | 36 | 36 | **36** | +> | 轮头 `.turn-head` | 152 | 39 | 39 | **39** | +> | 压缩带 `li.compaction-cell` | 23 | 38 | 38 | **38** | +> | 轮组自身 chrome | 152 | 0 | 0 | 12 | +> +> 普通行 **min = max = 36 px**,19,292 条一条不差:摘要列是 nowrap + ellipsis,**它根本不换行**,所以「行高随内容变化」这个担心从一开始就不成立。 +> +> ``` +> 预测总高 694,512(行) + 883(带) + 5,928(头) = 701,323 +> 真实总高 701,660 +> 误差 0.048% +> ``` +> +> 填一个 CSS 常量 → 误差是那 11 倍;**逐组按 (行数, 压缩带数) 在建行那一刻算** → 误差 0.05%。两者不是同一件事。 +> +> **逐组推导版已实现并实测,但未采用——有一个没能定位的滚动回归。** 做法:`.turn-group` 的 `content-visibility: auto` 由账本上的一个 class 打开,class 只在第一批建完、并且从**真实 DOM** 量出行高 / 带高 / 头高之后才加上(写死的常量就是「猜别人屏幕」,所以不写死);此后每个轮组在建行那一刻按 (行数, 压缩带数) 算自己的 `contain-intrinsic-size`。 +> +> 实测(同机,152 组 / 19,374 行 / 视口 735×900 / dpr 2): +> +> ``` +> 总建行时间 782 ms (分批无跳过 3,956 ms → 5.1×;CSS 常量版 979 ms) +> 自标定高度 head 39.40 · row 36 · band 38.40 +> 带尺寸的轮组 152 / 152 +> 预测文档高 705,230 真实 705,529 误差 −0.04% +> 文档实际高 705,627(滚到底部后,`auto` 已记住已渲染轮组的真实尺寸) +> ``` +> +> **已验证**:滚到 705,627 px 的文档底部,行正常渲染且有真实几何;深层记录(第 27 轮 #2129)可点,检视正确填出「第 27 轮 · #2129 · 工具结果」。 +> +> **2026-10-12 归因完成:那次跳转不是回归,是既有缺陷 D14,逐组推导版的唯一阻塞已解除。** +> +> 先做的是控制实验——在**已发布代码**(无 `content-visibility`、无探针)上跑同一条路径:显示全部 → 滚到第 29 轮 → 点一行 → 等轮询。视口同样跳回第 1 轮,检视面板仍正确显示 `#2780 · 第 29 轮`。所以它与 `content-visibility` 无关。 +> +> 下面是给 `renderLedger()` 的捕获点与 `restoreRowAnchor()` 打的点(19,626 行 / 155 轮 / 文档高 716,000 px / 视口 841×900): +> +> ``` +> cap ySave=715080 yReal=616 doc=1515 ← clear(ledger) 把文档压到 1515px,scrollY 被浏览器夹到 616 +> rst after=707051 delta=707056 yIn=1128 yOut=708185 ← 从被夹住的位置出发,仍然正确还原 +> cap ySave=715080 yReal=616 doc=1515 +> rst after=-6 delta=0 yIn=708185 yOut=708185 ← 已稳定,每轮 delta 0 +> ``` +> +> `restoreRowAnchor()` **本身是对的**,即使起点被夹到 616 / 2,051,它也能算出正确的 delta 并落回原位。丢失发生在另一处:那次点击落在**轮询建行途中**,`ledgerBuildToken` 让在途 `buildStep()` 提前 return,而那个 return **跳过了 `restoreRowAnchor`**。下一轮于是从一个已经被摧毁的位置捕获锚点,`docYWas == docYNow == 5515` 证明它忠实地还原到了错误的地方。完整机制见 **D14**。 +> +> **代价量清了,不采用的理由只剩一条:这是别人的缺陷,不是这条路的代价。** 归因表明逐组推导版没有引入任何新的滚动问题——它只是把建行从 3,956 ms 缩到 782 ms,而 D14 的触发窗口宽度正好等于建行时长,所以这条路反而把窗口从 3,956 ms 收窄到 782 ms(窗口**占整个轮询周期的比例**本轮没有单独量,不在这里给数)。修 D14 之后,这条路可以重新提交。 + +**修法**:一次 `requestAnimationFrame` 建至多 `LEDGER_BATCH_GROUPS = 25` 轮组就交还主线程;`setLedgerProgress()` 把「已建 N / 共 M 条」交给 `updateWindowBar()` 输出(唯一写手);每一轮 `renderLedger()` 先 `ledgerBuildToken += 1`,在途分批下次醒来发现 token 变了就地退出、不碰 DOM;`startPolling()` 的 tick 发现 `ledgerBuilding()` 为真就记住这一拍,等构建结束由 `flushDeferredPoll()` 补跑一次——因为 3,956 ms > 2,500 ms 的轮询间隔,不这么做的话读者会看着计数一次次归零,永远看不到终点。 + +**剩下的杠杆没动,因为它要改承诺**:真正把「两万行」这个数字本身变小,只有两条路——给「显示全部」封顶,或者走增量协议(`?since=`)。前者是口径变更,后者是协议变更,两者都该先在这里定口径再动手,不该由一个性能修复顺手做掉。 + +### D13 — 检视面板能改窗口,于是「已显示 200 条」在一秒内变成 15,224 条 **已修** + +**现象**:在「显示全部」下选中一条早期记录,点「显示最新 200 条」,那条记录离开窗口,表里再没有行带选中标记,**但检视面板还在描述它**。 + +**根因**不是漏了清空,是 `revealSelection()` 在**切片之前**判定,并且会 `state.limit = Math.min(needed, entries.length)` —— 它主动把窗口撑大到能装下那条选中: + +``` +点下「显示最新 200 条」 + 此刻窗口条 已显示 200 条 + 下一秒 已显示 15,224 条 ← 差值 15,024 = 选中记录在 matched 里的位置 +``` + +读者自己点的按钮把窗口条上的数字改了,而且是在点击之后才改。 + +**两条错**: + +1. **窗口成了检视面板能写的东西。** `state.limit` 因此有两个写手(窗口控件 / 一次判定),这正是本文件里每一个双写手缺陷的形状(R1)。 +2. **判据错了。** 「读者能看见什么」是**窗口**,不是筛选结果。判在 `matched` 上,一条通过了全部筛选、却被窗口切掉的记录会继续留着选中。 + +**修法**:`revealSelection(entries)` 收成纯谓词——只写 `state.selectedId`,不再碰 `state.limit`;调用点从切片前挪到切片后,参数从 `matched` 改成 `windowed`。窗口收窄到不含选中记录时**取消选中**,不撑大窗口:读者的窗口选择优先于更早的那次点击,且与筛选轴的既有规则一致(记录被滤掉即取消选中)。 + +**口径变更,读点已逐个改**(`tests/verify-selection.mjs` 整份重写,14 断言): + +| 旧承诺 | 新承诺 | +| --- | --- | +| 「窗口会被拉到选中所在处,按表计数」(`limit === 8`) | 「选中在窗口之下时取消选中,且窗口原地不动」(`limit === 3`) | +| 判定发生在切片**之前** | 判定发生在切片**之后**,参数是 `windowed` | +| —— | 新增:`revealSelection` 源码里不允许出现 `limit` 这个词 | + +`tests/mutate-selection.py` 里有**两条变异是改写而不是删除的**:原来的「窗口不会被拉到选中」守的正是现在删掉的那行 `state.limit` 写入,它现在改为弄坏替换它的新规则;原来的「判定发生在切片之后」守的是相反的顺序,现在改为弄坏「判定用的是窗口而不是筛选结果」。 + +### D14 — 在途建行被取代时跳过 `restoreRowAnchor()`,滚动位置永久丢失 **已修** + +**归层** L5 的建行路径,但**归属的规则是 D9**:滚动位置只能由 `restoreRowAnchor()` 这一个出口决定。它写的是 L5 的什么状态?一个还没有的字段——修法需要新增一个(见下)。 + +**关联** 与 `ledgerBuildToken` **从属且同源**:D12 引入 `ledgerBuildToken` 是为了让在途分批「就地退出、不碰 DOM」,那条规则是对的,坏的是退出时顺手把 D9 的还原也一起跳过了。与 `firstVisibleRow()` / `anchorOffset()` 并列,是锚点三件套的第四个成员——它管的是**跨建行的意图**,不是单次测量。 + +**布局** 不新增 DOM、不新增面板。纯 JS:一个模块级变量 + `renderLedger()` 里一处赋值 + `restoreRowAnchor()` 里一处清空。 + +**口径** 不改变任何已有数字。「显示全部」仍显示全部;窗口条读数不变;选中语义不变。可观察变化只有一条:滚动位置不再丢失。 + +**实测(19,626 行 / 155 轮 / 文档高 716,000 px / 视口 841×900 / 已发布代码无探针)**: + +`renderLedger()` 在 `clear(ledger)` 之前捕获 `{anchor, scrollY}`,这一点是对的。问题在 `clear(ledger)` 的副作用: + +``` +clear(ledger) 之前 doc = 716,016 px scrollY = 715,080 +clear(ledger) 之后 doc = 1515 px scrollY = 616 ← 文档塌了,浏览器把 scrollY 夹到底 +建行 6 帧,约 3,956 ms,期间 scrollY 停在被夹住的位置 +buildStep() 收尾 restoreRowAnchor() 把位置算回来 +``` + +所以位置**不是丢失,是被摧毁再重建**——中间那 3,956 ms 里读者看到的是第 1 轮。`restoreRowAnchor()` 本身完全正确,从被夹住的 2,051 出发也能算出 `delta = +103,559` 落回 105,610。 + +**真正的丢失点是 `buildStep()` 的提前 return:** + +```js +function buildStep() { + if (build.token !== ledgerBuildToken) return; // ← 这一句把 restoreRowAnchor() 也跳过了 + ... + restoreRowAnchor(anchor, scrollY); +} +``` + +D12 给在途分批定的规则是「就地退出、不碰 DOM」——退出得对。但退出时**没人把滚动位置交还给下一个建行**,而下一个建行捕获锚点时读到的是已经被夹断的位置: + +``` +cap ySave=715080 yReal=616 doc=1515 ← 轮询建行开始,位置被摧毁 + (无 rst:这次建行被点击取代,restoreRowAnchor 从未执行) +cap ySave=5530 yReal=1065 doc=1965 ← 点击触发的 renderLedger,锚点已经错了 +rst after=-15 delta=0 yIn=5530 yOut=5530 + docYWas=5515 docYNow=5515 ← 忠实地还原到了一个错误的位置 +``` + +一旦落到顶,之后每一轮都拿「第 1 轮」当锚点,**自我维持**,读者再也回不去。 + +**触发面**:任何在轮询建行途中直接调用 `renderLedger()` 的读者动作。最大的一处是 `selectRecord()`——**点任意一行都会整表重建**。「显示全部」下建行 3,956 ms、轮询 2.5 s,所以点一行大概率落在建行途中。 + +**对照组(证明与 `content-visibility` 无关)**:在无 `content-visibility` 的已发布代码上,点行时**没有**建行在途,则 `rst` 为 `delta 0 / yIn = yOut = 105,494`,位置纹丝不动。同一份代码、同一处点击,唯一变量是「有没有建行在途」。 + +**修法**:把「读者想去哪」从「建行结束时算」提前成「第一次捕获时记下,此后每次建行转交」。一个模块级字段 `pendingAnchor`,一个写手 `forgetScrollAnchor()`: + +- `renderLedger()` 只在 `pendingAnchor` 为空时捕获 `{row: firstVisibleRow(), y: window.scrollY}`,之后每一趟都沿用;空的那一趟才读实时位置。 +- `restoreRowAnchor()` 第一句就是 `forgetScrollAnchor()`——它是唯一以「还原」结束一链的地方,所以也是唯一必须消费它的地方。 +- 五条提前返回(加载中、硬错误、无 payload、无会话、无命中)各调一次 `forgetScrollAnchor()`:它们把账本换成一个状态块,没有可锚的行,**丢弃而不是还原**——否则会把一个针对旧会话或旧筛选测出来的位置,套到另一个上面。 + +**顺带修掉的**:`ledgerBuildToken += 1` 从 `applyFilterScope()` 那一侧移到 `clear(ledger)` **之上**。这不是顺手,是上面那条的前提——四条提前返回都在自增之前返回,它们同样清空账本,却什么都不退休,于是在途建行会继续往骨架屏里追加轮组,随后还会拿一张已经不存在的表的锚点去移动页面。现在自增在清空之前,四条返回路径和正常路径走的是同一句。 + +**守卫**:`tests/verify-anchor-carry.mjs`(10 条断言)。捕获那三句是**跑**出来的——`firstVisibleRow()` 与 `window.scrollY` 换成返回**另一个**位置的桩,一个每次重读的版本会给出桩的答案而不是预设的答案;消费者则按条数点,`clear(ledger)` 到 `var groups =` 之间的 `return;` 必须和 `forgetScrollAnchor()` 一一对应。`tests/mutate-anchor-carry.py` 6 条变异全捕。 + +`mutate-ledger-batch.py` 里原有两条变异锚在 `ledgerBuildToken += 1;` 后面那一行上,自增移动后失效——**已重新锚定到 `clear(ledger);`,一条未删**,并新增一条「把自增放回提前返回之下」忠实复现本条缺陷。`verify-ledger-batch.mjs` 相应从「数一次自增」加强为「数自增与 `clear()` 之间的 `return;`」——原来那条断言对四条提前返回完全失明。 + +**浏览器实测(本机,19,991 条 / 156 轮 / 视口 841×900)**:显示全部 → 滚到第 133 轮 / #17188 → 点一行 → 等重建。建行途中视口短暂塌到第 51 轮(预期内,文档正在重建),**建行收尾后回到第 134 轮 / #17207**,正是点行前那一行(会话实时增长,序号整体前移)。再点一行,仍在第 134 轮 / #17215,被点的 #17226 带选中标记。修复前同一操作把读者永久留在第 1 轮。 + +--- + +## 3. 分层模型 + +| 层 | 名称 | 拥有 | 回答 | 读 | +| --- | --- | --- | --- | --- | +| **L0** | 数据 | `server.mjs` 的 payload | 会话目录里有什么 | — | +| **L1** | 上下文 | 会话、上下文代、跟随最新、刷新 | **看哪段数据** | L0 | +| **L2** | 筛选 | 类型、异常规则、搜索、时间区间 | **看哪些记录** | L1 | +| **L3** | 投影 | 总览条、轴模式(等宽/真实)、窗口分页 | 同一批记录怎么画、画多少 | L2 | +| **L4** | 汇总 | KPI 卡 | 这段时间的整场会话 | L2 的**区间一个字段** | +| **L5** | 列表 | 账本行、轮头 | 逐条记录 | L2 + L3 | +| **L6** | 检视 | 单条记录的四视图 | 这一条长什么样 | L5 选中的 id | + +流向单向:`L6 ← L5 ← L3 ← L2 ← L1`,`L0` 在最底下。**唯一允许的向上写**是 L3 → L2 的区间刷选(见 R5)。 + +### 五条不可违反的规则 + +- **R1 一个字段一个写者。** 每个 `state.*` 只允许一个 setter 函数写入。互斥关系必须在这一处表达,不能散在两个渲染函数里。 +- **R2 可见性只有一个入口。** 「这条记录在不在屏上」只有一个函数回答;L3 的 span、L5 的行、L4 的口径全部走它。现在 `visibleEntries()` 和 `spanPassesFilter()` 是两个入口,这是 D 类缺陷的结构来源。 +- **R3 L4 只读 L2 的区间字段,其余不读。** KPI 不是筛选的函数,它不因类型轴变化而变化。 +- **R4 L6 不回查。** 检视只接受 L5 交付的选中记录 id。 +- **R5 L3 → L2 只有区间一条回写,且必须走同一个 setter。** 总览条不允许自己维护第二份区间。 +- **R6 控件位置与它写入的层可以不同,但必须声明。** 本页已知的两处:`window-bar` 在 L5 面板内、写 L3;`overview` 是 L3 的投影,同时持有写 L2 的控件。 + +--- + +## 4. 加功能之前:四个问题 + +**顺序是硬的**:这四个问题的答案写下来(进 PR 描述或本文件的增量)之后才允许写实现代码。答不上第一个,说明这个功能不属于这个页面,或者需要先改模型。 + +1. **归层** —— 它改的是哪一层的状态? + 改不了层归属的,不做。 +2. **关联** —— 它和**同层**已有控件是并列、互斥还是从属? + 互斥必须表达在同一个地方(R1)。不能靠「B 改的时候顺便把 A 清掉」这种跨函数约定。 +3. **布局** —— 它的容器是哪个已有层容器?需要新的 sticky、panel 或层吗?和邻居的视觉语言一致吗? +4. **口径** —— 它是否改变任何已有层**对外承诺的数字**? + 若改变,列出所有读那个数字的地方(按钮、提示、KPI、span、轮头),逐个加断言。这一问是「联动异常」的主要来源:加了一个能力,改了一个数字,别的地方还在按旧口径说话。 + +### 反模式清单(每条都从本页的代价里长出来) + +- ❌ 把新控件放进「离它作用对象最近的地方」,而不是「离它所属的层最近的地方」。 +- ❌ 用「加一套第二系统」的方式扩展,而不是给现有系统加一个字段(异常轴本可以是 `anomaly: {mode, rules}` 一个字段)。 +- ❌ 让新控件自己解释全局状态(合计、是否已是默认、按下态),而不是读同一份快照。 +- ❌ 把只读汇总放在操作区上方,让读者以为它随操作变化。 +- ❌ 在没有共享层的前提下,用散落的互斥分支维持不变量。 +- ❌ 先实现、再补布局、再补守卫。**这条的顺序反过来才对**:归层 → 关联 → 布局 → 口径 → 不变量 → 实现 → 守卫。 + +--- + +## 5. 分阶段迁移 + +每阶段一个独立改动、独立守卫、独立浏览器实测,不合并。 + +### ~~Stage 1 — 把 L2 在文档里收成连续的(纯结构)~~ **已否决,不做** + +这一阶段的全部内容作废,理由见 D1:查询条件是聚合,UI 与请求构造本来就该拆开;强行收成一整块要付出窄屏可用面积。`docs/STAGE1-SPEC.md` 已标注不采纳。 + +### Stage 0 — 谁负责重算(D8 / D9,**已完成**) + +先修「位置与位置变了之后由谁负责重算」,再谈其它。这一步已经落地: + +- 检视面板的选中判定收进 `revealSelection(entries)` 一个函数,参数就是**将要建行的那批行**,调用点收进 `renderLedger()`(D8)。 +- sticky 偏移的测量同样收进 `renderLedger()` 出口,并补 `resize` 挂钩(D9)。 +- 建行也走同一个出口:分批、进度、一轮新的渲染作废在途分批(D12)。 +- 选中的判定不再兼管窗口(D13):`state.limit` 回到只有窗口控件一个写手。 + +三条共用同一个出口,是因为它们回答的是同一个问题:**这次重画,哪些状态要重新判定。** + +### Stage 2 — 把异常轴变成 L2 的字段(模型) + +- `anomalyRules + multiAnomaly + anomalyLast` 收成一个 `anomaly: {mode, rules, last}`。 +- `visibleEntries()` 与 `spanPassesFilter()` 合成 R2 的单一入口。 +- `resetBaseFilters()` 变成对 L2 模型的一次赋值,取代逐控件清理(D5)。 +- 删掉 `state.kinds` 上那句「the only filter」的注释(D2),因为它不再成立。 + +### Stage 3 — 声明跨层关系(R6) + +- 为「window-bar 在 L5 面板内、写 L3」和「overview 持有写 L2 的控件」各加一条断言:改位置或改归属时,断言先红。 + +--- + +## 6. 本轮明确不做 + +- **不再做 Stage 1 的结构搬动。** D1 已撤回为「设计如此」,`STAGE1-SPEC.md` 已标不采纳。剩下的 Stage 2(异常轴建模)与 Stage 3(声明跨层关系)等明确指示。 +- 没有把 L4 改成跟随全部筛选。那是一次口径变更,按第 4 节的第 4 问处理:先列全部读点,再逐个加断言,最后才改。 +- 没有为了「层要连续」去动任何控件的位置。D9 修的是**重算责任**,不是布局。 + +--- + +## 7. 未做清单(2026-10-12 记) + +第 1 项是上一轮自己留下的,**D12 只修了一半**:分批改的是等待的形状(进度可见、批次之间交还主线程、在途可被打断),等待的长度没有变。18,924 行仍然是 3,956 ms。 + +| # | 未做的是什么 | 属于 | 阻塞条件 | 下一步 | +| --- | --- | --- | --- | --- | +| 1 | **A 的墙钟时间没缩短。** 「显示全部」建 18,924 行仍要 3,956 ms。 | 代码 | **阻塞已解除** | 逐组推导 `contain-intrinsic-size` 版**已实现并实测**(782 ms / 5.1×,高度误差 −0.04%,深层行可点可读),回退的唯一理由是那次**视口从第 27 轮跳回第 1 轮**的滚动回归。该回归**已归因:不是回归,是既有缺陷 D14**,逐组推导版的唯一阻塞解除。下一步:先修 D14,再把这条路重新提交。详见 D12 与 D14。 | +| 1b | ~~D14 — 点行时滚动位置永久丢失~~ **已修** | 代码 | — | 「显示全部」下点任意一行,若此刻有轮询建行在途,`buildStep()` 的 `if (build.token !== ledgerBuildToken) return;` 会连 `restoreRowAnchor()` 一起跳过,位置被 `clear(ledger)` 造成的塌缩永久丢弃。已用「滚动意图跨建行转交」修掉,并顺带把 token 自增移到 `clear(ledger)` 之上(否则四条提前返回什么都不退休)。浏览器实测通过。详见 D14。 | +| 2 | **增量协议 `?since=`**(只发新增尾部) | 代码 + 协议 | 需先在本文定口径 | 性能上严格支配 D10 的按代缓存:1,400 ms → ~10 ms 量级,且不需要 116 MB 常驻。**但它主要省网络与 JSON 解析,对 #1 那 4 秒的 DOM + layout 成本帮助有限**,不要指望它顺带解决 A。 | +| 3 | ~~给「显示全部」封顶~~ **已否决,不做** | 口径 | — | **chli 2026-10-12 定:不封顶。** 理由是「无限大无非是极端情况下假死,碰到的几率很小」。补充一条事实(已告知,不影响决定):18,924 行不是极端情况,就是本会话现在的规模,3,956 ms 就是在它上面测出来的。封顶还会把「读完整个会话」从点一次变成点约 69 次「向上加载更早的 200 条」,而那个控件今天只在 `hiddenAbove > 0` 时出现,等于新增一条路。此路不再讨论。 | +| 4 | **Stage 2 — 异常轴建模**(见 5.3) | 代码 | 等明确指示 | `anomalyRules/multiAnomaly/anomalyLast` 收成 `anomaly: {mode, rules, last}`;`visibleEntries()` 与 `spanPassesFilter()` 合成 R2 的单一入口;`resetBaseFilters()` 变成对 L2 模型的一次赋值(解 D5);删掉 `state.kinds` 上那句已经不成立的「the only filter」注释(解 D2)。 | +| 5 | **Stage 3 — 声明跨层关系(R6)** | 代码 | 无 | 小活:为「window-bar 在 L5 面板内、写 L3」和「overview 持有写 L2 的控件」各加一条断言,改位置或改归属时断言先红。 | +| 6 | **窄屏 sticky 只有代码证据,没有浏览器实测** | 验证 | 环境 | in-app 浏览器视口固定 735×900 且不可 resize。目前窄屏那条路只有源码和网络请求证据。 | +| 7 | **跨家族验证只有 codex 一条腿** | 验证 | 环境 | claude OAuth 过期、codebuddy 要 `/login`,需 chli 交互式处理。 | +| 8 | **PR #16 reviews=0**;`.probe\perf-payload.json` 34 MB 临时产物待删 | 流程 | 等 maintainer | — | + diff --git a/plugins/avatasia/mmc-trajectory/docs/STAGE1-SPEC.md b/plugins/avatasia/mmc-trajectory/docs/STAGE1-SPEC.md new file mode 100644 index 0000000..3f57f5e --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/docs/STAGE1-SPEC.md @@ -0,0 +1,247 @@ +# Stage 1 冻结 spec:把 L2 在文档里收成连续的 + +> # ⛔ 不采纳 — 请勿实施 +> +> **状态:已否决(2026-10-11)。本文件保留只为记录否决理由,任何人不得据此改动页面代码。** +> +> **否决理由**:上游把「查询条件在文档里被 KPI 区隔开」当成了需要偿还的结构债(D1)。这个判断是错的—— +> 查询条件是一个**聚合**,前端 UI 与最终请求参数本来就该拆开,两块甚至更多块 UI 各拼一部分参数是正常形态 +> (是否 MVVM 与此无关)。要求它们在文档里连续,是把「层要连续」当成了设计洁癖。 +> +> 提的搬动方案(把搜索框与时间区间移进 sticky 筛选带)也被否决:**窄屏上 sticky 会占满视口,可用区域反而更小**, +> 搜索框尤其如此。 +> +> `LAYERS.md` 的 D1 已同步改写为「设计如此,不是缺陷」,并指向真正的问题 **D9(钉住偏移的重算责任只挂在一根线上)**—— +> 那一条已经修掉了,与本 spec 无关。 +> +> 下面 17KB 的正文(15 条断言 V1–V12、7 条后果 F1–F7、4 条浏览器补测 B1–B4)全部作废,只作为「这条路走过」的记录保留。 + +状态:**不采纳** +日期:2026-10-11 · 上游:`docs/LAYERS.md` D1(已撤回) · 查漏:`codex / gpt-5.6-sol`(OpenAI,跨家族) + +这一份把 `LAYERS.md` 第 4 节的四个问题在本阶段答完,并把它们变成可判定的断言。 +**实现代码在本文件冻结之前一行都不写。** + +> 修订记录见文末第 8 节。那一轮查漏改了 6 条断言、撤了 1 条、加了 3 条后果, +> 并改正了我自己写错的一个函数名和一条错误理由。 + +--- + +## 0. 这一阶段只解决一个缺陷 + +`LAYERS.md` D1:「看哪些记录」这一个职责住在文档位置 3(`form.toolbar`)和位置 5(`.filter-sticky`)两处, +中间隔着整块 KPI 区。读者要滚过 5 张统计卡才够得着筛选控件。 + +上一轮的 sticky 修复是这笔债的利息。Stage 1 动本金:**让这一层在文档里就是连续的**, +sticky 从「补丁」退化成「这一层本来就该一直在手边」。 + +--- + +## 1. Scope + +**做**: + +- 新建 L2 容器,包住全部四组「看哪些记录」的控件: + 1. 搜索框(`.field.field-grow` / `#search-input`) + 2. 时间区间(`#range-from` `#range-to` + 4 个快捷 + `#range-refresh` + `#range-clear` + `#range-hint`) + 3. 类型轴(`.traj-controls#kind-controls`) + 4. 异常轴(`.anomaly-bar#anomaly-bar`,含多选 + `#anomaly-mode-note`) +- 这四组在**文档顺序上连续**,中间不插入任何非 L2 元素。 +- 容器不透明、带 sticky。保留现有实测偏移机制(`syncStickyOffsets()`)。 + +**不做**(范围闸门,超出即停): + +- 不动 L1:`#session-select` / `#generation-select` 留在容器外。 +- 不动 Stage 2:异常轴仍是三字段(`anomalyRules` / `multiAnomaly` / `anomalyLast`), + `visibleEntries()` 与 `spanPassesFilter()` 仍是两个入口。**这一阶段不碰模型。** +- 不改任何数字口径 —— 见第 4 问,本阶段一个数字都不改。 +- 不删任何现有守卫。断言位置变了就重推,不是因为「搬家了不好测」而删。 +- 不改 `#kpi-scope` 的三句措辞,也不动它发布的两个数字。 + +--- + +## 2. 四问 + +### ① 归层 + +| 控件 | 归属 | 写的 state | +| --- | --- | --- | +| `#search-input` | L2 | `query` | +| 时间区间全部 | L2 | `rangeFrom` `rangeTo` `rangeCutoff` `rangeFromText` `rangeToText` | +| 类型轴 | L2 | `kinds` | +| 异常轴 | L2 | `anomalyRules` `multiAnomaly` `anomalyLast` | +| `#session-select` `#generation-select` | **L1** | `sessionId` `generation` | +| `#kpi-grid` `#kpi-scope` | L4 | —(只读) | + +### ② 关联 + +- 搜索 / 区间 / 类型轴 / 异常轴:**同层并列**,四组同时可读、互不置灰。 +- 异常轴与其它三者:**互斥**,且这个互斥只由 `visibleEntries()` / `spanPassesFilter()` 表达一次 + (`resetBaseFilters()` 负责把另外三者写回默认值)。本阶段不动这段逻辑,只保证容器不破坏它。 +- `#kpi-scope` 与 L2:**从属**。它是 L4 对 L2 的一句声明,视觉上必须与 L2 区隔。 + +### ③ 布局 + +``` +header.page-header +#error-bar / #stale-bar +form.toolbar#toolbar ← 只剩 L1 一行(会话 / 上下文代) +section.kpi-section#kpi-section ← L4,在 L2 之外,带自己的口径行 +div.filter-sticky#filter-sticky ← L2,四组控件,连续,不透明 +section.overview#overview +main.columns > panel 轨迹 / panel 检视 +``` + +KPI **留在 L2 上方**(它不是筛选的一部分,视觉上要分开),但它上方已有的 `#kpi-scope` +措辞已经承担了口径声明,本阶段不新增任何标注。 + +### ④ 口径 + +**本阶段不改变任何数字。** 但「KPI 跟区间、不跟类型轴/搜索/异常轴」这条口径的全部读点, +必须先盘清楚,因为 Stage 1 动的正是 KPI 与筛选层的相对位置: + +| 读点 | 谁写的 | 读什么 | 现在断言它的守卫 | +| --- | --- | --- | --- | +| 5 张卡 | `renderKpi()` | `messages` `toolCalls` `turns` `tokens` `duration` | `client-check.mjs`(类型链接走 `onKindToggle`)、`mutate-turn-breakdown.py` | +| `#kpi-scope` 措辞 | `applyFilterScope()`(5372) | —(纯文本) | 无功能断言 | +| **`#kpi-scope` 里的数字** | `applyFilterScope()`(5383-5387) | `stats.messages`、**`stats.turnsWithPrompt`** | 无 | +| `#range-hint` | `updateRangeHint()` | `stats.messages` | `verify-time-range.mjs` 234/236 | +| `#generation-hint` | `statsAll()` | `payload.stats.messagesAll` | 无 | + +> 上一版把写 `#kpi-scope` 的函数记成了 `updateKpiScope()`——**那个函数不存在**, +> 真正的写入者是 `applyFilterScope()`,也就是第 F4 条说「名字与职责不符」的那个。 +> 我在同一份文档里把它的名字写错了一次,正好印证那条结论。 + +**风险,必须写下来**:守着 KPI↔区间这条口径的功能性断言(`verify-time-range.mjs`) +在 `run.mjs` 的 `SKIP` 集合里,**默认不跑**——它的数字针对的是多代长会话,而提交的夹具是单代几十行。 +所以这条口径在**默认 24 项验收**里没有覆盖。Stage 1 恰好改 KPI 的位置,因此必须补一条能在 +默认套件里跑的断言,而不是依赖那个跳过文件。(补法见 V6。) + +--- + +## 3. 不变量(可判定的) + +| | 不变量 | 怎么判 | +| --- | --- | --- | +| I1 | L2 容器的**直属子节点**恰好是四组控件,顺序为 搜索 → 区间 → 类型轴 → 异常轴,且它们之间不夹任何别的元素 | 解析容器子树的**标签序列**,按 id 定位四组;断言它们在子节点序列里**连续**。不能只比下标大小——那样一个插在中间的 KPI 段不会被发现 | +| I2 | `#kpi-grid` 与 `#kpi-scope` 都不在 L2 容器内部 | 子树解析 | +| I3 | `#session-select` / `#generation-select` 不在 L2 容器内部 | 子树解析 | +| I4 | L2 容器不透明 | 解析出的 `background` 必须是**已定义的 `--mcode-*` token**;`transparent` / `rgba` / `hsla` / 含 `/ 0` 的 rgb 一律不算 | +| I5 | L2 容器的偏移仍在 `render()` **末尾**实测写入 | 静态:`syncStickyOffsets();` 是 render 最后一句。运行态由浏览器实测补(见 §6) | +| I6 | `#kpi-scope` 的三句措辞与它发布的两个数字一字未改 | 运行态断言,见 V6 | + +--- + +## 4. verify 清单 + +每条给出:**断言名** / **它必须拒绝的错误实现** / **配套变异**。 +变异之间必须**互不等价**:两条断言共用一个变异,等于其中一条没被测到。 + +| 断言名 | 必须拒绝的错误实现 | 变异 | +| --- | --- | --- | +| V1 `the four filter controls are direct children of one layer, in order, with nothing between them` | 在搜索与区间之间塞一个 KPI 摘要条或标题 div(**插在中间**,不是接在末尾) | 把 `#kpi-section` 整段移到 `#search-input` 的 field 之后、`#range-from` 之前 | +| V2 `the KPI cards are not inside the filter layer` | 为了「视觉统一」把 KPI 并入筛选容器,且**接在四组之后** | 把 `kpi-section` 追加到 `.anomaly-bar` 之后(仍在容器内) | +| V3 `the session picker stays out of the filter layer` | 顺手把会话选择器也搬进去(它是 L1,搬了就等于把上下文选择说成筛选) | 把 `#session-select` 的 field 挪进容器 | +| V4 `the filter layer is opaque` | 去掉背景色「让页面更透气」,或改成 `rgb(0 0 0 / 0)` | 把 `background: var(--mcode-bg)` 换成 `transparent` | +| V5 `the layer height is still measured at the end of render` | 把 `syncStickyOffsets()` 上移到 `syncAnomalyMode()` 之前 | 交换两行 | +| V6 `the KPI says in words and in numbers which filter it follows` | KPI 位置移动后,那句声明或那两个数字消失/被改成与 `kpiStats()` 行为不符的话 | 把 `整场会话 · 类型与搜索只筛列表` 换回旧措辞 | +| V7 `the range filter and the type chips are siblings inside one layer` | 把区间做成类型轴的子控件(读者会以为区间是「某一类记录的区间」) | 把时间区间 field 嵌进 `#kind-controls` | +| V8 `the time range no longer depends on a toolbar row for its layout` | 搬进容器却没给 L2 容器自己的 flex 规则,靠 `.toolbar-row` 的旧样式蒙混过关 | 删掉 L2 容器的 `display:flex` | +| V9 `the search form no longer calls itself a search landmark` | 保留 `role="search"` 于一个已不含搜索框的表单 | 把 `role="search"` 加回去 | +| V10a/b/c `anomaly mode still silences the other three filter groups` | 搜索、区间、类型轴中**任意一组**丢掉 `data-filter-scope` | 分别给三个 field 去掉该属性,**三条独立变异** | +| V11 `the anomaly axis is not silenced by its own mode` | 给 `.anomaly-bar` 加上 `data-filter-scope` | 给 `.anomaly-bar` 加 `data-filter-scope` | +| V12 `the KPI publishes the same numbers kpiStats returns` | 让 `#kpi-scope` 里的 `N 条消息` 变成别处的数字 | 把 `stats.messages` 换成 `statsAll()` | + +V7 是这一阶段最容易犯的错:为了少一个容器,把区间塞进类型轴。两者是同层并列,不是从属。 + +**为什么 V1 的变异必须插在中间**:上一版写的是「把 KPI 整段移进容器」,而接在四组**之后**时四组仍然连续, +V1 照样通过——**那是一条空操作变异**。现在改成插在搜索与区间之间,它才会真正打断连续性。 + +### 4.1 静态断言测不到、必须浏览器补的 + +| | 内容 | 怎么测 | +| --- | --- | --- | +| B1 | 异常模式下搜索 / 区间 / 类型轴确实 `inert`(运行时属性),且 Tab 到不了 | 浏览器读 `element.inert` + 逐个 Tab | +| B2 | 异常轴在异常模式下**仍可 Tab 到** | 同上 | +| B3 | 三个 sticky 带在窄屏换行、提示出现/消失后仍不重叠 | 735px 视口截图 + 量盒 | +| B4 | 最小视口高度下四组控件没有把可用高度吃光 | 断言 `filter-sticky` 高度 < 视口高度的某个比例 | + +V10 与 B1 不是一回事:V10 证明标记还在(静态),B1 证明浏览器真的把它 inert 了(运行态)。 + +### 4.2 结构改动会引出的后果(本阶段必须显式决策) + +| | 后果 | 证据 | 本阶段决定 | +| --- | --- | --- | --- | +| **F1** | `.toolbar-row` 承载区间那一行的 `display:flex; flex-wrap: wrap; align-items: center`。区间 field 一旦移出表单,这段规则不再命中它——**布局会静默变样,没有任何报错** | 两条 `.toolbar-row` 规则(默认 + 窄屏 `align-items: stretch`) | L2 容器自己给布局规则,V8 断言 | +| **F2** | `form#toolbar` 上的 `submit` → `preventDefault()` 唯一职责是拦搜索框的隐式提交。移走后表单里只剩两个 ` -
+
-
+
时间区间
-

留空 = 不限。选一个时间区间,或用快捷按钮。

- -
-
- 异常 - -
-
-
+

留空 = 不限。选一个时间区间,或用快捷按钮。

@@ -2008,7 +2079,8 @@

关键指标

-