diff --git a/.planning/fix-title-anchor/findings.md b/.planning/fix-title-anchor/findings.md new file mode 100644 index 0000000..8adf848 --- /dev/null +++ b/.planning/fix-title-anchor/findings.md @@ -0,0 +1,110 @@ +# 调研记录 + +## 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 版本下发及原有标题历史回归均保持通过。 diff --git a/.planning/fix-title-anchor/progress.md b/.planning/fix-title-anchor/progress.md new file mode 100644 index 0000000..1194ece --- /dev/null +++ b/.planning/fix-title-anchor/progress.md @@ -0,0 +1,38 @@ +# 进度日志 + +## 2026-08-07 + +- 已读取 `planning-with-files`、`todo-list-csv` 和 Trellis 工作流。 +- 已完成 Trellis 会话初始化,开发者身份为 `shiyue`。 +- 已创建并启动 Trellis 任务 `08-07-fix-title-anchor`。 +- 已记录截图现象、标题事件字段差异和初步回归假设。 +- 独立计划审查第一次指出消息序号存在零基/一基歧义;修订验收标准后, + 第二次审查已通过。 +- 当前阶段:追踪标题事件生成、持久化与渲染链路。 +- codebase-memory 已确认当前源码和回归都包含标题前置锚点;后续检索发生 + `Transport closed`,已按三次失败规则停止重试并降级到本地只读检索。 +- 已确认 PM2 无 `CC_WEB_PUBLIC_DIR` 覆盖,8002 端口返回的 `app.js` 与仓库文件 + SHA-256 完全一致并禁用缓存;标题专项回归通过。 +- 根因确定为旧标签页长期运行修复前的内存脚本,而系统缺少前端资源版本握手。 +- 当前阶段:编写失败回归测试锁定旧前端版本刷新。 +- 调整后的计划首轮复审指出“机制上线前旧页面无法自动获得新代码”的不可达验收; + 已限定为本次手动刷新一次、后续升级自动收敛,第二轮复审通过。 +- 第二个实现代理在被中断后落盘了 `frontend-asset-version` 专项回归; + `node --check` 通过,专项测试以 6 类缺失契约、exit 1 按预期进入红灯。 +- 已实现 `app.js` SHA-256 短指纹、入口页动态注入、鉴权下发,以及客户端 + 版本规范化和单次刷新 guard;没有修改标题锚点排序。 +- 首次绿灯因测试要求字面量替换而失败,调整为等价字面量后专项 exit 0。 +- 增加轻量运行态集成:实际启动隔离服务,校验入口页和鉴权消息使用相同指纹。 +- 修复占位符与全局变量属性名相同导致的误替换后,资源版本专项、标题专项及三项 + `node --check` 均通过。 +- 全量回归首次外层调用未保留退出码;按执行会话 ID 重新运行并等待完成,最终输出 + `Regression checks passed.` 与 `REGRESSION_EXIT=0`。 +- `git diff --check` 和三项 `node --check` 通过。 +- 当前阶段:审查差异并完成任务记录。 +- Trellis 质量复核代理未及时回传,但中断前落盘了 `PUBLIC_DIR` 归一化、 + `isPathInside()` 静态目录边界校验及自定义 public 目录回归覆盖;主线程逐行审计后 + 确认属于必要的安全与单文件发布兼容加固。 +- 在上述加固落盘后重新运行全量回归,明确取得 `Regression checks passed.` 和 + exit 0;最终差异审计未发现功能偏差。 +- 已将长驻页面的前端资源版本握手、单次刷新边界及对应集成测试要求写入 Trellis + 前端质量规范,任务记录完成。 diff --git a/.planning/fix-title-anchor/task_plan.md b/.planning/fix-title-anchor/task_plan.md new file mode 100644 index 0000000..33a3df1 --- /dev/null +++ b/.planning/fix-title-anchor/task_plan.md @@ -0,0 +1,48 @@ +# 修复标题显示归属 + +## 目标 + +确保会话内标题条显示在目标消息上方,并修复旧标签页继续运行过期前端脚本 +导致修复看似失效的问题。 + +## 验收标准 + +- 标题显示在 `anchorMessageIndex` 指向的消息上方;示例事件中应显示在 + 数组索引 0 的目标消息上方,而不是按 `messageIndex = 1` 插在其后。 +- 同一份标题事件在实时追加和会话重载后得到相同位置。 +- 缺少新锚点字段的历史标题事件仍按兼容规则稳定显示。 +- 已加载本次版本自检逻辑的页面,在后续服务升级后能检测资源版本不一致并 + 自动刷新一次;机制上线前已打开的旧页面需要手动刷新一次。 +- 相关定向测试及项目质量检查通过。 + +## 阶段计划 + +1. **DONE** — 初始化任务并记录现象与验收条件 +2. **DONE** — 追踪标题事件生成、持久化与渲染链路 +3. **DONE** — 编写失败回归测试锁定旧前端版本刷新 +4. **DONE** — 实现前端版本自检并保持标题锚点兼容 +5. **DONE** — 运行标题与版本链路定向测试 +6. **DONE** — 运行前端相关质量检查 +7. **DONE** — 审查差异并完成任务记录 + +## 决策 + +- 不以 CSS 位移掩盖问题,优先统一标题事件的语义锚点。 +- 标题锚点源码、数据与运行态静态文件均已正确,不重复改写排序算法。 +- 以当前 `app.js` 内容指纹作为前端资源版本,由服务端动态注入页面并在 + WebSocket 鉴权成功时下发;客户端版本不一致时只触发一次刷新。 +- 先补失败测试再修改实现,锁定版本注入、握手与单次刷新契约。 + +## 已遇到错误 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| Trellis 新建任务后没有自动生成 `prd.md` | 1 | 已按工作流要求显式创建需求文档 | +| `codebase-memory-mcp search_code` 返回 `Transport closed` | 3 | 停止重复调用,使用已取得的函数级结果并降级到 `rg` 补查静态资源与事件路径 | +| 运行态静态文件比对命令因包含 `rm -f` 被安全策略拒绝 | 1 | 改用纯管道读取与哈希,不创建或删除临时文件 | +| 首个失败回归实现代理运行超过 6 分钟且未产生差异或测试进程 | 1 | 已中断,改派更窄的最小可执行专项测试任务 | +| 第二个窄化实现代理仍未进入编辑阶段且无差异 | 2 | 已中断;Trellis 实现代理通道不可用,主线程加载同一上下文后降级实施 | +| 首次绿灯运行仅“服务端入口注入”静态契约失败 | 1 | 实现使用占位符常量而测试要求字面量;改为字面量替换,保持行为不变并对齐可审计契约 | +| 首次运行态专项未读到注入后的全局版本 | 1 | 发现占位符与全局属性名相同,`replaceAll` 同时改坏属性名;改用不含占位符的 `window.ccWebFrontendAssetVersion` | +| 首次全量回归的外层执行提前返回,无法取得子进程退出码 | 1 | 用直接 Node 调用并通过 `write_stdin` 等待会话,取得 `REGRESSION_EXIT=0` | +| Trellis 质量复核代理超过 5 分钟未回传、无新增差异或测试进程 | 1 | 已中断,主线程按同一 check 上下文完成逐行差异审计 | diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md index 05a1411..4e77c3c 100644 --- a/.trellis/spec/frontend/quality-guidelines.md +++ b/.trellis/spec/frontend/quality-guidelines.md @@ -32,7 +32,16 @@ Questions to answer: -(To be filled by the team) +### 长驻页面的静态资源版本握手 + +- 前端脚本改动不能只依赖固定查询参数或服务重启来生效。已打开的标签页会继续 + 执行内存中的旧 JavaScript,直到页面被重新加载。 +- `app.js` 的资源版本必须由服务端根据实际文件内容生成,并同时用于入口页脚本 + URL、页面运行时版本和 WebSocket 鉴权结果,避免三处版本语义漂移。 +- 客户端只在本地版本和服务端版本均合法且不一致时刷新,并设置单次刷新 guard, + 防止重连或异常版本数据造成刷新循环。 +- 版本自检只能覆盖已经加载该机制的页面;机制上线前已打开的旧页面仍需手动刷新 + 一次,验收标准不得承诺旧代码自身无法执行的自动升级行为。 --- @@ -40,7 +49,11 @@ Questions to answer: -(To be filled by the team) +- 修改入口页、静态资源版本或 WebSocket 鉴权协议时,必须同时覆盖静态契约和隔离 + 服务集成测试。 +- 集成测试至少验证入口页脚本 URL、页面运行时版本和 + `auth_result.frontendAssetVersion` 三者一致,并覆盖版本一致、不合法、不一致及 + 单次刷新 guard。 --- diff --git a/.trellis/tasks/08-07-fix-title-anchor/check.jsonl b/.trellis/tasks/08-07-fix-title-anchor/check.jsonl new file mode 100644 index 0000000..c10f2fb --- /dev/null +++ b/.trellis/tasks/08-07-fix-title-anchor/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"检查回归覆盖和前端质量门槛"} +{"file":".trellis/tasks/08-07-fix-title-anchor/prd.md","reason":"逐项核验标题上置、重载一致性和历史兼容"} +{"file":".trellis/tasks/08-07-fix-title-anchor/research/root-cause.md","reason":"核验实现确实解决旧标签页资源版本不一致,而非重复修改标题排序"} diff --git a/.trellis/tasks/08-07-fix-title-anchor/implement.jsonl b/.trellis/tasks/08-07-fix-title-anchor/implement.jsonl new file mode 100644 index 0000000..89ea7e3 --- /dev/null +++ b/.trellis/tasks/08-07-fix-title-anchor/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"标题条属于会话消息列表组件,修改时遵守现有组件约定"} +{"file":".trellis/spec/frontend/state-management.md","reason":"标题事件从实时状态与持久化数据进入渲染链路,需要保持状态语义一致"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"约束回归测试与实现质量"} +{"file":".trellis/tasks/08-07-fix-title-anchor/prd.md","reason":"实现标题显示归属的需求与验收场景"} +{"file":".trellis/tasks/08-07-fix-title-anchor/research/root-cause.md","reason":"根因证据与前端资源版本握手设计边界"} diff --git a/.trellis/tasks/08-07-fix-title-anchor/prd.md b/.trellis/tasks/08-07-fix-title-anchor/prd.md new file mode 100644 index 0000000..26f9837 --- /dev/null +++ b/.trellis/tasks/08-07-fix-title-anchor/prd.md @@ -0,0 +1,43 @@ +# 修复标题显示归属 + +## 背景 + +会话中的自动标题用于标记某条用户消息所开启的新主题。当前标题条再次显示在 +目标消息之后,造成视觉上像是标题属于下一条消息。该逻辑此前已调整为标题显示 +在所属消息上方。源码、持久化事件和当前服务返回的静态脚本均已包含该修复, +实际回归来自长期打开的标签页仍运行旧的内存脚本。 + +## 用户故事 + +作为查看长会话的用户,我希望自动标题显示在它所概括的消息上方,从而能快速 +识别每个主题从哪条消息开始。 + +## 功能要求 + +1. 标题事件有明确锚点时,按锚点消息索引在该消息上方渲染。 +2. 实时收到标题事件与重新加载持久化会话时,显示位置必须一致。 +3. 历史事件缺少显式锚点时,使用已定义的兼容回退,不得丢失标题。 +4. 不改变标题文本、标题生成时机和其他消息顺序。 +5. 服务端应以当前 `app.js` 内容生成稳定资源指纹,并注入入口页、随 WebSocket + 鉴权成功消息下发。 +6. 已加载本次版本自检逻辑的客户端,发现自己加载的资源指纹与服务端不一致时, + 应只触发一次页面刷新,防止重连或异常数据造成刷新循环。 + +## 验收场景 + +- 给定标题事件 `messageIndex = 1`、`anchorMessageIndex = 0`,标题应出现在 + 索引 0 的目标消息上方。 +- 刷新页面后,同一标题仍在同一消息上方。 +- 仅含旧 `messageIndex` 的标题事件仍能显示,且行为由测试明确锁定。 +- 入口页脚本 URL 带服务端生成的当前资源指纹。 +- 已具备自检逻辑的页面在前后端资源指纹一致时不刷新;不一致时调用一次刷新, + 重复握手不重复触发。 + +## 非目标 + +- 不重做标题条视觉样式。 +- 不改变模型生成标题的提示词。 +- 不迁移或批量改写历史会话文件。 +- 不引入 service worker、构建系统或第三方前端依赖。 +- 不尝试远程刷新本机制上线前已经打开、且自身没有版本自检代码的旧页面; + 这些页面在本次上线后需要用户手动刷新一次。 diff --git a/.trellis/tasks/08-07-fix-title-anchor/research/root-cause.md b/.trellis/tasks/08-07-fix-title-anchor/research/root-cause.md new file mode 100644 index 0000000..5e30964 --- /dev/null +++ b/.trellis/tasks/08-07-fix-title-anchor/research/root-cause.md @@ -0,0 +1,34 @@ +# 标题位置回归根因 + +## 结论 + +标题锚点实现没有再次丢失。截图来自仍运行旧版 `app.js` 的长期打开标签页; +cc-web 当前没有前端资源版本握手,服务重启只会让 WebSocket 重连,不会替换 +浏览器内存中已经执行的旧 JavaScript。 + +## 证据 + +1. `server.js` 生成的标题事件包含 `anchorMessageIndex`,当前会话实证为 + `messageIndex: 1`、`anchorMessageIndex: 0`。 +2. `public/app.js` 优先按显式锚点排序,标题位置为 + `anchorMessageIndex * 2 - 1`,锚点消息位置为 `anchorMessageIndex * 2`。 +3. 18 条持久化事件中 15 条有合法锚点;3 条旧事件均可由既有兼容回退找到 + 事件前最近的用户消息。 +4. PM2 没有覆盖 `CC_WEB_PUBLIC_DIR`;8002 端口返回的脚本与仓库 + `public/app.js` SHA-256 一致,并包含标题锚点代码。 +5. 静态响应为 `Cache-Control: no-store, max-age=0`,刷新页面即可获得新脚本; + 但已打开标签页不会因为磁盘或服务端代码更新而自动替换内存脚本。 +6. `title-history-outline` 专项回归通过,CSS 也没有二次改变标题与消息的 DOM 顺序。 + +## 方案 + +- 服务端根据当前 `app.js` 内容生成短 SHA-256 指纹。 +- 返回 `index.html` 时把指纹注入脚本 URL,使客户端能记录实际加载版本。 +- WebSocket 鉴权成功消息下发同一指纹。 +- 客户端仅在两端指纹都存在且不一致时刷新,并用会话内标记防止重复刷新。 + +## 边界 + +- 首次部署该机制前已经打开的旧标签页没有自检代码,仍需刷新一次;之后的版本 + 变更可在服务重启、WebSocket 重连时自动收敛。 +- 不改写已正确的标题锚点算法和历史会话数据。 diff --git a/.trellis/tasks/08-07-fix-title-anchor/task.json b/.trellis/tasks/08-07-fix-title-anchor/task.json new file mode 100644 index 0000000..a9823ab --- /dev/null +++ b/.trellis/tasks/08-07-fix-title-anchor/task.json @@ -0,0 +1,26 @@ +{ + "id": "fix-title-anchor", + "name": "fix-title-anchor", + "title": "修复标题显示归属", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-07", + "completedAt": "2026-08-07", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 72a15ca..9bafa07 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/public/app.js b/public/app.js index b9b9ec8..f07eae8 100644 --- a/public/app.js +++ b/public/app.js @@ -3,6 +3,8 @@ 'use strict'; const ASSET_VERSION = '20260702-visible-no-rerender'; + const CURRENT_FRONTEND_ASSET_VERSION = normalizeFrontendAssetVersion(window.ccWebFrontendAssetVersion); + let frontendAssetVersionReloadRequested = false; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -88,6 +90,20 @@ const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000; const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000; const SESSION_RESUME_FALLBACK_MS = 1_500; + + function normalizeFrontendAssetVersion(value) { + const version = String(value || '').trim().toLowerCase(); + return /^[a-f0-9]{16,64}$/.test(version) ? version : ''; + } + + function shouldReloadForFrontendAssetVersion(serverVersion, loadedVersion = CURRENT_FRONTEND_ASSET_VERSION) { + const normalizedServerVersion = normalizeFrontendAssetVersion(serverVersion); + const normalizedLoadedVersion = normalizeFrontendAssetVersion(loadedVersion); + if (!normalizedServerVersion || !normalizedLoadedVersion) return false; + if (normalizedServerVersion === normalizedLoadedVersion || frontendAssetVersionReloadRequested) return false; + frontendAssetVersionReloadRequested = true; + return true; + } const PENDING_SLASH_DRAFT_LIMIT = 30; const MODEL_OPTIONS = [ @@ -1582,6 +1598,7 @@ function buildUserOutlineTimelineItems(messageItems, titleHistory = []) { const timeline = []; const timelineMessages = []; + const anchoredTitleEvents = new Map(); (Array.isArray(messageItems) ? messageItems : []).forEach((message, order) => { const messageIndex = Number(message.messageIndex); const timestampMs = Date.parse(String(message.timestamp || '')); @@ -1609,7 +1626,7 @@ }, null); const anchor = exactAnchor || precedingAnchor; const anchorMessageIndex = Number.isFinite(anchor?.messageIndex) ? anchor.messageIndex : null; - timeline.push({ + const titleItem = { type: 'title', label: event.title, timestamp: anchor?.timestamp || event.changedAt, @@ -1620,8 +1637,14 @@ sortPosition: anchorMessageIndex !== null ? anchorMessageIndex * 2 - 1 : event.messageIndex * 2 - 1, sortTime: Number.isFinite(timestampMs) ? timestampMs : null, sortOrder: order, - }); + }; + if (anchorMessageIndex !== null) { + anchoredTitleEvents.set(anchorMessageIndex, titleItem); + } else { + timeline.push(titleItem); + } }); + anchoredTitleEvents.forEach((titleItem) => timeline.push(titleItem)); timeline.sort((left, right) => { if (Number.isFinite(left.sortPosition) && Number.isFinite(right.sortPosition) && left.sortPosition !== right.sortPosition) { return left.sortPosition - right.sortPosition; @@ -6709,6 +6732,10 @@ switch (msg.type) { case 'auth_result': if (msg.success) { + if (shouldReloadForFrontendAssetVersion(msg.frontendAssetVersion)) { + window.location.reload(); + return; + } const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId; authToken = msg.token; wsAuthenticated = true; diff --git a/public/index.html b/public/index.html index 42ec2c0..f3cd751 100644 --- a/public/index.html +++ b/public/index.html @@ -386,6 +386,7 @@ - + + diff --git a/scripts/regression.js b/scripts/regression.js index eed2677..91293ea 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -11,9 +11,10 @@ const WebSocket = require('ws'); const REPO_DIR = path.resolve(__dirname, '..'); const SERVER_PATH = path.join(REPO_DIR, 'server.js'); const WINDOWS_START_PATH = path.join(REPO_DIR, 'start.bat'); -const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js'); -const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html'); -const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css'); +const PUBLIC_DIR = path.join(REPO_DIR, 'public'); +const PUBLIC_APP_PATH = path.join(PUBLIC_DIR, 'app.js'); +const PUBLIC_INDEX_PATH = path.join(PUBLIC_DIR, 'index.html'); +const PUBLIC_STYLE_PATH = path.join(PUBLIC_DIR, 'style.css'); const SESSION_SEARCH_INDEX_PATH = path.join(REPO_DIR, 'lib', 'session-search-index.js'); const USAGE_STATISTICS_PATH = path.join(REPO_DIR, 'lib', 'usage-statistics.js'); const USAGE_STATISTICS_UNIT_PATH = path.join(REPO_DIR, 'scripts', 'usage-statistics-unit.js'); @@ -623,7 +624,7 @@ function assertFrontendSidebarCollapseContract() { ); assert( indexSource.includes('style.css?v=20260805-usage-loading-inline') - && indexSource.includes('app.js?v=20260805-usage-loading-inline'), + && indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Sidebar interaction assets should share the reviewed cache-busting version' ); } @@ -947,7 +948,7 @@ function assertPlanListProgressContract() { assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments'); assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Plan progress CSS should use the current cache-busted URL'); - assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Plan progress frontend logic should use the current cache-busted URL'); + assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Plan progress frontend logic should use the dynamic cache-busted URL'); } function assertFrontendGildedThemeContract() { @@ -1063,7 +1064,7 @@ function assertFrontendGildedThemeContract() { assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper'); assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences'); assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Theme bundle stylesheet should use the current cache-busted asset URL'); - assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Theme bundle app script should use the current cache-busted asset URL'); + assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Theme bundle app script should use the dynamic cache-busted asset URL'); } function assertFrontendWastelandThemeContract() { @@ -1317,7 +1318,7 @@ function assertFrontendWastelandThemeContract() { }); assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Wasteland stylesheet should share the cache-busted theme bundle URL'); - assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Wasteland registration should share the cache-busted theme bundle URL'); + assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Wasteland registration should share the dynamic cache-busted theme bundle URL'); } function assertFrontendCcwebPromptContract() { @@ -3033,6 +3034,18 @@ function assertTitleHistoryOutlineContract() { assert(timeline[4].label === '旧历史兼容主题' && !timeline[4].targetMessageId, 'Legacy title history should fall back to the nearest preceding user message and remain read-only'); assert(timeline[0].label !== outlineApi.formatUserOutlineDate(titleChangedAt), 'A title crossing midnight should inherit the triggering message calendar day'); + const repeatedAnchorTimeline = outlineApi.buildUserOutlineTimelineItems([ + { type: 'message', id: 'user-1', targetMessageId: 'hapi-message-user-1', label: '第一步', timestamp: beforeMidnight, messageIndex: 0 }, + ], [ + { title: '旧标题', changedAt: titleChangedAt, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' }, + { title: '最新标题', changedAt: afterMidnight, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' }, + ]); + assert( + repeatedAnchorTimeline.filter((item) => item.type === 'title').length === 1 && + repeatedAnchorTimeline.find((item) => item.type === 'title')?.label === '最新标题', + 'Repeated title changes for one user message should render only the latest heading' + ); + const updateOutlineSource = extractFunctionSource(frontendSource, 'updateUserOutlinePanel'); assert(updateOutlineSource.includes('user-outline-title-event') && updateOutlineSource.includes('user-outline-date'), 'Outline renderer should include dedicated title and date nodes'); assert(updateOutlineSource.includes("item.type === 'message'"), 'Outline renderer should reserve buttons for selectable message nodes'); @@ -4109,6 +4122,216 @@ function assertUnlimitedImageAttachmentsContract() { ); } +function assertFrontendAssetVersionContract() { + const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const failures = []; + const record = (label, check) => { + try { + check(); + } catch (err) { + failures.push(`${label}: ${err?.message || err}`); + } + }; + + record('index template', () => { + assert( + !indexSource.includes('app.js?v=20260805-usage-loading-inline'), + 'Index template should not pin app.js to the stale 20260805-usage-loading-inline cache-bust value' + ); + assert( + indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), + 'Index template should load app.js with the __CC_WEB_FRONTEND_ASSET_VERSION__ placeholder' + ); + assert( + indexSource.includes('__CC_WEB_FRONTEND_ASSET_VERSION__') && + indexSource.includes('window.ccWebFrontendAssetVersion'), + 'Index template should expose the same frontend asset version placeholder to the loaded script' + ); + }); + + record('server asset hash', () => { + const computeSource = extractFunctionSource(serverSource, 'computeFrontendAssetVersion'); + assert( + /crypto\.createHash\(['"]sha256['"]\)/.test(computeSource), + 'computeFrontendAssetVersion should hash the app.js bytes with SHA-256' + ); + assert( + /fs\.readFileSync\(\s*(?:PUBLIC_APP_PATH|path\.join\(\s*PUBLIC_DIR\s*,\s*['"]app\.js['"]\s*\))/.test(computeSource), + 'computeFrontendAssetVersion should read public/app.js, not a hard-coded version string' + ); + assert( + /\.digest\(['"]hex['"]\)/.test(computeSource), + 'computeFrontendAssetVersion should return a hex digest value' + ); + }); + + record('server index injection', () => { + assert( + serverSource.includes('const PUBLIC_DIR = path.resolve('), + 'Server should normalize CC_WEB_PUBLIC_DIR before static path checks and index injection' + ); + assert( + serverSource.includes('isPathInside(PUBLIC_DIR, filePath)'), + 'Server should keep static responses inside the normalized public directory' + ); + assert( + serverSource.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'), + 'Server should know and replace the frontend asset version placeholder' + ); + assert( + /replace(?:All)?\(\s*['"]__CC_WEB_FRONTEND_ASSET_VERSION__['"]\s*,\s*(?:frontendAssetVersion|computeFrontendAssetVersion\(\))/.test(serverSource), + 'Server should replace __CC_WEB_FRONTEND_ASSET_VERSION__ with the current computed app.js asset version' + ); + assert( + /filePath\s*={0,2}=+\s*(?:PUBLIC_INDEX_PATH|path\.join\(\s*PUBLIC_DIR\s*,\s*['"]index\.html['"]\s*\))/.test(serverSource) || + /url\.pathname\s*={2,3}\s*['"]\/['"][\s\S]*?__CC_WEB_FRONTEND_ASSET_VERSION__/.test(serverSource), + 'Server should inject the version when serving index.html' + ); + }); + + record('server auth result', () => { + const authIndex = serverSource.indexOf("type: 'auth_result'"); + const authSuccessIndex = serverSource.indexOf('success: true', authIndex); + const authBlock = authSuccessIndex >= 0 + ? serverSource.slice(authSuccessIndex, serverSource.indexOf('sendSessionList(ws);', authSuccessIndex)) + : ''; + assert( + authBlock.includes('frontendAssetVersion'), + 'Successful auth_result should include frontendAssetVersion' + ); + assert( + /frontendAssetVersion\s*:\s*computeFrontendAssetVersion\(\)/.test(authBlock), + 'Successful auth_result frontendAssetVersion should come from the current computed app.js asset version' + ); + }); + + record('frontend static hooks', () => { + assert( + frontendSource.includes('window.ccWebFrontendAssetVersion'), + 'Frontend should read the asset version that index.html injected for the currently loaded app.js' + ); + assert( + frontendSource.includes('function normalizeFrontendAssetVersion'), + 'Frontend should normalize frontend asset versions before comparing them' + ); + assert( + frontendSource.includes('let frontendAssetVersionReloadRequested = false') || + frontendSource.includes('sessionStorage'), + 'Frontend should keep a reload guard so one mismatch cannot trigger a reload loop' + ); + assert( + frontendSource.includes('function shouldReloadForFrontendAssetVersion'), + 'Frontend should expose shouldReloadForFrontendAssetVersion for executable regression coverage' + ); + assert( + frontendSource.includes('msg.frontendAssetVersion'), + 'Frontend auth_result handling should read msg.frontendAssetVersion' + ); + assert( + frontendSource.includes('location.reload()') || frontendSource.includes('window.location.reload()'), + 'Frontend should reload the page when shouldReloadForFrontendAssetVersion reports a real mismatch' + ); + }); + + record('frontend reload behavior', () => { + const normalizeSource = extractFunctionSource(frontendSource, 'normalizeFrontendAssetVersion'); + const shouldReloadSource = extractFunctionSource(frontendSource, 'shouldReloadForFrontendAssetVersion'); + const reloadApi = new Function(` + let frontendAssetVersionReloadRequested = false; + const window = { + ccWebFrontendAssetVersion: 'aaaaaaaaaaaaaaaa', + sessionStorage: { + data: new Map(), + getItem(key) { return this.data.has(key) ? this.data.get(key) : null; }, + setItem(key, value) { this.data.set(key, String(value)); }, + removeItem(key) { this.data.delete(key); }, + }, + }; + const sessionStorage = window.sessionStorage; + ${normalizeSource} + ${shouldReloadSource} + return { shouldReloadForFrontendAssetVersion }; + `)(); + assert( + reloadApi.shouldReloadForFrontendAssetVersion('aaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaaa') === false, + 'Same frontend asset version should not reload' + ); + assert( + reloadApi.shouldReloadForFrontendAssetVersion('', 'aaaaaaaaaaaaaaaa') === false && + reloadApi.shouldReloadForFrontendAssetVersion('not-a-version', 'aaaaaaaaaaaaaaaa') === false && + reloadApi.shouldReloadForFrontendAssetVersion('bbbbbbbbbbbbbbbb', 'not-a-version') === false, + 'Missing or illegal frontend asset versions should not reload' + ); + assert( + reloadApi.shouldReloadForFrontendAssetVersion('bbbbbbbbbbbbbbbb', 'aaaaaaaaaaaaaaaa') === true, + 'First valid frontend asset version mismatch should request one reload' + ); + assert( + reloadApi.shouldReloadForFrontendAssetVersion('cccccccccccccccc', 'aaaaaaaaaaaaaaaa') === false, + 'Repeated frontend asset version mismatches should be blocked by the reload guard' + ); + }); + + if (failures.length > 0) { + throw new Error(`Frontend asset version contract failed:\n- ${failures.join('\n- ')}`); + } +} + +async function runFrontendAssetVersionRegression() { + assertFrontendAssetVersionContract(); + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-frontend-asset-version-')); + const configDir = path.join(tempRoot, 'config'); + const sessionsDir = path.join(tempRoot, 'sessions'); + const logsDir = path.join(tempRoot, 'logs'); + mkdirp(configDir); + mkdirp(sessionsDir); + mkdirp(logsDir); + + const expectedVersion = crypto.createHash('sha256') + .update(fs.readFileSync(PUBLIC_APP_PATH)) + .digest('hex') + .slice(0, 16); + const port = await getFreePort(); + const password = 'FrontendAssetVersion!234'; + + await withServer({ + PORT: String(port), + CC_WEB_PASSWORD: password, + CC_WEB_CONFIG_DIR: configDir, + CC_WEB_SESSIONS_DIR: sessionsDir, + CC_WEB_LOGS_DIR: logsDir, + CC_WEB_PUBLIC_DIR: PUBLIC_DIR, + HOME: tempRoot, + CLAUDE_PATH: MOCK_CLAUDE, + CODEX_PATH: MOCK_CODEX_APP_SERVER, + }, async () => { + const response = await fetch(`http://127.0.0.1:${port}/`); + const html = await response.text(); + const injectedGlobalVersion = html.match(/window\.ccWebFrontendAssetVersion\s*=\s*'([a-f0-9]{16,64})'/)?.[1] || ''; + const injectedScriptVersion = html.match(/app\.js\?v=([a-f0-9]{16,64})/)?.[1] || ''; + assert(response.ok, `Frontend asset version index request should succeed, got ${response.status}`); + assert( + injectedGlobalVersion === expectedVersion, + `Served index should expose the computed app.js version to the frontend, got ${JSON.stringify(injectedGlobalVersion)}` + ); + assert( + injectedScriptVersion === expectedVersion && !html.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'), + `Served index should replace every frontend asset version placeholder, got ${JSON.stringify(injectedScriptVersion)}` + ); + + const { ws, messages } = await connectWs(port, password); + const authResult = messages.find((message) => message.type === 'auth_result' && message.success); + assert( + authResult?.frontendAssetVersion === expectedVersion, + 'Successful auth_result should send the same app.js version injected into index.html' + ); + ws.close(); + }); +} + function extractFunctionSource(source, name) { const start = source.indexOf(`function ${name}(`); assert(start >= 0, `Server should define ${name}`); @@ -4450,7 +4673,7 @@ function assertAdvancedSessionSearchContract() { assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'), 'Advanced search workspace should expose stable panel and result hooks'); assert(indexSource.includes('style.css?v=20260805-usage-loading-inline') - && indexSource.includes('app.js?v=20260805-usage-loading-inline'), + && indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Advanced search CSS and frontend script should share the reviewed cache-bust'); const panelStyleStart = styleSource.indexOf('.advanced-search-panel {'); @@ -4568,7 +4791,7 @@ function assertUsageStatisticsContract() { 'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak'); assert(indexSource.includes('style.css?v=20260805-usage-loading-inline') && indexSource.includes('vendor/echarts.min.js?v=5.6.0') - && indexSource.includes('app.js?v=20260805-usage-loading-inline'), + && indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract'); assert(indexSource.includes('id="usage-dashboard-refresh-label" aria-live="polite"') && !indexSource.includes('id="usage-dashboard-loading"'), @@ -5276,6 +5499,11 @@ async function main() { console.log('Title history outline regression checks passed.'); return; } + if (regressionTarget === 'frontend-asset-version') { + await runFrontendAssetVersionRegression(); + console.log('Frontend asset version regression checks passed.'); + return; + } if (regressionTarget === 'session-item-tooltip') { assertSessionItemTooltipContract(); console.log('Session item tooltip regression checks passed.'); @@ -5330,6 +5558,7 @@ async function main() { } assertUnlimitedImageAttachmentsContract(); + await runFrontendAssetVersionRegression(); assertFrontendGildedThemeContract(); assertFrontendWastelandThemeContract(); assertFrontendSidebarCollapseContract(); diff --git a/server.js b/server.js index 095c863..0b5924d 100644 --- a/server.js +++ b/server.js @@ -83,7 +83,8 @@ const CODEX_PATH = process.env.CODEX_PATH || 'codex'; const INTERNAL_MCP_TOKEN = process.env.CC_WEB_INTERNAL_MCP_TOKEN || crypto.randomBytes(32).toString('hex'); const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(APP_DIR, 'config'); const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(APP_DIR, 'sessions'); -const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(APP_DIR, 'public'); +const PUBLIC_DIR = path.resolve(process.env.CC_WEB_PUBLIC_DIR || path.join(APP_DIR, 'public')); +const PUBLIC_INDEX_PATH = path.join(PUBLIC_DIR, 'index.html'); const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(APP_DIR, 'logs'); const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments'); const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; @@ -113,6 +114,20 @@ const SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_ const HISTORY_PREFETCH_CHUNKS = readPositiveIntEnv('CC_WEB_HISTORY_PREFETCH_CHUNKS', 3, { min: 0, max: 20 }); const HISTORY_MAX_CHUNKS_PER_LOAD = readPositiveIntEnv('CC_WEB_HISTORY_MAX_CHUNKS_PER_LOAD', 8, { min: 1, max: 100 }); const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_BYTES', 2 * 1024 * 1024, { min: 128 * 1024 }); + +function computeFrontendAssetVersion() { + try { + const appSource = fs.readFileSync(path.join(PUBLIC_DIR, 'app.js')); + return crypto.createHash('sha256').update(appSource).digest('hex').slice(0, 16); + } catch { + return ''; + } +} + +function injectFrontendAssetVersion(indexHtml) { + const frontendAssetVersion = computeFrontendAssetVersion(); + return String(indexHtml || '').replaceAll('__CC_WEB_FRONTEND_ASSET_VERSION__', frontendAssetVersion); +} const CODEX_APP_STATE_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_LOAD_MAX_BYTES', 4 * 1024 * 1024, { min: CODEX_APP_STATE_MAX_BYTES }); const CODEX_APP_STATE_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 192 * 1024, { min: 4096 }); const CODEX_APP_STATE_MAP_VALUE_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', 16 * 1024, { min: 1024 }); @@ -6688,7 +6703,7 @@ const server = http.createServer((req, res) => { let filePath = path.join(PUBLIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname); filePath = path.resolve(filePath); - if (!filePath.startsWith(PUBLIC_DIR)) { + if (!isPathInside(PUBLIC_DIR, filePath)) { res.writeHead(403); return res.end('Forbidden'); } @@ -6699,11 +6714,14 @@ const server = http.createServer((req, res) => { return res.end('Not Found'); } const ext = path.extname(filePath); + const responseData = filePath === PUBLIC_INDEX_PATH + ? Buffer.from(injectFrontendAssetVersion(data), 'utf8') + : data; res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-store, max-age=0', }); - res.end(data); + res.end(responseData); }); }); @@ -6782,6 +6800,7 @@ wss.on('connection', (ws, req) => { success: true, token: authToken, mustChangePassword: !!authConfig.mustChange, + frontendAssetVersion: computeFrontendAssetVersion(), features: { usageStatistics: USAGE_STATISTICS_ENABLED }, }); sendSessionList(ws);