fix: 修复会话归组和文件预览编码

This commit is contained in:
shiyue
2026-08-07 10:54:08 +08:00
parent 3ddf21af85
commit 5ad2839b2c
21 changed files with 765 additions and 42 deletions

View File

@@ -1 +1 @@
subagent-plan-visibility-fix
file-preview-encoding

View 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` 存在其他会话的未提交修改,编码修复必须以小范围追加方式完成。

View 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`,均通过。

View 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 | 读取真实模板后精确替换 |

View 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`
- 现有预览读取用于避免大文件完整解析,修复不应退化成无上限读取整个文件。

View 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.` | ✓ |

View 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`,应同步增加上限或候选尝试限制。

View File

@@ -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`、字段跨截断边界、头尾拼接伪命中。
---

View 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":"核对修复是否覆盖真实故障链路"}

View 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":"使用已闭环的现场根因和实现边界"}

View 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`
- 代码注释使用简体中文,只解释必要的边界与原因。

View File

@@ -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`

View 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": {}
}

View 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":"逐条核验验收标准"}

View 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":"本任务需求与验收标准"}

View 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 与二进制拒绝。
## 非目标
- 不修改源文件编码。
- 不在前端增加手动编码选择器。
- 不引入新的生产依赖。

View 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": {}
}

View File

@@ -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();

View File

@@ -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
View File

@@ -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,