diff --git a/.planning/.active_plan b/.planning/.active_plan index 28e0a88..5f7cd2a 100644 --- a/.planning/.active_plan +++ b/.planning/.active_plan @@ -1 +1 @@ -subagent-plan-visibility-fix +file-preview-encoding diff --git a/.planning/file-preview-encoding/findings.md b/.planning/file-preview-encoding/findings.md new file mode 100644 index 0000000..18cee79 --- /dev/null +++ b/.planning/file-preview-encoding/findings.md @@ -0,0 +1,7 @@ +# 调研结论 + +- 前端 `openFileBrowserFile` 请求 `/api/fs/read` 后,将 `data.content` 原样写入 `textContent`,渲染层不会制造乱码。 +- 服务端 `handleFileSystemReadApi` 当前使用 `previewBuffer.toString('utf8')`,无效 UTF-8 字节会变成 U+FFFD。 +- 截图中的 `����` 是典型替换字符,且相邻 ASCII 正常,符合 GBK/GB18030 被当成 UTF-8 解码的特征。 +- 项目当前只依赖 `echarts` 与 `ws`。Node 的 WHATWG `TextDecoder` 原生支持 `gb18030`,可避免新增包依赖。 +- `server.js` 与 `scripts/regression.js` 存在其他会话的未提交修改,编码修复必须以小范围追加方式完成。 diff --git a/.planning/file-preview-encoding/progress.md b/.planning/file-preview-encoding/progress.md new file mode 100644 index 0000000..67ef5ee --- /dev/null +++ b/.planning/file-preview-encoding/progress.md @@ -0,0 +1,8 @@ +# 进度日志 + +- 2026-08-07:读取项目规范、Trellis 工作流和计划技能。 +- 2026-08-07:确认 codebase-memory 索引可用并定位文件预览链路。 +- 2026-08-07:确认根因是 `/api/fs/read` 固定按 UTF-8 解码。 +- 2026-08-07:实现代理完成多编码解码和回归测试。 +- 2026-08-07:实现代理与检查代理均通过专用回归、语法检查和 diff 检查。 +- 2026-08-07:主代理复跑 `file-preview-encoding` 与 `session-preview-metadata`,均通过。 diff --git a/.planning/file-preview-encoding/task_plan.md b/.planning/file-preview-encoding/task_plan.md new file mode 100644 index 0000000..9547f1e --- /dev/null +++ b/.planning/file-preview-encoding/task_plan.md @@ -0,0 +1,38 @@ +# 任务计划:中文文件预览编码兼容 + +## 目标 + +修复文件浏览器预览 GBK/GB2312/GB18030 等中文文本时出现替换字符的问题,并保持 UTF-8 与二进制拒绝行为稳定。 + +## 当前阶段 + +阶段 5:交付 + +## 阶段 + +- [x] 定位文件预览读取与渲染链路 +- [x] 补充非 UTF-8 中文文件回归测试 +- [x] 实现安全的多编码检测与解码 +- [x] 运行相关测试与静态检查 +- [x] 执行浏览器回归并复核变更 + +## 交付结果 + +- 服务端支持 UTF-8、UTF-8/UTF-16 BOM、GB18030(兼容 GBK/GB2312)。 +- `/api/fs/read` 返回 `encoding`,前端预览元信息展示识别结果。 +- 二进制 415、路径安全、预览大小限制保持不变。 +- Trellis 实现与检查代理均未发现明确缺陷。 + +## 关键决策 + +- 在服务端解码,API 继续返回合法 JSON/Unicode,前端无需猜测原始字节编码。 +- BOM 优先;无 BOM 时严格验证 UTF-8;无效 UTF-8 回退 GB18030。 +- 仅对已判定为文本的内容解码,不能放宽二进制文件预览限制。 +- API 返回实际识别编码,便于诊断,但前端不依赖该字段。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| codebase-memory `search_code` 返回 `pattern is required` | 1 | 查阅工具 schema 后改用 `pattern` | +| 计划补丁未命中模板标题 | 1 | 读取真实模板后精确替换 | diff --git a/.planning/fix-session-preview-cwd/findings.md b/.planning/fix-session-preview-cwd/findings.md new file mode 100644 index 0000000..47c816a --- /dev/null +++ b/.planning/fix-session-preview-cwd/findings.md @@ -0,0 +1,29 @@ +# 调研结论 + +## 用户可见现象 + +- 同一会话 `bf88c202-711a-4f2f-b6e3-b29062f37b20` 刷新后归入 `fineui-web-mcp`。 +- 点击后同一会话回到正确的 `上下游协同平台`。 +- 后续 `session_list` 推送会再次把它归入错误分组。 + +## 已证实根因 + +- 目标会话文件为 909402 bytes,超过默认 512KB 完整元数据解析阈值。 +- 大文件路径通过 `readSessionPreview()` 读取头尾片段。 +- `jsonStringFieldFromPreview()` 用正则匹配第一个同名键,不识别 JSON 层级。 +- 文件中首次出现的 `cwd` 位于消息工具调用 `fastctx.run` 的参数内,值为 `/mnt/e/我的项目/MCP/fineui-web-mcp`。 +- 真正的顶层 `cwd` 是 `/mnt/e/华电众信/2026/上下游协同平台`。 +- 刷新使用错误的预览元数据;点击使用完整 JSON,因此出现往返漂移。 + +## 代码边界 + +- 根因位置:`server.js` 的预览字段读取与 `loadSessionMetaFromFile()` 大文件分支。 +- 消费链路:`sendSessionList()` → 前端 `session_list` 整批替换 → `groupSessionsByProject()`。 +- 点击恢复链路:`handleLoadSession()` 完整解析 → `session_info` → `mergeSessionListSnapshot()`。 +- 前端按收到的数据正确渲染,不属于本次修改范围。 + +## 风险点 + +- 不能仅把顶层 `cwd` 移到 `messages` 前面,这依赖属性顺序且不能解决其他顶层字段误读。 +- 必须覆盖字符串转义、嵌套对象/数组、头尾拼接边界、缺失字段和 `null`。 +- 现有预览读取用于避免大文件完整解析,修复不应退化成无上限读取整个文件。 diff --git a/.planning/fix-session-preview-cwd/progress.md b/.planning/fix-session-preview-cwd/progress.md new file mode 100644 index 0000000..6147e0f --- /dev/null +++ b/.planning/fix-session-preview-cwd/progress.md @@ -0,0 +1,57 @@ +# 进度日志 + +## 2026-08-06 + +### 建立修复基线并确认大文件元数据解析边界 + +- 状态:完成 +- 工作树基线:干净,`main`,提交 `3ddf21a`。 +- 已确认用户现场的完整根因链路。 +- 已确认修改范围为 `server.js` 与回归测试,不修改前端。 +- 已建立 Trellis 任务 `.trellis/tasks/08-06-fix-session-preview-cwd`。 +- 计划文档经过独立审查;补齐头尾截断与拼接边界后获批。 + +### 编写失败回归覆盖嵌套 cwd 误判 + +- 状态:完成 +- 专项回归目标:`session-preview-metadata`。 +- 旧实现失败:返回嵌套 `/home/fineui-web-mcp`,未返回尾部顶层 `cwd`。 + +### 实现仅解析顶层会话元数据 + +- 状态:完成 +- `readSessionPreview()` 改为独立返回头、尾片段,避免拼接边界伪命中。 +- 新解析器从头部解析完整顶层前缀,并从尾部验证可独立组成根对象后缀的顶层字段。 + +### 运行专项测试并修正边界问题 + +- 状态:完成 +- 主线程复核 `session-preview-metadata` 专项回归通过。 +- `node --check server.js`、`node --check scripts/regression.js`、`git diff --check` 通过。 + +### 运行语法检查与完整回归 + +- 状态:完成 +- 完整回归在 60 秒上限内通过,输出 `Regression checks passed.`。 + +### 审查差异及兼容性风险 + +- 状态:完成 +- 独立 Trellis check 未发现阻塞级正确性、性能或兼容性问题。 +- 非阻塞风险:未来若大幅提高预览字节配置,尾部多候选解析成本会增加。 + +### 清理临时跟踪文件并完成交付 + +- 状态:完成 +- 最终专项回归、语法检查、Trellis 上下文校验和差异检查通过。 +- 临时 TODO CSV 已完成并自动清理。 + +## 测试结果 + +| 测试 | 预期 | 实际 | 状态 | +|---|---|---|---| +| 大会话预览专项 | 顶层 cwd 稳定、嵌套 cwd 不污染 | 通过 | ✓ | +| server.js 语法 | 无语法错误 | 通过 | ✓ | +| regression.js 语法 | 无语法错误 | 通过 | ✓ | +| git diff --check | 无空白错误 | 通过 | ✓ | +| 完整回归 | 全部回归通过 | `Regression checks passed.` | ✓ | diff --git a/.planning/fix-session-preview-cwd/task_plan.md b/.planning/fix-session-preview-cwd/task_plan.md new file mode 100644 index 0000000..3f62cc8 --- /dev/null +++ b/.planning/fix-session-preview-cwd/task_plan.md @@ -0,0 +1,45 @@ +# 任务计划:修复会话归组漂移 + +## 目标 + +修复大会话文件的预览元数据解析,确保侧栏列表只使用 JSON 顶层 `cwd`,不再被消息或工具调用中的嵌套 `cwd` 污染。 + +## 当前阶段 + +完成 + +## 步骤 + +1. [DONE] 建立修复基线并确认大文件元数据解析边界 +2. [DONE] 编写失败回归覆盖嵌套 cwd 误判 +3. [DONE] 实现仅解析顶层会话元数据 +4. [DONE] 运行专项测试并修正边界问题 +5. [DONE] 运行语法检查与完整回归 +6. [DONE] 审查差异及兼容性风险 +7. [DONE] 清理临时跟踪文件并完成交付 + +## 完成标准 + +- 大于 `CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES` 的会话文件,列表元数据仍读取顶层 `cwd`。 +- 消息、工具输入、工具结果中的嵌套 `cwd/projectName` 不影响会话归组。 +- 顶层 `cwd` 位于尾部预览、嵌套 `cwd` 位于头部预览时仍返回顶层值。 +- 顶层字段跨预览截断边界或头尾片段不构成完整 JSON 时安全降级,不把两个片段拼成伪字段。 +- 小文件完整解析路径保持原行为。 +- 回归测试先能复现旧实现失败,再验证修复通过。 +- `node scripts/regression.js --target session-preview-metadata`、`server.js` 语法检查、完整回归和 `git diff --check` 通过。 + +## 决策 + +- 只修服务端元数据读取根因,不在前端增加掩盖性缓存或版本逻辑。 +- 不依赖 JSON 属性顺序调整作为修复,因为后续对象构造可能再次改变顺序。 +- 解析异常沿用现有保守降级行为,不让单个坏会话中断整个列表。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 暂无 | 1 | — | + +## 已知非阻塞风险 + +- 尾部候选字段解析在默认 128KB 预览范围内风险可控;若未来大幅提高 `CC_WEB_SESSION_META_PREVIEW_BYTES`,应同步增加上限或候选尝试限制。 diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index c1e1065..eb8f1af 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -32,7 +32,11 @@ Questions to answer: -(To be filled by the team) +### 部分 JSON 预览 + +- 从大会话文件的头部/尾部预览提取元数据时,必须识别 JSON 顶层结构;禁止用“第一个同名键”的正则结果代表顶层字段。 +- 头部和尾部片段必须独立解析,禁止直接拼接后匹配,避免截断边界组合出文件中不存在的键值。 +- 预览读取必须保持明确的字节上限;解析失败时按字段缺失安全降级,不能让单个损坏会话中断整个列表。 --- @@ -40,7 +44,8 @@ Questions to answer: -(To be filled by the team) +- 部分 JSON 解析改动必须使用超过完整解析阈值的真实文件夹具,证明测试确实进入预览分支。 +- 回归至少覆盖:嵌套同名键早于顶层键、顶层键位于尾部、字符串伪键与转义、`null`、字段跨截断边界、头尾拼接伪命中。 --- diff --git a/.trellis/tasks/08-06-fix-session-preview-cwd/check.jsonl b/.trellis/tasks/08-06-fix-session-preview-cwd/check.jsonl new file mode 100644 index 0000000..a7f4e47 --- /dev/null +++ b/.trellis/tasks/08-06-fix-session-preview-cwd/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/index.md","reason":"按后端规范检查改动边界"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查代码质量、测试与兼容性"} +{"file":".trellis/tasks/08-06-fix-session-preview-cwd/research/root-cause.md","reason":"核对修复是否覆盖真实故障链路"} diff --git a/.trellis/tasks/08-06-fix-session-preview-cwd/implement.jsonl b/.trellis/tasks/08-06-fix-session-preview-cwd/implement.jsonl new file mode 100644 index 0000000..5d5b16e --- /dev/null +++ b/.trellis/tasks/08-06-fix-session-preview-cwd/implement.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/index.md","reason":"遵循后端修改入口与项目约束"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵循后端质量与测试要求"} +{"file":".trellis/tasks/08-06-fix-session-preview-cwd/research/root-cause.md","reason":"使用已闭环的现场根因和实现边界"} diff --git a/.trellis/tasks/08-06-fix-session-preview-cwd/prd.md b/.trellis/tasks/08-06-fix-session-preview-cwd/prd.md new file mode 100644 index 0000000..186d519 --- /dev/null +++ b/.trellis/tasks/08-06-fix-session-preview-cwd/prd.md @@ -0,0 +1,38 @@ +# 修复大会话预览 cwd 误读 + +## 背景 + +大会话文件超过 `CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES` 后,侧栏列表使用头尾预览和正则提取元数据。当前正则会命中消息或工具调用中的第一个嵌套 `cwd`,导致会话在刷新后归入错误项目;点击会话完整解析后又恢复,形成归组漂移。 + +## 目标 + +让大会话的列表元数据只读取 JSON 顶层字段,保证刷新、点击和后续状态推送的项目归属一致。 + +## 范围 + +- 修改 `server.js` 中大会话预览元数据读取逻辑。 +- 为嵌套 `cwd` 位于顶层 `cwd` 之前的场景增加自动化回归。 +- 保持小文件完整解析路径、会话加载路径和前端归组逻辑不变。 + +## 非目标 + +- 不修改项目分组 UI 或 CSS。 +- 不新增前端缓存、列表版本号或乱序抑制逻辑。 +- 不迁移、改写或清理现有会话文件。 +- 不通过调整 JSON 属性顺序掩盖问题。 + +## 验收标准 + +1. 大文件预览中即使消息工具参数先出现 `cwd=/home/fineui-web-mcp`,列表仍返回顶层 `cwd=/home/上下游协同平台`。 +2. 嵌套 `projectName`、字符串内容中的伪键和转义字符不污染顶层元数据。 +3. 顶层字段缺失或为 `null` 时维持现有安全降级语义。 +4. 顶层 `cwd` 位于尾部预览、嵌套 `cwd` 位于头部预览时仍返回顶层值。 +5. 顶层字段跨预览截断边界时安全降级;头尾片段不能被错误拼接为伪 JSON 字段。 +6. 不无上限读取完整大会话文件。 +7. `node scripts/regression.js --target session-preview-metadata`、完整回归、语法检查和差异检查通过。 + +## 约束 + +- 保持实现小而可审计,避免引入新的 JSON 解析依赖。 +- 异常处理不能让一个损坏会话阻断整个 `session_list`。 +- 代码注释使用简体中文,只解释必要的边界与原因。 diff --git a/.trellis/tasks/08-06-fix-session-preview-cwd/research/root-cause.md b/.trellis/tasks/08-06-fix-session-preview-cwd/research/root-cause.md new file mode 100644 index 0000000..fb29ee2 --- /dev/null +++ b/.trellis/tasks/08-06-fix-session-preview-cwd/research/root-cause.md @@ -0,0 +1,29 @@ +# 根因证据:大会话预览误读嵌套 cwd + +## 现场 + +- 会话 ID:`bf88c202-711a-4f2f-b6e3-b29062f37b20` +- 文件大小:909402 bytes +- 顶层 `cwd`:`/mnt/e/华电众信/2026/上下游协同平台` +- 预览误读 `cwd`:`/mnt/e/我的项目/MCP/fineui-web-mcp` +- 第一个错误键来自 `$.messages[28].toolCalls[39].input.arguments.cwd`。 + +## 时序 + +1. 刷新触发 `sendSessionList()`。 +2. `loadSessionMetaFromFile()` 因文件超过 512KB 进入预览分支。 +3. `jsonStringFieldFromPreview()` 以正则命中任意层级第一个 `cwd`。 +4. 前端 `session_list` 整批替换列表,显示错误项目。 +5. 点击后 `handleLoadSession()` 完整解析 JSON,发送正确顶层 `cwd`。 +6. 后续列表推送重复第 1—4 步,产生往返漂移。 + +## 推荐修复边界 + +只修服务端预览解析,让字段提取具备 JSON 顶层感知能力;不在前端掩盖错误数据。 + +## 必测预览边界 + +- 头部包含嵌套 `cwd`,尾部包含顶层正确 `cwd`。 +- 顶层字段跨头部或尾部预览截断边界时安全降级。 +- 头尾片段拼接处不能组合出不存在的顶层键值。 +- 专项回归目标:`session-preview-metadata`。 diff --git a/.trellis/tasks/08-06-fix-session-preview-cwd/task.json b/.trellis/tasks/08-06-fix-session-preview-cwd/task.json new file mode 100644 index 0000000..86bd8e1 --- /dev/null +++ b/.trellis/tasks/08-06-fix-session-preview-cwd/task.json @@ -0,0 +1,30 @@ +{ + "id": "fix-session-preview-cwd", + "name": "fix-session-preview-cwd", + "title": "修复大会话预览 cwd 误读", + "description": "", + "status": "completed", + "dev_type": null, + "scope": "server.js,scripts/regression.js", + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-06", + "completedAt": "2026-08-06T19:46:53+08:00", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server.js", + "scripts/regression.js", + ".trellis/spec/backend/quality-guidelines.md" + ], + "notes": "", + "meta": {} +} diff --git a/.trellis/tasks/08-07-file-preview-encoding/check.jsonl b/.trellis/tasks/08-07-file-preview-encoding/check.jsonl new file mode 100644 index 0000000..688855e --- /dev/null +++ b/.trellis/tasks/08-07-file-preview-encoding/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/index.md","reason":"后端检查入口规范"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"验证测试覆盖与质量"} +{"file":".trellis/spec/backend/error-handling.md","reason":"验证 API 错误行为"} +{"file":".trellis/tasks/08-07-file-preview-encoding/prd.md","reason":"逐条核验验收标准"} diff --git a/.trellis/tasks/08-07-file-preview-encoding/implement.jsonl b/.trellis/tasks/08-07-file-preview-encoding/implement.jsonl new file mode 100644 index 0000000..2489f31 --- /dev/null +++ b/.trellis/tasks/08-07-file-preview-encoding/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/index.md","reason":"后端开发入口规范"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"回归测试和代码质量要求"} +{"file":".trellis/spec/backend/error-handling.md","reason":"API 错误行为要求"} +{"file":".trellis/tasks/08-07-file-preview-encoding/prd.md","reason":"本任务需求与验收标准"} diff --git a/.trellis/tasks/08-07-file-preview-encoding/prd.md b/.trellis/tasks/08-07-file-preview-encoding/prd.md new file mode 100644 index 0000000..f31fcc6 --- /dev/null +++ b/.trellis/tasks/08-07-file-preview-encoding/prd.md @@ -0,0 +1,21 @@ +# 中文文件预览编码兼容 + +## 背景 + +文件浏览器预览部分历史 C# 源文件时,ASCII 内容正常但中文注释显示为 `�`。这些文件通常使用 GBK、GB2312 或 GB18030,而当前服务端固定按 UTF-8 解码。 + +## 验收标准 + +1. UTF-8(含中文)保持正确显示。 +2. UTF-8 BOM、UTF-16LE BOM、UTF-16BE BOM 能正确预览,且正文不显示 BOM。 +3. GBK/GB2312/GB18030 中文文本能正确预览。 +4. API 返回实际识别编码,便于定位异常文件。 +5. 二进制文件仍返回 415,不因编码回退被误判为文本。 +6. 预览大小限制和路径安全逻辑不变。 +7. 增加端到端回归测试,覆盖 UTF-8、GB18030 与二进制拒绝。 + +## 非目标 + +- 不修改源文件编码。 +- 不在前端增加手动编码选择器。 +- 不引入新的生产依赖。 diff --git a/.trellis/tasks/08-07-file-preview-encoding/task.json b/.trellis/tasks/08-07-file-preview-encoding/task.json new file mode 100644 index 0000000..20f2220 --- /dev/null +++ b/.trellis/tasks/08-07-file-preview-encoding/task.json @@ -0,0 +1,26 @@ +{ + "id": "file-preview-encoding", + "name": "file-preview-encoding", + "title": "兼容中文文件预览编码", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-07", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file 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 31d450c..72a15ca 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 612558a..b9b9ec8 100644 --- a/public/app.js +++ b/public/app.js @@ -3480,6 +3480,7 @@ syncFileBrowserSelection(); state.previewTitleEl.textContent = data.name || '文件预览'; const metaParts = [formatFileSize(data.size || 0)]; + if (data.encoding) metaParts.push(String(data.encoding).toUpperCase()); if (data.updatedAt) metaParts.push(timeAgo(data.updatedAt)); if (data.truncated) metaParts.push(`仅显示前 ${formatFileSize(data.previewBytes || 0)}`); if (targetLine) metaParts.push(`第 ${targetLine} 行`); @@ -5765,7 +5766,9 @@ currentModel = snapshot.model || ''; if (!preserveStreaming) { renderMessages(snapshot.messages || [], { - immediate: !!options.immediate, + // 历史分片加载期间必须一次性渲染当前快照;异步倒序批次会与 + // session_history_chunk 的 prepend 并发,造成消息视觉顺序错乱。 + immediate: !!options.immediate || !!snapshot.historyPending, baseIndex: snapshot.historyBaseIndex || 0, }); scheduleAdvancedSearchJump(); diff --git a/scripts/regression.js b/scripts/regression.js index f17a996..eed2677 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -5003,6 +5003,180 @@ async function runAdvancedSessionSearchRegression() { }); } +function makeLargeSessionPreviewFixture(sessionId, tailFields = {}) { + return JSON.stringify({ + messages: [ + { + role: 'assistant', + content: 'Preview text with escaped pseudo keys: "cwd": "/tmp/string-cwd", "title": "String Title" and a backslash \\ marker.', + toolCalls: [ + { + name: 'mcp__preview__cwd', + input: { + arguments: { + cwd: '/home/fineui-web-mcp', + projectName: 'fineui-web-mcp', + }, + }, + done: true, + }, + ], + }, + ], + padding: 'x'.repeat(70 * 1024), + id: sessionId, + ...tailFields, + }); +} + +function makeHeadTailJoinRiskFixture(sessionId, previewBytes) { + const beforePadding = `{"id":${JSON.stringify(sessionId)},"title":"Boundary Join Risk","updated":"2026-08-06T00:02:00.000Z","agent":"codexapp","padding":"`; + const beforeCwd = '","cwd":'; + const targetHeadBytes = previewBytes - Buffer.byteLength(beforeCwd); + const paddingBytes = targetHeadBytes - Buffer.byteLength(beforePadding); + assert(paddingBytes > 0, 'Boundary fixture should fit the preview head'); + const head = `${beforePadding}${'x'.repeat(paddingBytes)}${beforeCwd}`; + assert(Buffer.byteLength(head) === previewBytes, 'Boundary fixture should end the head preview after cwd colon'); + return `${head}${' '.repeat(70 * 1024)}"/tmp/joined-preview-cwd","messages":[]}`; +} + +async function runSessionPreviewMetadataRegression() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-session-preview-metadata-')); + const configDir = path.join(tempRoot, 'config'); + const sessionsDir = path.join(tempRoot, 'sessions'); + const logsDir = path.join(tempRoot, 'logs'); + const homeDir = path.join(tempRoot, 'home'); + mkdirp(configDir); + mkdirp(sessionsDir); + mkdirp(logsDir); + mkdirp(homeDir); + + const fullParseBytes = 64 * 1024; + const previewBytes = 16 * 1024; + const tailCwd = path.join(homeDir, '上下游协同平台'); + const tailSessionId = 'preview-tail-top-level'; + const nullSessionId = 'preview-null-fields'; + const boundarySessionId = 'preview-boundary-join-risk'; + const tailSessionPath = path.join(sessionsDir, `${tailSessionId}.json`); + const nullSessionPath = path.join(sessionsDir, `${nullSessionId}.json`); + const boundarySessionPath = path.join(sessionsDir, `${boundarySessionId}.json`); + + fs.writeFileSync(tailSessionPath, makeLargeSessionPreviewFixture(tailSessionId, { + title: 'Escaped "Top" \\\\ Title', + updated: '2026-08-06T00:00:00.000Z', + created: '2026-08-05T23:00:00.000Z', + pinnedAt: null, + titleSource: 'llm', + createdFrom: { kind: 'ccweb_prompt_user' }, + hasUnread: true, + agent: 'codexapp', + cwd: tailCwd, + })); + fs.writeFileSync(nullSessionPath, makeLargeSessionPreviewFixture(nullSessionId, { + title: null, + updated: '2026-08-06T00:01:00.000Z', + created: null, + pinnedAt: null, + titleSource: null, + createdFrom: { kind: null }, + hasUnread: false, + agent: null, + cwd: null, + })); + fs.writeFileSync( + boundarySessionPath, + makeHeadTailJoinRiskFixture(boundarySessionId, previewBytes) + ); + assert(fs.statSync(tailSessionPath).size > fullParseBytes, 'Tail fixture must use oversized preview parsing'); + assert(fs.statSync(nullSessionPath).size > fullParseBytes, 'Null fixture must use oversized preview parsing'); + assert(fs.statSync(boundarySessionPath).size > fullParseBytes, 'Boundary fixture must use oversized preview parsing'); + + const port = await getFreePort(); + const password = 'SessionPreview!234'; + await withServer({ + PORT: String(port), + CC_WEB_PASSWORD: password, + CC_WEB_INTERNAL_MCP_TOKEN: 'SessionPreviewMcp!234', + CC_WEB_CONFIG_DIR: configDir, + CC_WEB_SESSIONS_DIR: sessionsDir, + CC_WEB_LOGS_DIR: logsDir, + CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES: String(fullParseBytes), + CC_WEB_SESSION_META_PREVIEW_BYTES: String(previewBytes), + HOME: homeDir, + CLAUDE_PATH: MOCK_CLAUDE, + CODEX_PATH: MOCK_CODEX_APP_SERVER, + }, async () => { + const { ws, messages } = await connectWs(port, password); + const list = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + [tailSessionId, nullSessionId, boundarySessionId].every((id) => ( + msg.sessions.some((session) => session.id === id) + )) + ), 5000); + const sessionById = new Map(list.sessions.map((session) => [session.id, session])); + const tailSession = sessionById.get(tailSessionId); + const nullSession = sessionById.get(nullSessionId); + const boundarySession = sessionById.get(boundarySessionId); + + assert(tailSession.cwd === tailCwd, `Oversized preview should read tail top-level cwd, got ${JSON.stringify(tailSession.cwd)}`); + assert(tailSession.projectName === path.basename(tailCwd), `Project name should derive from top-level cwd, got ${JSON.stringify(tailSession.projectName)}`); + assert(tailSession.title === 'Escaped "Top" \\\\ Title', `Top-level escaped title should be preserved, got ${JSON.stringify(tailSession.title)}`); + assert(tailSession.createdFromKind === 'ccweb_prompt_user', 'Top-level createdFrom.kind should be read from preview metadata'); + assert(tailSession.hasUnread === true, 'Top-level hasUnread should be read from preview metadata'); + + assert(nullSession.title === 'Untitled', `Null top-level title should fall back safely, got ${JSON.stringify(nullSession.title)}`); + assert(nullSession.cwd === '', `Null top-level cwd should not use nested cwd, got ${JSON.stringify(nullSession.cwd)}`); + assert(nullSession.projectName === '', `Null top-level cwd should keep projectName empty, got ${JSON.stringify(nullSession.projectName)}`); + assert(nullSession.createdFromKind === null, 'Null nested top-level kind should fall back to null'); + assert(nullSession.hasUnread === false, 'False top-level hasUnread should be preserved'); + + assert(boundarySession.cwd === '', `Preview parser should not join head cwd key with tail value, got ${JSON.stringify(boundarySession.cwd)}`); + assert(boundarySession.projectName === '', `Boundary preview should not derive a projectName from a joined value, got ${JSON.stringify(boundarySession.projectName)}`); + ws.close(); + }); +} + +async function runFilePreviewEncodingRegression() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-file-preview-encoding-')); + const configDir = path.join(tempRoot, 'config'); + const sessionsDir = path.join(tempRoot, 'sessions'); + const logsDir = path.join(tempRoot, 'logs'); + const workspace = path.join(tempRoot, '中文工作区'); + mkdirp(configDir); mkdirp(sessionsDir); mkdirp(logsDir); mkdirp(workspace); + const text = '中文注释:文件预览编码兼容'; + fs.writeFileSync(path.join(workspace, 'utf8.cs'), text, 'utf8'); + fs.writeFileSync(path.join(workspace, 'utf8-bom.cs'), Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(text)])); + fs.writeFileSync(path.join(workspace, 'utf16le.cs'), Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, 'utf16le')])); + fs.writeFileSync(path.join(workspace, 'utf16be.cs'), Buffer.concat([Buffer.from([0xfe, 0xff]), Buffer.from(text, 'utf16le').swap16()])); + fs.writeFileSync(path.join(workspace, 'gb18030.cs'), Buffer.from('d6d0cec4d7a2cacda3bacec4bcfed4a4c0c0b1e0c2ebbce6c8dd', 'hex')); + const previewLimit = 200 * 1024; + fs.writeFileSync(path.join(workspace, 'utf8-truncated.cs'), Buffer.concat([Buffer.alloc(previewLimit - 1, 0x61), Buffer.from('中尾')])); + fs.writeFileSync(path.join(workspace, 'gb18030-truncated.cs'), Buffer.concat([Buffer.alloc(previewLimit - 3, 0x61), Buffer.from('d6d0d600', 'hex')])); + fs.writeFileSync(path.join(workspace, 'binary.bin'), Buffer.from([0x00, 0xff, 0x01, 0x80, 0x00])); + const sessionId = 'file-preview-encoding-session'; + fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({ id: sessionId, cwd: workspace, title: '编码回归', messages: [] })); + const port = await getFreePort(); + const password = 'FilePreviewEncoding!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, HOME: tempRoot, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER }, async () => { + const { ws, messages, token } = await connectWs(port, password); + await nextMessage(messages, ws, (msg) => msg.type === 'session_list'); + for (const filename of ['utf8.cs', 'utf8-bom.cs', 'utf16le.cs', 'utf16be.cs', 'gb18030.cs']) { + const payload = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=${encodeURIComponent(filename)}`); + assert(payload.content === text, `${filename} should decode Chinese text correctly, got ${JSON.stringify(payload.content)}`); + assert(typeof payload.encoding === 'string' && payload.encoding.length > 0, `${filename} should report detected encoding`); + } + for (const [filename, encoding] of [['utf8-truncated.cs', 'utf-8'], ['gb18030-truncated.cs', 'gb18030']]) { + const payload = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=${filename}`); + assert(payload.truncated === true, `${filename} should exercise the preview-size truncation branch`); + assert(payload.encoding === encoding, `${filename} should retain ${encoding} detection across a split multibyte character`); + assert(!payload.content.includes('\ufffd'), `${filename} should omit an incomplete trailing character instead of rendering replacement text`); + } + const binaryResponse = await fetch(`http://127.0.0.1:${port}/api/fs/read?sessionId=${sessionId}&path=binary.bin`, { headers: { Authorization: `Bearer ${token}` } }); + assert(binaryResponse.status === 415, `Binary preview should remain 415, got ${binaryResponse.status}`); + ws.close(); + }); +} + function assertWindowsStartupContract() { const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n'); @@ -5120,6 +5294,16 @@ async function main() { console.log('Advanced session search regression checks passed.'); return; } + if (regressionTarget === 'session-preview-metadata') { + await runSessionPreviewMetadataRegression(); + console.log('Session preview metadata regression checks passed.'); + return; + } + if (regressionTarget === 'file-preview-encoding') { + await runFilePreviewEncodingRegression(); + console.log('File preview encoding regression checks passed.'); + return; + } if (regressionTarget === 'usage-statistics') { assertUsageStatisticsUnitChecks(); assertUsageStatisticsContract(); @@ -5174,6 +5358,7 @@ async function main() { assertMultiAgentV2CompatibilityContract(); assertWindowsStartupContract(); assertCcwebDisplayImageContract(); + await runSessionPreviewMetadataRegression(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); diff --git a/server.js b/server.js index 606a318..095c863 100644 --- a/server.js +++ b/server.js @@ -1790,6 +1790,36 @@ function readFilePreviewBuffer(filePath, maxBytes) { } } +function decodeTextPreview(buffer, options = {}) { + const truncated = options.truncated === true; + const decode = (encoding, bytes) => { + try { + return new TextDecoder(encoding, { fatal: true }).decode(bytes, { stream: truncated }); + } catch { + return null; + } + }; + + if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { + const content = decode('utf-8', buffer.subarray(3)); + if (content !== null) return { encoding: 'utf-8-bom', content }; + } + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { + const content = decode('utf-16le', buffer.subarray(2)); + if (content !== null) return { encoding: 'utf-16le', content }; + } + if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { + const content = decode('utf-16be', buffer.subarray(2)); + if (content !== null) return { encoding: 'utf-16be', content }; + } + + const utf8 = decode('utf-8', buffer); + if (utf8 !== null) return { encoding: 'utf-8', content: utf8 }; + const gb18030 = decode('gb18030', buffer); + if (gb18030 !== null) return { encoding: 'gb18030', content: gb18030 }; + return { encoding: 'gb18030', content: new TextDecoder('gb18030').decode(buffer) }; +} + const COMPOSER_COMMANDS = [ { name: '/clear', description: '清除当前会话', insertion: '/clear ' }, { name: '/model', description: '查看/切换模型', insertion: '/model ' }, @@ -2876,6 +2906,9 @@ function handleFileSystemReadApi(req, res, url) { return jsonResponse(res, 415, { ok: false, message: '当前仅支持预览简单文本文件' }); } + const truncated = stat.size > FILE_BROWSER_MAX_PREVIEW_BYTES; + const decodedPreview = decodeTextPreview(previewBuffer, { truncated }); + return jsonResponse(res, 200, { ok: true, sessionId, @@ -2884,9 +2917,10 @@ function handleFileSystemReadApi(req, res, url) { name: path.basename(target.realPath), size: stat.size, updatedAt: stat.mtime.toISOString(), - truncated: stat.size > FILE_BROWSER_MAX_PREVIEW_BYTES, + truncated, previewBytes: previewBuffer.length, - content: previewBuffer.toString('utf8'), + encoding: decodedPreview.encoding, + content: decodedPreview.content, }); } @@ -3818,34 +3852,187 @@ function safeReadSessionJson(filePath, maxBytes, context = {}) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } -function jsonStringFieldFromPreview(text, key) { - const pattern = new RegExp(`"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`); - const match = pattern.exec(text); - if (!match) return null; - if (match[1] === 'null') return null; - try { - return JSON.parse(match[1]); - } catch { - return null; - } +const SESSION_META_PREVIEW_TOP_LEVEL_KEYS = new Set([ + 'id', + 'title', + 'updated', + 'updatedAt', + 'created', + 'pinnedAt', + 'titleSource', + 'createdFrom', + 'hasUnread', + 'agent', + 'cwd', +]); + +function isJsonWhitespace(ch) { + return ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t'; } -function jsonNestedStringFieldFromPreview(text, objectKey, key) { - const pattern = new RegExp(`"${objectKey}"\\s*:\\s*\\{[\\s\\S]{0,1200}?"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`); - const match = pattern.exec(text); - if (!match) return null; - if (match[1] === 'null') return null; - try { - return JSON.parse(match[1]); - } catch { - return null; - } +function skipJsonWhitespace(text, index) { + let i = index; + while (i < text.length && isJsonWhitespace(text[i])) i += 1; + return i; } -function jsonBooleanFieldFromPreview(text, key) { - const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`); - const match = pattern.exec(text); - return match ? match[1] === 'true' : false; +function readJsonStringEnd(text, start) { + if (text[start] !== '"') return -1; + let escaped = false; + for (let i = start + 1; i < text.length; i += 1) { + const ch = text[i]; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === '"') return i + 1; + } + return -1; +} + +function readJsonContainerEnd(text, start) { + const first = text[start]; + if (first !== '{' && first !== '[') return -1; + const stack = [first]; + let inString = false; + let escaped = false; + for (let i = start + 1; i < text.length; i += 1) { + const ch = text[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{' || ch === '[') { + stack.push(ch); + continue; + } + if (ch === '}' || ch === ']') { + const open = stack.pop(); + if ((ch === '}' && open !== '{') || (ch === ']' && open !== '[')) return -1; + if (stack.length === 0) return i + 1; + } + } + return -1; +} + +function readJsonValueEnd(text, start) { + const i = skipJsonWhitespace(text, start); + const ch = text[i]; + if (!ch) return -1; + if (ch === '"') return readJsonStringEnd(text, i); + if (ch === '{' || ch === '[') return readJsonContainerEnd(text, i); + if (text.startsWith('true', i)) return i + 4; + if (text.startsWith('false', i)) return i + 5; + if (text.startsWith('null', i)) return i + 4; + const numberMatch = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i)); + return numberMatch ? i + numberMatch[0].length : -1; +} + +function parseJsonTopLevelPrefixFields(text) { + const fields = {}; + let i = skipJsonWhitespace(text, 0); + if (text[i] !== '{') return fields; + i += 1; + while (i < text.length) { + i = skipJsonWhitespace(text, i); + if (text[i] === '}') break; + if (text[i] !== '"') break; + const keyStart = i; + const keyEnd = readJsonStringEnd(text, keyStart); + if (keyEnd < 0) break; + let key = ''; + try { + key = JSON.parse(text.slice(keyStart, keyEnd)); + } catch { + break; + } + i = skipJsonWhitespace(text, keyEnd); + if (text[i] !== ':') break; + const valueStart = skipJsonWhitespace(text, i + 1); + const valueEnd = readJsonValueEnd(text, valueStart); + if (valueEnd < 0) break; + if (SESSION_META_PREVIEW_TOP_LEVEL_KEYS.has(key)) { + try { + fields[key] = JSON.parse(text.slice(valueStart, valueEnd)); + } catch {} + } + i = skipJsonWhitespace(text, valueEnd); + if (text[i] === ',') { + i += 1; + continue; + } + if (text[i] === '}') break; + break; + } + return fields; +} + +function previousNonWhitespaceChar(text, index) { + for (let i = index - 1; i >= 0; i -= 1) { + if (!isJsonWhitespace(text[i])) return text[i]; + } + return ''; +} + +function parseJsonTopLevelSuffixFields(text) { + let end = text.length; + while (end > 0 && isJsonWhitespace(text[end - 1])) end -= 1; + if (text[end - 1] !== '}') return {}; + const content = text.slice(0, end - 1); + const keyPattern = /"(id|title|updated|updatedAt|created|pinnedAt|titleSource|createdFrom|hasUnread|agent|cwd)"\s*:/g; + let match; + while ((match = keyPattern.exec(content))) { + const start = match.index; + const prev = previousNonWhitespaceChar(content, start); + if (prev && prev !== ',' && prev !== '{') continue; + try { + const parsed = JSON.parse(`{${content.slice(start)}}`); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + const fields = {}; + for (const key of SESSION_META_PREVIEW_TOP_LEVEL_KEYS) { + if (Object.prototype.hasOwnProperty.call(parsed, key)) fields[key] = parsed[key]; + } + return fields; + } catch {} + } + return {}; +} + +function readSessionPreviewTopLevelFields(preview) { + // 头尾分段独立解析,避免把截断边界两侧拼成不存在的 JSON 字段。 + return { + ...parseJsonTopLevelPrefixFields(preview.head || ''), + ...parseJsonTopLevelSuffixFields(preview.tail || ''), + }; +} + +function previewStringField(fields, key) { + return typeof fields?.[key] === 'string' ? fields[key] : null; +} + +function previewBooleanField(fields, key) { + return typeof fields?.[key] === 'boolean' ? fields[key] : false; +} + +function previewCreatedFromKind(fields) { + const createdFrom = fields?.createdFrom; + return createdFrom && typeof createdFrom === 'object' && typeof createdFrom.kind === 'string' + ? createdFrom.kind + : null; } function readSessionPreview(filePath, stat) { @@ -3855,10 +4042,10 @@ function readSessionPreview(filePath, stat) { try { const head = Buffer.alloc(headSize); fs.readSync(fd, head, 0, headSize, 0); - if (stat.size <= headSize) return head.toString('utf8'); + if (stat.size <= headSize) return { head: head.toString('utf8'), tail: '' }; const tail = Buffer.alloc(tailSize); fs.readSync(fd, tail, 0, tailSize, Math.max(0, stat.size - tailSize)); - return `${head.toString('utf8')}\n${tail.toString('utf8')}`; + return { head: head.toString('utf8'), tail: tail.toString('utf8') }; } finally { fs.closeSync(fd); } @@ -3889,17 +4076,18 @@ function loadSessionMetaFromFile(filePath) { } const preview = readSessionPreview(filePath, stat); - const cwd = jsonStringFieldFromPreview(preview, 'cwd') || ''; + const previewFields = readSessionPreviewTopLevelFields(preview); + const cwd = previewStringField(previewFields, 'cwd') || ''; return { - id: jsonStringFieldFromPreview(preview, 'id') || fallbackId, - title: jsonStringFieldFromPreview(preview, 'title') || 'Untitled', - updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(), - created: jsonStringFieldFromPreview(preview, 'created') || null, - pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null, - titleSource: jsonStringFieldFromPreview(preview, 'titleSource') || null, - createdFromKind: jsonNestedStringFieldFromPreview(preview, 'createdFrom', 'kind') || null, - hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'), - agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')), + id: previewStringField(previewFields, 'id') || fallbackId, + title: previewStringField(previewFields, 'title') || 'Untitled', + updated: previewStringField(previewFields, 'updated') || previewStringField(previewFields, 'updatedAt') || stat.mtime.toISOString(), + created: previewStringField(previewFields, 'created') || null, + pinnedAt: previewStringField(previewFields, 'pinnedAt') || null, + titleSource: previewStringField(previewFields, 'titleSource') || null, + createdFromKind: previewCreatedFromKind(previewFields), + hasUnread: previewBooleanField(previewFields, 'hasUnread'), + agent: normalizeAgent(previewStringField(previewFields, 'agent')), cwd, projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '', fileBytes: stat.size,