fix: 修复会话归组和文件预览编码
This commit is contained in:
@@ -1 +1 @@
|
||||
subagent-plan-visibility-fix
|
||||
file-preview-encoding
|
||||
|
||||
7
.planning/file-preview-encoding/findings.md
Normal file
7
.planning/file-preview-encoding/findings.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 调研结论
|
||||
|
||||
- 前端 `openFileBrowserFile` 请求 `/api/fs/read` 后,将 `data.content` 原样写入 `textContent`,渲染层不会制造乱码。
|
||||
- 服务端 `handleFileSystemReadApi` 当前使用 `previewBuffer.toString('utf8')`,无效 UTF-8 字节会变成 U+FFFD。
|
||||
- 截图中的 `<60><><EFBFBD><EFBFBD>` 是典型替换字符,且相邻 ASCII 正常,符合 GBK/GB18030 被当成 UTF-8 解码的特征。
|
||||
- 项目当前只依赖 `echarts` 与 `ws`。Node 的 WHATWG `TextDecoder` 原生支持 `gb18030`,可避免新增包依赖。
|
||||
- `server.js` 与 `scripts/regression.js` 存在其他会话的未提交修改,编码修复必须以小范围追加方式完成。
|
||||
8
.planning/file-preview-encoding/progress.md
Normal file
8
.planning/file-preview-encoding/progress.md
Normal file
@@ -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`,均通过。
|
||||
38
.planning/file-preview-encoding/task_plan.md
Normal file
38
.planning/file-preview-encoding/task_plan.md
Normal file
@@ -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 | 读取真实模板后精确替换 |
|
||||
29
.planning/fix-session-preview-cwd/findings.md
Normal file
29
.planning/fix-session-preview-cwd/findings.md
Normal file
@@ -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`。
|
||||
- 现有预览读取用于避免大文件完整解析,修复不应退化成无上限读取整个文件。
|
||||
57
.planning/fix-session-preview-cwd/progress.md
Normal file
57
.planning/fix-session-preview-cwd/progress.md
Normal file
@@ -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.` | ✓ |
|
||||
45
.planning/fix-session-preview-cwd/task_plan.md
Normal file
45
.planning/fix-session-preview-cwd/task_plan.md
Normal file
@@ -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`,应同步增加上限或候选尝试限制。
|
||||
@@ -32,7 +32,11 @@ Questions to answer:
|
||||
|
||||
<!-- Patterns that must always be used -->
|
||||
|
||||
(To be filled by the team)
|
||||
### 部分 JSON 预览
|
||||
|
||||
- 从大会话文件的头部/尾部预览提取元数据时,必须识别 JSON 顶层结构;禁止用“第一个同名键”的正则结果代表顶层字段。
|
||||
- 头部和尾部片段必须独立解析,禁止直接拼接后匹配,避免截断边界组合出文件中不存在的键值。
|
||||
- 预览读取必须保持明确的字节上限;解析失败时按字段缺失安全降级,不能让单个损坏会话中断整个列表。
|
||||
|
||||
---
|
||||
|
||||
@@ -40,7 +44,8 @@ Questions to answer:
|
||||
|
||||
<!-- What level of testing is expected -->
|
||||
|
||||
(To be filled by the team)
|
||||
- 部分 JSON 解析改动必须使用超过完整解析阈值的真实文件夹具,证明测试确实进入预览分支。
|
||||
- 回归至少覆盖:嵌套同名键早于顶层键、顶层键位于尾部、字符串伪键与转义、`null`、字段跨截断边界、头尾拼接伪命中。
|
||||
|
||||
---
|
||||
|
||||
|
||||
3
.trellis/tasks/08-06-fix-session-preview-cwd/check.jsonl
Normal file
3
.trellis/tasks/08-06-fix-session-preview-cwd/check.jsonl
Normal file
@@ -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":"核对修复是否覆盖真实故障链路"}
|
||||
@@ -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":"使用已闭环的现场根因和实现边界"}
|
||||
38
.trellis/tasks/08-06-fix-session-preview-cwd/prd.md
Normal file
38
.trellis/tasks/08-06-fix-session-preview-cwd/prd.md
Normal file
@@ -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`。
|
||||
- 代码注释使用简体中文,只解释必要的边界与原因。
|
||||
@@ -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`。
|
||||
30
.trellis/tasks/08-06-fix-session-preview-cwd/task.json
Normal file
30
.trellis/tasks/08-06-fix-session-preview-cwd/task.json
Normal file
@@ -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": {}
|
||||
}
|
||||
4
.trellis/tasks/08-07-file-preview-encoding/check.jsonl
Normal file
4
.trellis/tasks/08-07-file-preview-encoding/check.jsonl
Normal file
@@ -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":"逐条核验验收标准"}
|
||||
@@ -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":"本任务需求与验收标准"}
|
||||
21
.trellis/tasks/08-07-file-preview-encoding/prd.md
Normal file
21
.trellis/tasks/08-07-file-preview-encoding/prd.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 中文文件预览编码兼容
|
||||
|
||||
## 背景
|
||||
|
||||
文件浏览器预览部分历史 C# 源文件时,ASCII 内容正常但中文注释显示为 `<60>`。这些文件通常使用 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 与二进制拒绝。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不修改源文件编码。
|
||||
- 不在前端增加手动编码选择器。
|
||||
- 不引入新的生产依赖。
|
||||
26
.trellis/tasks/08-07-file-preview-encoding/task.json
Normal file
26
.trellis/tasks/08-07-file-preview-encoding/task.json
Normal file
@@ -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": {}
|
||||
}
|
||||
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
264
server.js
264
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,
|
||||
|
||||
Reference in New Issue
Block a user