| 1 | # 对话滚动与历史契约 |
| 2 | |
| 3 | [English](TRANSCRIPT_SCROLL_CONTRACT.md) |
| 4 | |
| 5 | ## 适用范围 |
| 6 | |
| 7 | 对话区(`../desktop/frontend/src/components/Transcript.tsx`)通过 `ChatSource` |
| 8 | 和 `ChatScrollController` 以自然文档流渲染。本地与远程会话共用同一套实现。 |
| 9 | 已退役的 window adapter、measurement ledger、几何 revision 循环和逻辑选区不会 |
| 10 | 回归:不要重新引入第二套渲染栈、嵌套的虚拟纵向滚动区域或平台专属滚动补偿。 |
| 11 | |
| 12 | 修改任何可能移动对话视口或改变常驻历史范围的行为时,遵守以下契约。 |
| 13 | |
| 14 | ## 身份与渲染 |
| 15 | |
| 16 | - **稳定节点 key**:节点与块的 key 来自消息、轮次和工具身份,不能使用数组 |
| 17 | 下标。前插、结算和内容补丁不得改变已挂载节点的名称。 |
| 18 | - **未变化的节点保持对象引用**:流式与结算更新同一个助手 host;节点或顺序 |
| 19 | 快照未变化时保留原引用,React 不会重新挂载。 |
| 20 | - **Markdown 块身份**来自解析:每个块带有 key(同一次解析内的顶层下标)和由 |
| 21 | 产生它的那次解析盖上的内容指纹。两者都相同时渲染路径保留上一份 AST 对象, |
| 22 | 这正是原生选区和代码折叠状态能跨流式发布保持的原因。不要在渲染路径上比较 |
| 23 | 序列化后的树——指纹替换掉的正是这份开销。 |
| 24 | - **自然流**:Markdown、表格与已加载历史都使用文档流。解析可以懒执行、正文 |
| 25 | 可以按需读取,但对话区不得创建嵌套的虚拟纵向滚动区域。折叠的 process/tool |
| 26 | 正文按需挂载。 |
| 27 | - **业务状态归其所有者**:controller 与历史 store 持有状态,`ChatSource` 是 |
| 28 | 可重建的视图投影。结构变化在 microtask 中批量提交。 |
| 29 | |
| 30 | ## 单一写入者 |
| 31 | |
| 32 | - 只有 `../desktop/frontend/src/lib/transcriptViewportWriter.ts` 能修改对话区的 |
| 33 | 原生滚动位置。`ChatScrollController` 负责程序化跟随、阅读锚点与跳转,其他 |
| 34 | 模块一律提交给它。`../desktop/frontend/scripts/check-single-scroll-writer.mjs` |
| 35 | 必须拒绝任何绕过。 |
| 36 | - 原生输入永不被合成或被阻止,以维持尾部跟随。读者小幅上移即解除跟随, |
| 37 | 包括在底部阈值之内。 |
| 38 | - 前插、resize 和页面替换保留一个稳定节点加视口偏移。 |
| 39 | |
| 40 | ## 有界阅读窗口 |
| 41 | |
| 42 | 历史是有界窗口,不是无限增长的列表。 |
| 43 | |
| 44 | - 常驻 store 每个会话只保留少量相邻页(`windowMaxPages`,默认 3 页,每页 32 条 |
| 45 | 消息),**包含活跃会话**。超出预算时从读者正在离开的一端回收页面,并把调用方 |
| 46 | 必须移除的 item id 一并返回;忽略这些 id 的调用方会渲染出 store 已经释放的行。 |
| 47 | - **pin 保护的是会话身份与其活跃边缘,不是无限记录集。** 运行中或可见的会话 |
| 48 | 仍不可被逐出,但其历史同样受页面预算约束。 |
| 49 | - 回收不等于删除。持久化会话仍是权威数据,被回收的方向仍可通过游标重新到达, |
| 50 | 因此全部消息依然可搜索、可定位、可导出。不要把"全部历史已挂载"当作正确性 |
| 51 | 断言;应断言全历史可达性与有界驻留。 |
| 52 | - 翻页是双向的(`loadOlder` / `loadNewer`)。不提供较新游标的绑定保留其向前 |
| 53 | 翻页,而不是被要求用全量下载模拟双向窗口。 |
| 54 | - 窗口游标固定一个快照。追加不会使游标失效;存储替换或投影重建返回类型化的 |
| 55 | `stale_cursor`;服务端无法读取的游标也返回同一种类型化结果,而不是传输错误。 |
| 56 | 客户端最多自动重新定位一次,再次失败则保留当前页面并提供重试入口。 |
| 57 | - 按[会话同步 v2](TRANSCRIPT_V2.md)约定,轮次导航条仅展示已加载轮次,标记随 |
| 58 | 常驻窗口变化,不枚举未加载历史。 |
| 59 | - 跳转到未加载历史(例如权威历史搜索命中)时,通过历史索引定位并直接请求 |
| 60 | 目标附近页面,绝不从最新页循环加载直到目标。 |
| 61 | |
| 62 | ## 路由 |
| 63 | |
| 64 | - 历史读取按标签页的**绑定身份**路由,在请求之前由 controller 已加载的标签页 |
| 65 | 元数据决定。一次本地失败绝不能被另一个持有不同会话的远程服务应答。 |
| 66 | - 聊天要求 Desktop 与 Serve 协商 `transcript-v2`。旧服务显示升级错误,不回退到 |
| 67 | 旧聊天协议。权限、损坏和网络错误不触发协议降级。 |
| 68 | |
| 69 | ## 代际隔离 |
| 70 | |
| 71 | - 会话或 surface 替换时递增 generation。所有延迟测量、timer、动画帧回调和写入 |
| 72 | 请求都携带该 generation,过期工作零写入。 |
| 73 | - 异步分页绑定源会话请求身份;导航从请求到定位终态绑定 generation 与交互 |
| 74 | revision。原生输入接管取消导航,但不取消有效的源数据加载。旧 completion 或 |
| 75 | `finally` 只能释放同一个请求。 |
| 76 | - 来自已被替换会话的响应不得推进覆盖率,也不得改动其他标签页的状态。 |
| 77 | |
| 78 | ## 预算 |
| 79 | |
| 80 | - 每个 renderer 的历史正文缓存 32 MiB 与 Markdown 解析缓存 16 MiB 是可重建数据 |
| 81 | 的准入预算,不是整个 Electron 进程或模型执行内存的上限。 |
| 82 | - 字符串按驻留表示计账,媒体按解码尺寸计账;网络字节不等于堆内存。 |
| 83 | - 回收页面时同时撤销属于它的正文请求、解析任务、DOM 和 Object URL。 |
| 84 | - 超出预览预算的文本长度与元素数量降级为有界预览并提供明确的详情入口。复制与 |
| 85 | 导出不得静默截断:完整的显式操作或流式文件导出才承载完整内容。 |
| 86 | |
| 87 | ## 确定性行为 |
| 88 | |
| 89 | - 滚动逻辑走 controller 使用的那一套可注入时钟(`requestAnimationFrame`、 |
| 90 | `Date.now`、timer 函数)。不允许真实 sleep 或隐藏的重试时钟。 |
| 91 | - 请求的偏移已经落地的 transaction 可以作为 no-op 提交,但不得再次赋值 |
| 92 | `scrollTop`。 |
| 93 | - **竞态测试是必须的**:任何滚动或翻页行为变更都要在 |
| 94 | `../desktop/frontend/src/__tests__/` 中附带确定性事件序列,提交对话区变更前 |
| 95 | 运行 `pnpm test:transcript`。 |
| 96 |