Files
cc-web/.planning/fix-title-anchor/findings.md
2026-08-07 14:22:52 +08:00

6.9 KiB
Raw Blame History

调研记录

2026-08-07 初始现象

  • 用户截图中,标题条位于编号 2 的消息之后、编号 3 的消息之前。
  • 预期是标题条属于编号 2 的消息,应显示在编号 2 的消息上方。
  • 本轮 ccweb_set_title 返回的标题事件同时包含 messageIndex: 1 和 anchorMessageIndex: 0。两个字段相差 1,说明系统已经表达了“事件发生位置” 与“标题归属锚点”的区别。
  • 初步假设:事件生成端已恢复 anchorMessageIndex,但某个持久化或渲染路径 仍读取 messageIndex,从而产生一位偏移。

仓库状态

  • Git 工作区开始时干净。
  • codebase-memory-mcp 项目 home-cc-web 索引状态为 ready, 共 5036 个节点、10706 条边。

代码索引初查

  • 服务端 setCurrentConversationTitle() 已将当前消息总数写入 messageIndex, 并通过 findLatestUserMessageIndex() 写入 anchorMessageIndex。
  • 服务端与前端 normalizer 都会保留满足 0 <= anchorMessageIndex < messageIndex 的显式锚点。
  • buildUserOutlineTimelineItems() 已优先查找显式锚点,并把标题的 sortPosition 设为 anchorMessageIndex * 2 - 1,从位置值看应排在锚点消息前。
  • 现有 scripts/regression.js 已断言时间线类型顺序为 date,title,message,date,title,message,并断言显式锚点为 0。
  • 因此问题不在标题事件生成或基础时间线构造的明显缺失;下一步需要重点核对 最终排序比较器、实时事件处理、DOM 更新是否绕开或覆盖了该结果。

历史修复与当前源码

  • 隔离计划 .planning/title-history-locator/ 记录:2026-07-29 已针对同一现象 新增 anchorMessageIndex,专项与全量回归当时均通过。
  • Git blame 显示该实现来自提交 33c9783,当前 main 已包含此提交。
  • 当前排序比较器首先比较 sortPosition;显式锚点 0 的标题位置为 -1, 锚点消息位置为 0,纯函数上确实会得到“标题 → 消息”。
  • 当前 updateUserOutlinePanel() 直接按该纯函数返回顺序生成 DOM,没有二次排序。
  • 由此可排除“当前源码根本没有实现”这一可能;需要验证浏览器实际加载的静态资源 是否仍是旧版本,或实时标题事件在某条路径被前端 normalizer 丢掉锚点。

运行态与静态资源

  • PM2 的 ccweb 进程在线,启动于 2026-08-06 19:47(本地时区),未启用 watch。
  • 页面引用 app.js?v=20260805-usage-loading-inline,该缓存版本晚于 2026-07-29 的标题锚点修复;正常重新加载过页面的浏览器应取得含修复的脚本。
  • session_renamed 实时处理会把 msg.titleEvent 同时写入 snapshot 和 currentOutlineTitleHistory,两处都经过保留锚点的 normalizer,然后立即重绘。
  • 精确标题文本只在会话 904bcc12-... 的运行态 Codex App 文件中命中; 该文件本身不是 cc-web 的会话元数据,需继续定位真正的持久化会话文档。

当前会话字段实证

  • 当前会话文档 904bcc12-...json 已持久化本轮标题事件: messageIndex: 1、anchorMessageIndex: 0,且消息数组只有索引 0 的用户消息。
  • 这证明正在运行的服务端会正确生成和保存锚点;回归现象更可能只发生在客户端 展示版本或目标历史会话的数据形状,而非当前服务端生成逻辑。

客户端版本风险

  • 前端没有应用版本握手,也没有检测新静态资源后自动刷新页面的逻辑。
  • app.js 的查询参数只在用户重新请求 index.html 后生效;已经长期打开的标签页 会一直执行旧内存中的 JavaScript,即使 PM2 重启或磁盘源码已更新。
  • 这与“源码和回归正确、运行中 UI 仍呈现旧排序”的现象吻合,需进一步用运行态 静态响应和端到端 DOM 验证确认,而不是再次改写已正确的排序算法。

静态服务与目标数据补查

  • 服务端对所有静态资源返回 Cache-Control: no-store, max-age=0,因此一旦页面刷新, 浏览器缓存不会继续提供旧脚本;风险只剩“标签页从未刷新”的内存旧版本。
  • 以截图可见关键词宽泛搜索到的两个旧会话都没有 titleHistory,命中来自长文本 内容而非截图对应的标题事件,不能据此认定是目标会话。
  • 下一步应直接枚举标题历史的结构分布,确认是否还存在缺少锚点的新事件或旧格式 事件在兼容回退中排序错误。

标题历史结构审计

  • 当前 18 条持久化标题事件中,15 条新事件包含合法锚点;3 条旧事件没有锚点。
  • 所有带锚点事件都指向事件发生位置之前的用户消息,例如当前会话为 messageIndex: 1 -> anchorMessageIndex: 0,多轮会话也保持相同契约。
  • 3 条无锚点旧事件的 messageIndex 之前都存在可见用户消息,现有 precedingAnchor 回退能够找到正确候选。
  • 没有发现磁盘数据层面的非法锚点、字符串索引或事件位置异常;数据本身不足以 复现截图顺序,继续核对 CSS 与真实浏览器渲染。

CSS 与浏览器验证条件

  • 标题、消息和日期节点都按普通文档流渲染;标题样式没有 order、定位或变换, CSS 不会把正确的 DOM 顺序重新排错。
  • 当前环境没有 Chromium、Playwright、Puppeteer 或 jsdom,不能直接启动现成的 浏览器端到端测试;可继续使用项目现有的函数提取式前端回归。
  • 尚需确认 PM2 是否通过 CC_WEB_PUBLIC_DIR 指向另一份静态目录;如果存在, 就能解释“源码正确但页面仍是旧实现”。

最终实现

  • 服务端读取当前 PUBLIC_DIR/app.js 字节,计算 SHA-256 并取前 16 位十六进制 作为资源指纹;入口页脚本 URL 与 auth_result.frontendAssetVersion 使用同一算法。
  • index.html 使用占位符,服务端仅在返回入口页时替换;实际运行态测试确认占位符 全部消失,页面全局版本、脚本 URL 和 WebSocket 鉴权版本三者一致。
  • 客户端只接受 16–64 位十六进制版本;缺失、非法或一致时保持正常鉴权流程, 不一致时设置内存 guard 后刷新,重复握手不会重复触发。
  • 独立质量复核代理虽未回传结论,但在被中断前落盘了两项合理加固: PUBLIC_DIR 统一 path.resolve(),静态文件边界从字符串前缀改为复用 isPathInside(),同时让运行态专项显式覆盖 CC_WEB_PUBLIC_DIR。
  • 该加固修复了类似 /public-evil 通过 startsWith('/public') 的潜在路径边界误判, 与本次动态入口注入的自定义静态目录兼容性直接相关,予以保留。
  • 加固后的全量回归最终以 exit 0 完成;PUBLIC_DIR 自定义目录、入口页动态注入、 WebSocket 版本下发及原有标题历史回归均保持通过。