feat: add conversation search and usage dashboard
This commit is contained in:
94
.planning/advanced-conversation-search/findings.md
Normal file
94
.planning/advanced-conversation-search/findings.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 高级会话检索发现与决策
|
||||
|
||||
## 用户边界
|
||||
|
||||
- 现有检索必须保留,不改输入行为和本地标题/项目/路径/ID 过滤。
|
||||
- 在现有检索框旁增加高级检索按钮。
|
||||
- 高级检索参照用户提供的暗色结果页,结合前一轮正文索引分析落地。
|
||||
|
||||
## 视觉论点
|
||||
|
||||
在不打扰现有侧栏检索的前提下,打开一层沉静、近乎无边框的检索工作台,用青蓝标题、暖黄命中标记和克制分隔线建立快速扫读节奏。
|
||||
|
||||
## 内容计划
|
||||
|
||||
1. 顶部:返回按钮、长查询输入、清空按钮和回车提示。
|
||||
2. 控制:相关优先/最新优先,整词/包含两组切换。
|
||||
3. 状态:结果数、耗时、索引构建/错误状态。
|
||||
4. 结果:会话标题、项目/角色来源、最多两段摘要、右对齐时间和细分隔线。
|
||||
5. 空态:未输入、无结果、索引构建中和查询失败。
|
||||
|
||||
## 交互论点
|
||||
|
||||
- 入口点击后使用 140ms 淡入和轻微上移,形成从侧栏到检索工作台的空间切换。
|
||||
- 查询采用约 200ms debounce;旧响应由 requestId 丢弃,结果更新只做轻微透明度过渡。
|
||||
- 点击摘要打开原会话,必要时补载历史页,定位后做一次短暂暖色高亮。
|
||||
|
||||
## 参考图量化
|
||||
|
||||
- 源图:733 × 673 RGBA PNG。
|
||||
- 顶栏约 58px;左右内容安全区约 46–48px。
|
||||
- 控制行位于顶栏下约 22px,采用低对比胶囊,不使用独立卡片。
|
||||
- 状态行与第一条结果间距约 18px;结果之间使用 1px 低对比分隔线。
|
||||
- 标题使用高饱和青蓝,正文接近中性白,命中使用暖黄底深色字,时间靠右且弱化。
|
||||
- 面板是单一连续表面,不叠加卡片阴影;参考图中的圆角仅属于外围窗口,不复制成结果卡片。
|
||||
|
||||
## 参考区域映射
|
||||
|
||||
| 参考区域 | 目标 DOM | CSS 责任 | 资产 |
|
||||
|---|---|---|---|
|
||||
| 顶部搜索栏 | `.advanced-search-header` | 高度、输入基线、返回/清空按钮 | 无 |
|
||||
| 排序/匹配切换 | `.advanced-search-toolbar` | 分段控件、选中态 | 无 |
|
||||
| 结果统计 | `#advanced-search-status` | 弱化状态文案 | 无 |
|
||||
| 结果列表 | `#advanced-search-results` | 连续流、分隔线、滚动 | 无 |
|
||||
| 命中标题/摘要 | `.result-title/.result-snippet` | 青蓝标题、暖黄 mark、截断 | 无 |
|
||||
| 侧栏入口 | `#advanced-search-open` | 紧邻现有输入、独立 tooltip | 无 |
|
||||
|
||||
## 资产台账
|
||||
|
||||
- 原始附件:`sessions/_attachments/237582ef-4157-4175-84fa-38629613df7f.png`
|
||||
- 归档:`.trellis/tasks/08-03-advanced-conversation-search/references/source-assets/advanced-search-reference.png`
|
||||
- SHA-256:`39e5b93d26deca64ea88a46b09bed7f7c47d2af733e8f2f857a9d35fc7895692`
|
||||
- 用途:仅作为视觉对照,不进入产品运行时。
|
||||
|
||||
## 已确认技术基线
|
||||
|
||||
- 当前 `session_list` 只携带元数据,完整消息按会话加载。
|
||||
- 当前可检索 user/assistant 正文约 4.26 MB,内存全量子串扫描约 0.98ms。
|
||||
- 项目运行于 Node 18,发布使用 Bun baseline 单文件;首期采用零依赖内存索引和派生缓存。
|
||||
|
||||
## 代码接入基线
|
||||
|
||||
- `codebase-memory-mcp` 的 `home-cc-web` 索引 ready(4417 节点、9212 边)。
|
||||
- 服务端模块统一使用 CommonJS 工厂函数,可新增 `lib/session-search-index.js` 并在 `server.js` 顶部 require。
|
||||
- WebSocket 消息 switch 在 `load_session/load_history_page/delete_session/rename_session` 附近,新增 `search_sessions` 可保持普通会话列表协议不变。
|
||||
- 前端 `handleServerMessage` 已集中处理 `session_info/session_history_chunk`,高级检索结果可新增独立 case;`openSession(sessionId, options)` 已支持 options,适合作为命中定位入口。
|
||||
- `markSessionMessageElement` 已给消息 DOM 写入 `data-message-index`;历史分页响应包含 `historyBaseIndex`,可以稳定定位旧消息。
|
||||
- `saveSession` 有大量调用者,索引更新必须按 sessionId debounce 并在成功写盘后触发,避免频繁同步重建。
|
||||
|
||||
## 跨层数据流
|
||||
|
||||
```text
|
||||
sessions/*.json
|
||||
→ SessionSearchIndex 规范化 user/assistant 文本
|
||||
→ 内存文档集合 + sessions/_search/index-v1.json 派生缓存
|
||||
→ search_sessions 请求验证
|
||||
→ session_search_results 受限摘要
|
||||
→ 高级检索结果流
|
||||
→ openSession + load_session(targetMessageIndex)
|
||||
→ data-message-index 定位与高亮
|
||||
```
|
||||
|
||||
- 服务端入口负责 query、limit、sort、matchMode 校验;前端只发送枚举值并丢弃过期 requestId。
|
||||
- 原始会话文件是唯一事实来源;缓存损坏只能降级/重建,不能反向覆盖会话。
|
||||
- Trellis backend/frontend 细则当前仍为占位模板,本次以现有 CommonJS、集中消息 switch、原生 DOM/CSS 和回归脚本的实际约定为准。
|
||||
|
||||
## 并行审计结论
|
||||
|
||||
- 高级检索面板放在 `.chat-main` 内并使用局部 absolute overlay;桌面保留侧栏,移动端点击入口后关闭抽屉。不要复用全局 modal overlay。
|
||||
- 现有 `sessionSearchQuery`、`syncSessionSearchUi`、`getSessionSearchText`、`sessionMatchesSearch`、`renderSessionList` 和原 input/Escape/clear 事件均保持原样;高级检索使用完全独立状态。
|
||||
- 搜索缓存的文件签名使用 dev/ino/size/mtimeMs;每次启动对账 root `*.json` 集合,原子保存导致 inode 变化时自然失效,删除项被 prune。
|
||||
- 初次构建和变化扫描分批让出事件循环;查询只访问内存文档,不在请求路径解析全部 JSON。
|
||||
- 高级面板 z-index 位于聊天内容之上、全局 modal 之下;结果区自身滚动,避免落进 `.messages-wrap` 的 overflow 裁切。
|
||||
- 精确定位必须返回 `sessionId + messageIndex`;前端在 `renderMessages/prependHistoryMessages` 后查找 `data-message-index`,服务端按 `targetMessageIndex` 增加首次加载的历史预取块。
|
||||
- 修改 `style.css/app.js` 后同步推进 `index.html` cache-bust,并覆盖 dark/gilded/wasteland 与 768px 移动端。
|
||||
26
.planning/advanced-conversation-search/progress.md
Normal file
26
.planning/advanced-conversation-search/progress.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 高级会话检索进展
|
||||
|
||||
- 2026-08-03:读取并启用 create-cc-web-theme、frontend-skill、planning-with-files、todo-list-csv。
|
||||
- 2026-08-03:创建并启动 Trellis 任务 `08-03-advanced-conversation-search`。
|
||||
- 2026-08-03:归档参考图,确认源图与归档 hash 一致。
|
||||
- 2026-08-03:建立 10 步 TODO CSV 与对话计划,当前处于规格与基线阶段。
|
||||
- 2026-08-03:首次派发后端审计子代理因 full-history 与 agent_type 参数冲突失败,已记录并改用无历史完整提示。
|
||||
- 2026-08-03:确认 codebase-memory 索引 ready,并定位服务端模块、WebSocket、前端消息分发和历史定位主链路。
|
||||
- 2026-08-03:读取 Trellis backend/frontend 与跨层指南;细则为空,已按实际代码模式记录数据流和边界责任。
|
||||
- 2026-08-03:wait_agent 首次使用低于最小等待窗口,已记录并改用 10 秒。
|
||||
- 2026-08-03:独立计划审查通过,无阻塞问题;已将协议上限、缓存校验和 messageIndex 定位补入主计划。
|
||||
- 2026-08-03:后端/前端并行只读审计完成;汇总独立协议、文件签名缓存、局部 overlay、主题和命中定位边界。
|
||||
- 2026-08-03:完成 TODO 第 1 步,进入索引模块实现。
|
||||
- 2026-08-03:新增 `lib/session-search-index.js`,完成版本化 0600 派生缓存、文件指纹校验、分批构建、增量 upsert/remove、相关/最新与包含/整词检索。
|
||||
- 2026-08-03:服务端接入 `search_sessions → session_search_results/session_search_error`,限制 query≤200、limit≤50、120ms 单连接频率,并保持 `session_list` 原协议不变。
|
||||
- 2026-08-03:`saveSession`、重命名和删除链路已同步索引;查询前 flush pending upsert,避免 250ms debounce 窗口内读取旧索引。
|
||||
- 2026-08-03:侧栏原检索 DOM 属性和 input/Escape/clear 事件保持不变,仅增加相邻 `#advanced-search-open`;高级工作台使用独立状态和 `.chat-main` 局部 overlay。
|
||||
- 2026-08-03:完成安全 DOM 高亮、requestId 旧响应隔离、200ms 防抖、结果流、排序/匹配切换及 `sessionId + messageIndex` 定位。
|
||||
- 2026-08-03:目标命中通过 `load_session.targetMessageIndex` 计算额外预取块,180 条消息夹具可补载到 `historyBaseIndex=0`。
|
||||
- 2026-08-03:新增 `advanced-session-search` 专项回归,覆盖敏感内容排除、整词检索、协议隔离和旧历史定位;语法、diff、gilded、wasteland、全量回归通过。
|
||||
- 2026-08-03:真实 Chromium 验收 1672×941、1440×900、390×844;三个视口横向溢出均为 0,结果区 `overflow-y:auto`,控制台错误为 0。
|
||||
- 2026-08-03:视觉实测为深色连续表面、青蓝标题 `rgb(0,164,223)`、暖黄命中 `rgb(242,211,107)`;桌面侧栏保留,移动侧栏抽屉关闭后面板占满主画布。
|
||||
- 2026-08-03:专项浏览器点击结果确认面板关闭并生成 `data-message-index="0"` 的目标高亮;临时实例使用 18082 端口,验收后已停止,未重启生产 ccweb。
|
||||
- 2026-08-03:完成后尝试按项目约定刷新 codebase-memory 全量索引;`index_repository` 与后续 `index_status` 均返回 `Transport closed`,已降级为本地语法、专项/全量回归与 diff 核验,不影响运行时代码交付。
|
||||
- 2026-08-03:根据用户截图复核 wasteland 侧栏几何,将搜索框的 8px 外距提升到行容器,并把高级按钮统一为 36×36;真实 Chromium bbox 为搜索框与按钮 `y=66 / h=36 / centerY=84`,中心差 0。
|
||||
- 2026-08-03:对齐调整后 cache-bust 更新为 `20260803-advanced-search-align`;advanced-session-search、wasteland-theme 与全量 regression 再次通过。
|
||||
62
.planning/advanced-conversation-search/task_plan.md
Normal file
62
.planning/advanced-conversation-search/task_plan.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 高级会话检索落地计划
|
||||
|
||||
## Goal
|
||||
|
||||
保留现有侧栏检索全部行为,在旁边新增高级检索入口,落地可检索用户/助手消息正文、展示命中摘要并定位原消息的独立检索工作台。
|
||||
|
||||
## Current Phase
|
||||
|
||||
Complete
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: 规格与基线
|
||||
|
||||
- [x] 归档参考图并记录哈希
|
||||
- [x] 审计相关实现、测试与主题边界
|
||||
- [x] 完成计划审查
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 2: 服务端检索能力
|
||||
|
||||
- [x] 实现零依赖内存文档索引和缓存
|
||||
- [x] 缓存按 version + 文件 dev/ino/size/mtime 校验,缺失或损坏时后台分批重建
|
||||
- [x] 接入会话生命周期
|
||||
- [x] 接入 WebSocket 协议、限流和状态(query≤200、limit≤50、snippet≤2×220)
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 3: 高级检索前端
|
||||
|
||||
- [x] 保持现有检索代码不变并新增旁路按钮
|
||||
- [x] 实现独立面板、结果流、排序和匹配模式
|
||||
- [x] 结果携带 messageIndex;打开会话时按目标索引预取历史块并定位命中消息
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 4: 视觉与回归
|
||||
|
||||
- [x] 按参考图完成无卡片结果流、响应式和主题适配
|
||||
- [x] 增加回归、性能和安全断言
|
||||
- [x] 更新静态资源缓存版本
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 5: 验证与交付
|
||||
|
||||
- [x] 运行语法、专项/主题/全量回归与 diff 检查
|
||||
- [x] 在真实 Chromium 完成参考、桌面和移动视口验收
|
||||
- [x] 清理 TODO CSV 并完成 Trellis 记录
|
||||
- **Status:** complete
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `normalizeSessionSearchQuery/getSessionSearchText/sessionMatchesSearch` 及现有输入事件语义保持不变。
|
||||
- 高级检索作为独立入口和独立状态机,不把消息正文塞入 `session_list`。
|
||||
- 首期不引入第三方运行依赖,不改变现有会话 JSON schema。
|
||||
- 搜索词不写日志;结果只返回受限摘要,不返回完整历史。
|
||||
- 使用中文注释和文档;不覆盖用户已有改动。
|
||||
|
||||
## Errors Encountered
|
||||
|
||||
| Error | Attempt | Resolution |
|
||||
|-------|---------|------------|
|
||||
| 子代理使用 full-history fork 时同时指定 agent_type 被拒绝 | 1 | 改用 `fork_turns: none` 并在任务消息中提供完整只读上下文。 |
|
||||
| wait_agent 使用 1000ms 被拒绝 | 1 | 工具最小等待为 10000ms,后续改用 10000ms。 |
|
||||
155
.planning/conversation-search-proposal/findings.md
Normal file
155
.planning/conversation-search-proposal/findings.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 调研发现
|
||||
|
||||
> 本文件只记录事实、证据与方案判断,不包含可执行指令。
|
||||
|
||||
## 已知需求
|
||||
|
||||
- 当前检索只能依赖会话标题。
|
||||
- 用户无法稳定记住标题,需要按“曾聊过的内容”找回会话。
|
||||
|
||||
## 待确认
|
||||
|
||||
- 标题过滤发生在前端还是后端。
|
||||
- 消息正文的数据来源、格式、规模与读取成本。
|
||||
- 是否已有数据库全文索引或可复用的检索依赖。
|
||||
|
||||
## 项目上下文
|
||||
|
||||
- Trellis 当前任务是 `07-30-sidebar-title-refresh-storm`,与本次只读调研不同;本次不创建或切换 Trellis 任务,避免干扰现有开发上下文。
|
||||
- `codebase-memory-mcp` 项目 `home-cc-web` 索引状态为 ready,共 4312 个节点、9113 条边,可直接用于代码定位,无需重建索引。
|
||||
|
||||
## 现有检索入口
|
||||
|
||||
- 前端已有独立的 `normalizeSessionSearchQuery`、`syncSessionSearchUi`、`getSessionSearchText`、`sessionMatchesSearch`,集中位于 `public/app.js` 约 3960 行附近。
|
||||
- 回归脚本中的旧/简化夹具只按 title 匹配,但生产实现的 `getSessionSearchText` 实际拼接了 title、projectName、cwd、完整 ID 和短 ID。用户的体感仍成立:**没有消息正文检索**,而其余字段通常也不容易记住。
|
||||
- 搜索 `searchQuery` / `searchTerm` 未命中,说明这不是一个已经下沉到服务端的通用查询参数。
|
||||
- 后端存在 `listConversationSummaries`、`sendSessionList`、`handleLoadSession` 等候选链路,下一步需确认列表摘要是否携带正文、消息是否仅在打开会话时加载。
|
||||
|
||||
## 列表与存储初步结论
|
||||
|
||||
- `sessionMatchesSearch` 由 `renderSessionList` 调用;检索完全发生在会话列表渲染期。
|
||||
- `listConversationSummaries` 会遍历 `SESSIONS_DIR/*.json`,通过 `loadSessionMetaFromFile` 只提取元数据,返回字段包含 title、agent、status、updatedAt、cwd、projectName 等,但不含消息正文或正文摘要。
|
||||
- `sendSessionList` 同样依赖 `loadSessionMetaFromFile`,说明普通侧边栏列表和 MCP 会话列表都刻意走轻量元数据路径。
|
||||
- 完整会话由 `loadSession(id)` 读取并解析 JSON,`handleLoadSession` 在打开单个会话时才发送历史消息;因此把所有历史消息塞进现有 session_list 再由前端检索,会破坏当前“轻量列表、按需加载”的架构边界。
|
||||
- `loadSessionMetaFromFile` 已针对超大 JSON 做头尾预览和大小阈值处理,侧面说明会话文件可能很大,不能在每次输入搜索词时全量扫描解析所有 JSON。
|
||||
|
||||
## 规模与边界常量
|
||||
|
||||
- 默认 `SESSIONS_DIR` 是项目下的 `sessions/`,可通过 `CC_WEB_SESSIONS_DIR` 覆盖。
|
||||
- 默认单会话保存上限约 10 MiB、加载上限约 32 MiB;超过 512 KiB 时列表元数据不再完整解析文件,而是只读约 128 KiB 头尾预览。
|
||||
- 默认持久化最多 180 条消息、单条消息正文最多约 96 KiB。即使消息条数受限,全部会话逐文件全文扫描仍会产生明显同步 I/O 和 JSON 解析开销。
|
||||
- 会话读写目前是 JSON 文件制,不是数据库;因此推荐方案应新增旁路搜索索引,不能把搜索做成 `sendSessionList` 内的全文件扫描。
|
||||
|
||||
## 当前真实数据规模(2026-08-03,只输出聚合值)
|
||||
|
||||
- 133 个会话 JSON,总计约 81.8 MB;文件中位数约 337 KB,P95 约 2.63 MB,最大约 4.09 MB。
|
||||
- 共 2197 条持久化消息;单会话消息数中位数 7、P95 67、最大 128。
|
||||
- 现存消息 content 全部是字符串,角色分布为 user 1133、assistant 979、system 85,解析错误 0。
|
||||
- 当前 `package.json` 唯一运行依赖是 `ws`,没有 SQLite、全文检索或分词库。
|
||||
- 数据量尚未大到需要 Elasticsearch/Meilisearch 这类独立服务,但已经足以让“每次键入都同步读取并解析全部会话文件”的朴素方案产生明显卡顿。
|
||||
|
||||
## 实测检索基线(2026-08-03)
|
||||
|
||||
- 排除 system 后,可检索的 user/assistant 消息为 2112 条,规范化正文总量约 4.26 MB。
|
||||
- 一次性读取全部 133 个会话并解析/规范化正文约 867 ms;所以不能在搜索请求内现读原文件,但可以后台建索引。
|
||||
- 正文已在内存时,对全部 2112 条消息执行一次最坏情况的精确子串扫描平均约 0.98 ms。
|
||||
- 结论:当前规模最合适的不是外部搜索服务或原生数据库,而是“服务端内存文档索引 + 持久化增量缓存”。它天然支持中文子串、依赖为零,未来正文规模大两个数量级时再切换倒排/FTS 后端。
|
||||
|
||||
## 运行时兼容约束
|
||||
|
||||
- 开发/普通启动基线是 Node 18.19;发布包用 `bun build --compile --target=bun-linux-x64-baseline` 生成 CentOS 7 兼容单文件。
|
||||
- 当前代码只在删除 Codex 本地会话时 best-effort 调用宿主机 `sqlite3` CLI,不能把它视为必备依赖。
|
||||
- `node:sqlite` 不适用于 Node 18,`bun:sqlite` 又无法覆盖 Node 启动路径;`better-sqlite3` 等原生扩展会增加 Bun 单文件和老 glibc 发布风险。因此 SQLite FTS5 不应作为第一阶段主路径。
|
||||
|
||||
## 现有 UI 行为
|
||||
|
||||
- 搜索输入每次触发 `input` 都立即调用 `renderSessionList`,无 debounce、无异步状态。
|
||||
- 搜索时现有逻辑已经自动展开折叠项目和旧会话,这一交互可复用。
|
||||
- 搜索命中只决定“显示/隐藏会话”,当前列表项没有命中片段、命中字段、匹配条数或定位消息能力。
|
||||
|
||||
## 接入点与一致性
|
||||
|
||||
- WebSocket 服务端已有按 `msg.type` 分发的 switch,可新增 `search_sessions`;前端 `handleServerMessage` 可新增 `session_search_results` / `session_search_status`。
|
||||
- `saveSession` 是所有会话持久化的中心入口,拥有 26 个直接调用者;适合作为“按会话 debounce 后增量 upsert 索引”的统一钩子,但不能每次保存都同步重建。
|
||||
- 删除、重命名、新建分别有集中处理函数,可分别触发 remove、metadata update、initial upsert。
|
||||
- 现有回归测试里的搜索夹具仍只匹配 title,与生产 `getSessionSearchText` 不一致;实现时应新增独立全文检索契约测试,并修正夹具,避免测试继续掩盖真实语义。
|
||||
|
||||
## 命中定位可行性
|
||||
|
||||
- 搜索结果可以返回持久化数组中的 `messageIndex`,现有 DOM 消息节点已经写入 `data-message-index`。
|
||||
- 旧消息已有 `load_history_page(before)` 分页接口,可以按目标 index 逐页补载;因此“点击结果 → 打开会话 → 自动加载目标页 → 滚动并高亮命中消息”不需要改会话存储格式。
|
||||
- 现有 `createSessionListItem` 点击只调用 `openSession(session.id)`;可扩展为接受可选 searchMatch,而不影响普通列表点击。
|
||||
|
||||
## 消息可索引范围
|
||||
|
||||
- `normalizeSession` 保留 `messages` 数组;持久化链路会限制消息数、正文长度和工具调用体积。
|
||||
- 第一版应只索引 title、projectName/cwd、用户消息和助手可见文本;默认排除 system 文本、tool input、tool result、附件二进制/元数据,减少噪声、索引体积和敏感信息暴露。
|
||||
|
||||
## 候选方案比较
|
||||
|
||||
1. 前端预加载全部消息后过滤:实现表面简单,但会把当前约 4.26 MB 且持续增长的正文发给浏览器,破坏轻量列表和按需加载,不推荐。
|
||||
2. 每次查询直接遍历 `sessions/*.json`:无需新文件,但当前一次全量解析已约 867 ms,会阻塞 Node 事件循环,不可接受。
|
||||
3. 服务端内存文档索引 + 持久化增量缓存:当前最坏正文扫描约 0.98 ms,中文精确子串天然可用,无新增依赖;推荐。
|
||||
4. SQLite FTS5 / FlexSearch / Meilisearch:倒排检索和模糊能力更强,但当前数据规模用不上;SQLite 与 Node 18/Bun baseline 双运行时存在部署成本,外部服务还有运维和隐私成本,保留为规模升级路径。
|
||||
|
||||
## 推荐架构
|
||||
|
||||
- 新增独立 `SessionSearchIndex` 模块,接口固定为 `initialize/upsert/remove/search/status`,具体后端首期使用内存文档集合,未来可替换而不改 WebSocket/UI 协议。
|
||||
- 索引粒度为“消息文档”:sessionId、messageIndex、role、timestamp、原始 snippet 文本、规范化文本;另保存每会话 title/project/cwd/id 元数据。
|
||||
- 规范化使用 Unicode NFKC、lowercase、合并空白;首期做精确短语 + 多词 AND,不做拼音、向量语义或重型分词。
|
||||
- 缓存存放到 `sessions/_search/index-v1.json`,与原始会话处于同一数据权限边界;缓存是派生数据,版本不匹配、损坏或缺失时自动后台重建。
|
||||
- 启动先载入缓存,再通过文件 size + mtime 校验;只解析新增/变化的会话并移除已删除项。首次全量建索引异步分批执行,不阻塞服务监听。
|
||||
- `saveSession` 成功后按 sessionId debounce 增量 upsert;rename 立即更新元数据;delete 立即 remove;缓存写盘合并并使用原子替换。
|
||||
- 搜索请求只访问内存索引,绝不在请求路径读取全部会话文件。
|
||||
|
||||
## 协议与结果形状
|
||||
|
||||
- 请求:`search_sessions { requestId, query, agent, limit }`;query 最大 200 字符,limit 默认 30、最大 50。
|
||||
- 响应:`session_search_results { requestId, query, indexState, tookMs, results }`。
|
||||
- 单条结果只返回 sessionId、title、projectName、updated、score、matchType、matchedMessageCount,以及最多 2 个 200 字符左右的 snippet;不返回完整历史。
|
||||
- 前端使用 requestId 丢弃乱序旧响应;输入 debounce 建议 160–220 ms。
|
||||
|
||||
## 排序与展示
|
||||
|
||||
- 搜索模式改为扁平相关性列表,清空查询后恢复现有按项目/置顶分组;避免“置顶/项目顺序”压过真正的命中相关性。
|
||||
- 分值优先级:标题前缀 > 标题包含 > 用户消息精确短语 > 助手消息精确短语 > 多词 AND > 项目/cwd/id;最后只给近期和置顶小幅加分。
|
||||
- 每条结果展示项目、命中来源(标题/用户消息/助手回复/路径)、相对时间、命中片段和命中数;关键词高亮必须基于文本节点,不能拼接未转义 HTML。
|
||||
- 一个字符仅做现有元数据过滤;至少两个字符才发起正文搜索,避免单字产生大量无意义结果。
|
||||
- 索引构建时继续提供标题/项目本地过滤,并显示“正在建立内容索引 x%”;完成后自动重跑当前查询。
|
||||
|
||||
## 分期建议
|
||||
|
||||
- P0(核心找回):正文索引、增量缓存、WebSocket 查询、相关性列表、命中片段、索引状态、独立回归测试。
|
||||
- P1(精准定位):点击内容命中后按 messageIndex 打开会话、自动补载历史页、滚动并短暂高亮目标消息。
|
||||
- P2(规模升级,可选):增加时间/项目/角色筛选;当可检索正文超过 100 MB、会话超过 5000 或 p95 查询持续超过 50 ms 时,再切换纯 JS 倒排或 FTS 后端。
|
||||
|
||||
## 安全与可运维性
|
||||
|
||||
- 默认排除 system、tool input、tool result 和附件;搜索词不写日志,日志只记录 tookMs、文档数、缓存命中/重建状态。
|
||||
- 缓存文件权限应收紧到当前用户,写入采用临时文件 + 原子 rename;缓存删除不会丢业务数据,只会触发重建。
|
||||
- 服务端限制查询长度、结果数和 snippet 长度;前端高亮不得使用未经 escape 的 `innerHTML`,防止历史消息形成持久型 XSS。
|
||||
|
||||
## 预计实施范围
|
||||
|
||||
- 新增 `lib/session-search-index.js`:索引构建、缓存、规范化、排序、snippet 和状态。
|
||||
- 修改 `server.js`:初始化索引、生命周期钩子、WebSocket 请求/响应、状态与限流。
|
||||
- 修改 `public/app.js`:debounce、异步请求状态、乱序响应保护、扁平结果渲染;P1 增加命中定位。
|
||||
- 修改 `public/index.html` / `public/style.css`:占位文案、索引状态、snippet/高亮,并覆盖移动端和现有主题。
|
||||
- 修改 `scripts/regression.js`:新增独立搜索契约、增量一致性、缓存恢复与前端安全断言。
|
||||
- 不修改现有会话 JSON schema,不引入第三方运行依赖,不要求一次性数据迁移。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 能用只出现在历史用户消息或助手回复中的中英文片段找到正确会话;标题、项目、路径、ID 的原能力不回退。
|
||||
- system/tool/附件内容默认不产生命中;搜索响应不包含完整会话正文。
|
||||
- 新建/新增消息在持久化后 2 秒内可检索,重命名立即生效,删除后不再命中;重启、缓存缺失、缓存损坏均能自动收敛。
|
||||
- 搜索结果按相关性而非项目顺序展示,snippet 正确转义;乱序响应不会覆盖新查询。
|
||||
- 当前规模 p95 服务端查询低于 50 ms,WebSocket 单次响应受 50 条和 snippet 上限约束;全量重建不阻塞正常会话操作。
|
||||
- `npm run regression` 通过,并对 Node 启动与 Bun baseline 单文件构建分别验证。
|
||||
|
||||
## 工作量与风险
|
||||
|
||||
- P0 属于中等改动,预计 1.5–2 个开发日;P1 命中定位约 0.5–1 个开发日。主要成本在异步状态、缓存一致性和回归覆盖,不在搜索算法。
|
||||
- 最大风险是索引陈旧和构建期阻塞;通过中心 save 钩子 + 启动 mtime/size 对账 + 分批异步重建解决。
|
||||
- 缓存会复制约 4.26 MB 当前正文,需和 sessions 同权限、禁止误打进空白发布包或日志;它不是唯一数据源,可随时重建。
|
||||
- 活跃助手流式内容可能要到下一次持久化才可检索;首期以历史找回为目标,这个延迟可接受并应在测试中明确。
|
||||
19
.planning/conversation-search-proposal/progress.md
Normal file
19
.planning/conversation-search-proposal/progress.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 进展日志
|
||||
|
||||
- 2026-08-03:启动只读代码审计;已读取 planning-with-files 规则并完成会话恢复检查。
|
||||
- 2026-08-03:建立隔离研究目录,避免干扰仓库中已有计划。
|
||||
- 2026-08-03:读取 Trellis 工作流;确认现有 current-task 与本调研无关,保持不变。
|
||||
- 2026-08-03:确认 codebase-memory 索引可用,无需重建。
|
||||
- 2026-08-03:定位前端搜索函数,确认当前实现只匹配 `session.title`。
|
||||
- 2026-08-03:定位会话列表与完整会话加载边界,确认列表接口不含正文,完整消息按会话加载。
|
||||
- 2026-08-03:核对生产搜索字段与容量常量;修正“仅标题”为“标题/项目/路径/ID,但无正文”。
|
||||
- 2026-08-03:完成当前会话文件与消息规模聚合,确认 133 会话/约 81.8 MB/2197 消息,现有依赖无搜索能力。
|
||||
- 2026-08-03:完成正文体量和扫描基准;确认内存正文扫描约 0.98 ms,后台建索引约 0.87 s。
|
||||
- 2026-08-03:核对 Node 18 + Bun baseline 单文件约束,排除首期原生 SQLite 依赖。
|
||||
- 2026-08-03:定位 WebSocket 协议、前端消息分发和新建/保存/重命名/删除生命周期接入点。
|
||||
- 2026-08-03:验证按 messageIndex 定位旧消息可复用现有历史分页和 DOM 索引标记。
|
||||
- 2026-08-03:完成四类架构对比,确定“服务端内存文档索引 + 持久化增量缓存”为首期方案。
|
||||
- 2026-08-03:定义索引边界、WebSocket 协议、排序、UI、增量生命周期和升级阈值。
|
||||
- 2026-08-03:核验源码行号,完成实施文件、验收指标、工作量和风险说明。
|
||||
- 2026-08-03:本轮只读方案审计完成;未修改业务代码、未重启服务。
|
||||
- 2026-08-03:修正规划完成检查格式;第一次复检因错误说明触发标题匹配而误计阶段,已记录并改写。
|
||||
62
.planning/conversation-search-proposal/task_plan.md
Normal file
62
.planning/conversation-search-proposal/task_plan.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 会话内容检索优化方案
|
||||
|
||||
## Goal
|
||||
|
||||
基于 cc-web 当前代码与存储方式,给出可按会话消息正文找回历史会话的可落地方案;本轮只调研和设计,不修改产品代码。
|
||||
|
||||
## Current Phase
|
||||
|
||||
Phase 5(已完成)
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: 项目上下文与边界
|
||||
|
||||
- [x] 读取 Trellis 工作流
|
||||
- [x] 建立隔离研究目录
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 2: 现有检索链路
|
||||
|
||||
- [x] 确认 codebase-memory 索引
|
||||
- [x] 定位前端过滤与后端列表接口
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 3: 数据与性能约束
|
||||
|
||||
- [x] 核查消息结构与持久化限制
|
||||
- [x] 聚合真实数据规模并执行只读基准
|
||||
- [x] 验证历史消息定位能力
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 4: 架构比较与推荐
|
||||
|
||||
- [x] 比较浏览器过滤、实时文件扫描、内存索引和 FTS/外部服务
|
||||
- [x] 确定服务端内存文档索引 + 持久化增量缓存
|
||||
- **Status:** complete
|
||||
|
||||
### Phase 5: 交付方案
|
||||
|
||||
- [x] 定义协议、UI、排序、增量生命周期和分期
|
||||
- [x] 整理实施范围、验收标准、风险与成本
|
||||
- [x] 核验源码行号和只读基准
|
||||
- **Status:** complete
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 本轮不修改业务代码、不重启服务。
|
||||
- 优先使用 codebase-memory-mcp 理解代码,rg/sed 只校验行号与配置。
|
||||
- 方案兼顾已有大量会话、中文检索、历史数据迁移和持续增量索引。
|
||||
|
||||
## 决策
|
||||
|
||||
- 首期采用服务端内存文档索引 + `sessions/_search/` 持久化增量缓存。
|
||||
- 保持现有轻量 `session_list`,正文只在命中时返回受限 snippet。
|
||||
- 不首期引入 SQLite/原生依赖或外部搜索服务。
|
||||
|
||||
## Errors Encountered
|
||||
|
||||
| Error | Attempt | Resolution |
|
||||
|-------|---------|------------|
|
||||
| 完成检查显示 `0/0 phases` | 1 | 原计划使用中文勾选列表,检查脚本只识别标准 Phase 标题与状态字段;已改用标准格式并显式传入计划路径复检。 |
|
||||
| 完成检查显示 `5/6 phases` | 2 | 错误说明本身包含了检查器的标题匹配字面量,被误计为第六阶段;已改写错误说明。 |
|
||||
102
.planning/usage-statistics-dashboard-plan/findings.md
Normal file
102
.planning/usage-statistics-dashboard-plan/findings.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 调研结论:cc-web 使用统计看板
|
||||
|
||||
## 用户要求
|
||||
|
||||
- 当前只需要可执行计划,不实施产品代码。
|
||||
- 看板必须不影响现有聊天、会话、检索和工具调用功能。
|
||||
- 指标必须有明确口径,不能使用含糊的“活跃会话”。
|
||||
- Skill 只统计显式 `$skill` 使用,不宣称能够监控实际读取或执行。
|
||||
- 用户已授权开始实施,并要求先完成整体功能,再适配所有现有主题。
|
||||
- 用户指定统计入口位于侧栏底部:会话列表下方、现有 `CC-Web` 设置入口同一区域,而不是会话列表内部。
|
||||
- 入口参考图为 `sessions/_attachments/750cb7a2-a109-44a9-ae3d-6ca352d4aba0.jpg`;图中底部操作区在会话列表滚动区域之外。
|
||||
|
||||
## 现有数据能力
|
||||
|
||||
- 会话数据保存在 `sessions/*.json`。
|
||||
- 用户消息可持久化 `composerMentions`,可识别显式 `$skill` mention。
|
||||
- Codex App 的 MCP 调用会规范化到 assistant message 的 `toolCalls[]`,包含 `server`、`tool`、`status`。
|
||||
- 现存 MCP 工具调用没有独立时间戳;历史统计只能暂时使用所属 assistant 消息完成时间。
|
||||
- 会话持久化有消息数和每条消息工具调用数上限,因此看板只能统计“当前保留数据”,不能声称是永久全量审计。
|
||||
- 当前会话主创建字段为 `created`;134 个会话均有 `created/updated`,当前 2,226 条保留消息均有 `timestamp`。
|
||||
- 当前扫描得到 1,745 次 MCP 调用,`completed=1645`、`failed=100`;失败调用同样可能 `done=true`,失败口径必须读取 `meta.status`。
|
||||
- 显式 Skill mention 的稳定判定是 `composerMentions[].kind === 'skill'`,不能扫描消息正文中的 `$xxx`。
|
||||
- `crossConversation` 可区分跨会话自动消息;界面使用“直接发送消息”和“跨会话消息”,避免把前者绝对命名为人工消息。
|
||||
|
||||
## 已有数据扫描结果
|
||||
|
||||
- 134 个会话 JSON,总量约 82 MB。
|
||||
- 1,678 次 MCP 调用,`server/tool` 解析率 100%。
|
||||
- MCP 状态:`completed=1578`,`failed=100`。
|
||||
- 显式 Skill mention 共 7 次。
|
||||
- 一次全量扫描约 0.93 秒,峰值 RSS 约 116 MB。
|
||||
|
||||
## 架构判断
|
||||
|
||||
- 82 MB 数据量下,全量扫描可以用于一次性回填或维护操作。
|
||||
- 不应让每次看板刷新都全量解析所有会话文件,否则数据增长后会和聊天服务争用 CPU、内存和磁盘 IO。
|
||||
- 最稳妥的结构是:独立索引文件、异步更新、独立查询协议、独立前端工作区。
|
||||
- 统计是旁路只读能力,不进入消息发送和会话写入的同步关键路径。
|
||||
- 统计索引采用懒加载:第一次打开看板才加载/回填,未使用看板时不增加服务启动成本。
|
||||
- 索引初始化后可防抖增量更新;调度与删除必须内层 `try/catch`,不能让统计异常进入 `saveSession` 或删除会话的主错误分支。
|
||||
- 统计响应只返回聚合值、工具名、Skill 名和会话元数据,不返回消息正文、MCP 参数或工具结果。
|
||||
|
||||
## 页面范围
|
||||
|
||||
- 顶部:时间范围、本周、本月、自定义范围、刷新。
|
||||
- 首行指标:新建会话、发送消息、MCP 调用、MCP 失败、Skill 显式使用。
|
||||
- 中部:按日趋势;MCP 工具使用明细。
|
||||
- 下部:MCP 状态分布;Skill 显式使用排行;最近会话明细。
|
||||
- MCP 工具明细行可进入该工具的调用明细;该交互属于网页实现,不需要在生图提示词中逐字描述。
|
||||
- 看板作为 `.chat-main` 内局部覆盖工作区,聊天 DOM 保持挂载;高级检索与看板互斥打开。
|
||||
- 入口必须使用独立 `.usage-dashboard-open`,不能复用 `.settings-btn`,否则 Wasteland 的齿轮伪元素会污染统计按钮。
|
||||
- 当前共有 11 个主题 ID;基础样式使用语义 token,专属修正只需要 coolvibe、共享暗色组、gilded 和 wasteland。
|
||||
|
||||
## 代码落点依据
|
||||
|
||||
- `public/index.html` 已有 sidebar 与 `main.chat-main`,看板可以作为 chat-main 内独立工作区,而不必重做应用外壳。
|
||||
- `server.js` WebSocket 分发已有独立消息类型模式,可新增统计查询类型而不改变现有协议。
|
||||
- `scripts/regression.js` 已覆盖高级检索的独立入口和独立状态模式,统计看板可沿用相同隔离策略。
|
||||
- codebase-memory 项目 `home-cc-web` 索引状态为 ready(4,455 nodes / 9,320 edges)。
|
||||
- `saveSession()` 是高入度核心写入函数,统计逻辑不得直接接入其同步调用链;旁路索引应在看板查询或独立后台任务中刷新。
|
||||
- 用户和 assistant 消息均有 `timestamp`;Codex App steer 用户消息也会持久化 `composerMentions`。
|
||||
- MCP 调用继续从 assistant message 的 `toolCalls` 读取,`ensureToolCall()` 负责归一化名称、kind、meta 和状态更新。
|
||||
|
||||
## 风险与控制
|
||||
|
||||
| 风险 | 控制方式 |
|
||||
|---|---|
|
||||
| 看板查询拖慢聊天服务 | 使用增量索引;请求限时;禁止请求时全量扫描 |
|
||||
| 索引与会话数据不一致 | 保存源文件指纹;可重建;界面显示统计更新时间 |
|
||||
| 历史 MCP 时间不准确 | 明示按 assistant 消息时间归属;不展示伪精确耗时 |
|
||||
| Skill 指标被误解 | 指标名称固定为“Skill 显式使用” |
|
||||
| 前端状态污染聊天 | 独立状态对象、DOM 根节点、样式命名空间和关闭恢复流程 |
|
||||
| 新功能引入回归 | 功能开关、合同测试、现有完整回归、灰度启用 |
|
||||
| 现有文件已有未提交修改 | 只在精确区块追加,不重排或覆盖高级会话检索改动;以任务开始时 diff 为基线 |
|
||||
|
||||
## 计划审查记录
|
||||
|
||||
- 第一次审查发现原计划缺少“全部现有主题逐一适配”的独立阶段。
|
||||
- 已新增 Phase 5 和硬性主题验收门槛,第二次审查通过。
|
||||
- 复审确认现有会话主创建字段为 `created`;实施以此为主,`createdAt` 只作历史兼容兜底。
|
||||
- 统计响应需携带 `schemaVersion`,性能验收已补充明确阈值。
|
||||
|
||||
## 资源
|
||||
|
||||
- 参考图:`sessions/_attachments/67a5f5ae-ea62-4c12-b723-deb9adb98c2f.png`
|
||||
- 会话存储与 WebSocket:`server.js`
|
||||
- Codex App 工具调用归一化:`lib/codex-app-runtime.js`
|
||||
- 页面外壳:`public/index.html`
|
||||
- 现有前端状态与交互:`public/app.js`
|
||||
- 回归测试:`scripts/regression.js`
|
||||
|
||||
## 实施与验收结论
|
||||
|
||||
- 统计索引在第一次打开看板时才构建;未初始化时保存会话不会安排统计更新。
|
||||
- 查询响应包含 `schemaVersion` 和 `[from,to)` 语义,不包含消息正文、MCP 参数或结果。
|
||||
- `usage_stats_query` 使用独立 requestId,真实协议回归确认不会新增 `session_list`。
|
||||
- `CC_WEB_USAGE_STATISTICS=0` 会关闭鉴权 feature flag,并让查询返回 `disabled`,不影响服务其他能力。
|
||||
- 真实数据最终性能:83,335,652 字节、134 个会话,回填 1,146.68 ms,查询 P95 51.79 ms,峰值 RSS 119,476 KB,缓存 394,629 字节。
|
||||
- 浏览器验收:Chrome Headless 151.0.7922.71,视口为 1440×900、1024×768、768×1024、390×844、360×800;11 个主题共 55 组。
|
||||
- 页面级横向溢出均为 0;窄屏 MCP 表格按设计在 `.usage-dashboard__table-wrap` 内局部滚动。
|
||||
- Wasteland 的 `.usage-dashboard-open` 没有继承 `.settings-btn::before`;移动端入口为 44×44,桌面为 34×34。
|
||||
- 浏览器动态状态已覆盖 loading、MCP 明细、空态和错误态;Wasteland 根看板使用实色背景,避免下层聊天视觉透出。
|
||||
87
.planning/usage-statistics-dashboard-plan/progress.md
Normal file
87
.planning/usage-statistics-dashboard-plan/progress.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 进度记录:cc-web 使用统计看板计划
|
||||
|
||||
## 2026-08-03
|
||||
|
||||
### Phase 1:边界与统计口径确认
|
||||
|
||||
- **状态:** complete
|
||||
- 已确认当前只产出计划,不修改产品代码。
|
||||
- 已确认可靠指标及其时间归属。
|
||||
- 已排除“活跃会话”和“Skill 实际读取次数”等不可靠指标。
|
||||
- 已记录“不影响现有功能”的硬性边界。
|
||||
|
||||
### Phase 2 至 Phase 6:实施与验收
|
||||
|
||||
- **状态:** complete
|
||||
- 用户已确认进入代码实施,并指定侧栏底部入口位置。
|
||||
- 已补充看板视觉主张、内容结构和交互原则;本轮仍未改产品代码。
|
||||
- 已把“先记录现有行为基线、功能开关默认关闭”设为实施第一道门禁。
|
||||
- 已创建 Trellis 任务 `08-03-usage-statistics-dashboard`。
|
||||
- 已记录现有高级会话检索相关脏文件,后续不得覆盖或清理。
|
||||
- 计划审查指出缺少独立的全部主题适配阶段;已补充 Phase 5 和对应硬性验收门槛。
|
||||
- 已确认 codebase-memory 索引可用,并开始定位会话写入、MCP toolCalls、composerMentions 和前端入口链路。
|
||||
- 第二次计划审查已通过;已修正 `created` 字段口径并加入 schema 版本和性能阈值。
|
||||
- 已完成后端数据结构、前端入口、回归落点和 11 个主题的并行只读审计。
|
||||
- 已确定看板使用 `.chat-main` 局部覆盖、独立状态机、独立请求 ID 和懒加载派生索引。
|
||||
- 实施清单第 1 项完成,进入只读统计聚合模块与单元测试实现。
|
||||
- 新增 `lib/usage-statistics.js` 和 `scripts/usage-statistics-unit.js`;语法检查、纯夹具单测和 `git diff --check` 通过。
|
||||
- 实施清单第 2 项完成,进入独立 WebSocket 统计查询协议接入。
|
||||
- 独立协议、指定入口、看板 DOM、前端状态机、趋势/明细交互及基础响应式样式已完成首轮实现。
|
||||
- 已通过 `node --check`、统计单测和相关文件 `git diff --check`,进入全部现有主题适配。
|
||||
- 已完成 CoolVibe、Carbon/Nocturne/Cinder、Gilded、Wasteland 的限定覆盖;其他主题直接继承看板语义变量。
|
||||
- 已新增 `usage-statistics` 回归目标,真实 WebSocket 验证查询不会额外发送 `session_list`,禁用开关返回 `disabled`。
|
||||
- 83.3 MB / 134 个真实会话最终首次回填 1.15 秒,查询 P95 51.79 ms,峰值 RSS 116.7 MB,索引占源数据 0.47%。
|
||||
- Chrome Headless 151 完成 11 个主题 × 5 个视口共 55 组布局验收;桌面、平板、移动端均无页面级横向溢出。
|
||||
- 已验证 loading、MCP 长工具名明细、空态和无效日期错误态;Wasteland 根看板改为实色,避免下层输入框视觉透出。
|
||||
- `gilded-theme`、`wasteland-theme`、`advanced-session-search`、统计专项和全量回归均通过。
|
||||
|
||||
### Phase 7:最终审查与交付
|
||||
|
||||
- **状态:** complete
|
||||
- 两轮独立审查均无高等级问题;提出的 4 个中等级问题已全部修复并补回归合同。
|
||||
- 已限制 MCP/Skill 返回规模、公开 total/returned、明确明细“最近 X / 共 Y 条”,回填改为每文件让出事件循环。
|
||||
- 已修复异常空文件增量可能保留旧文档,并把聚合核心单测接入统计专项和全量回归。
|
||||
- 已修复 CoolVibe 选中按钮对比度和暗色日期原生图标;Chrome computed style 验证通过。
|
||||
- PM2 服务已在线加载,用户确认最终效果可用。
|
||||
- 最终清理只删除本任务生成的临时浏览器包、截图和根目录 TODO CSV;未清理既有高级检索改动。
|
||||
|
||||
## 本轮文件变更
|
||||
|
||||
- `lib/usage-statistics.js`:新增懒加载、可重建的只读统计索引与聚合器。
|
||||
- `scripts/usage-statistics-unit.js`:新增统计口径、隐私、增量和损坏文件单测。
|
||||
- `server.js`:新增功能开关、安全生命周期钩子和独立查询协议。
|
||||
- `public/index.html`:新增固定 footer 入口和 chat-main 局部看板 DOM。
|
||||
- `public/app.js`:新增独立看板状态、查询、渲染和恢复交互。
|
||||
- `public/style.css`:新增基础响应式看板与现有主题限定覆盖。
|
||||
- `scripts/regression.js`:新增统计专项合同和真实 WebSocket 回归。
|
||||
- 计划、Trellis 和临时 CSV 仅用于过程记录;现有高级检索脏改动全部保留。
|
||||
|
||||
## 验证结果
|
||||
|
||||
| 验证项 | 预期 | 实际 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 单元与专项 | 统计口径、隐私、协议隔离 | 全部通过 | 通过 |
|
||||
| 全量回归 | 现有功能不回归 | `Regression checks passed.` | 通过 |
|
||||
| 性能 | 回填 ≤3s、P95 ≤100ms、RSS ≤160MB、索引 ≤35% | 1.15s / 51.79ms / 116.7MB / 0.47% | 通过 |
|
||||
| 浏览器 | 11 主题 × 5 视口、动态状态 | 55 组 + loading/detail/empty/error | 通过 |
|
||||
| 风险隔离 | 独立入口、协议、索引和关闭开关 | `CC_WEB_USAGE_STATISTICS=0` 可即时关闭 | 通过 |
|
||||
|
||||
## 错误日志
|
||||
|
||||
| 时间 | 错误 | 尝试 | 处理 |
|
||||
|---|---|---:|---|
|
||||
| 2026-08-03 | 无 | 1 | — |
|
||||
| 2026-08-03 | `task.py list-context` 不接受 action 位置参数 | 1 | 已有 `task.py validate` 通过结果,改为由实现代理读取 `implement.jsonl` |
|
||||
| 2026-08-03 | Wasteland 回归把三暗色统计 selector 识别为五主题共享层 | 1 | 将统计暗色组改用等价 `:where(...)`,保留既有共享层合同 |
|
||||
| 2026-08-03 | 浏览器验收把局部表格 scrollWidth 误报为页面溢出 | 1 | 以 document/body/dashboard body 为页面口径,表格保留明确的局部横向滚动 |
|
||||
| 2026-08-03 | 动态状态脚本刚完成查询即刷新,命中 250ms 限流 | 1 | 按真实操作节奏增加 350ms 间隔,协议行为符合设计 |
|
||||
|
||||
## 5 问检查
|
||||
|
||||
| 问题 | 答案 |
|
||||
|---|---|
|
||||
| 当前在哪? | 功能与验收完成,正在最终独立审查和清理 |
|
||||
| 接下来去哪? | 审查结论、运行服务激活、交付记录 |
|
||||
| 目标是什么? | 不影响现有功能地增加只读使用统计看板 |
|
||||
| 已学到什么? | 见 `findings.md` |
|
||||
| 已完成什么? | 整体功能、全主题适配、专项/全量回归、性能和浏览器验收 |
|
||||
151
.planning/usage-statistics-dashboard-plan/task_plan.md
Normal file
151
.planning/usage-statistics-dashboard-plan/task_plan.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# 任务计划:cc-web 使用统计看板
|
||||
|
||||
## 目标
|
||||
|
||||
在不改变现有聊天、会话列表、会话检索和消息发送行为的前提下,为 cc-web 增加一个可独立启停、只读、可回滚的使用统计看板。
|
||||
|
||||
## 当前阶段
|
||||
|
||||
已完成:功能、主题、回归、性能、浏览器与在线验收
|
||||
|
||||
## 实施阶段
|
||||
|
||||
### Phase 1:边界与统计口径确认
|
||||
|
||||
- [x] 明确当前可可靠统计的数据
|
||||
- [x] 明确当前不可可靠统计的数据
|
||||
- [x] 定义“不影响现有功能”的不可变边界
|
||||
- **状态:** complete
|
||||
|
||||
### Phase 2:安全基线、只读统计核心与历史回填
|
||||
|
||||
- [x] 记录现有回归结果及消息发送、会话切换、检索的行为基线
|
||||
- [x] 建立独立功能开关,`CC_WEB_USAGE_STATISTICS=0` 时不显示入口、不创建索引
|
||||
- [x] 新增独立统计模块,读取保留期内的 `sessions/*.json`
|
||||
- [x] 第一次打开看板时懒加载回填,不把 83.3 MB 扫描放进服务启动或消息处理链路
|
||||
- [x] 将聚合结果写入独立索引文件,不回写任何会话 JSON
|
||||
- [x] 索引就绪后通过防抖 save/delete 旁路钩子更新,钩子失败不进入主错误链路
|
||||
- [x] 索引只保存统计事件、源文件指纹和会话元数据,可删除重建
|
||||
- [x] 索引异常时返回看板错误,不阻塞聊天和会话持久化
|
||||
- **状态:** complete
|
||||
|
||||
### Phase 3:独立查询协议
|
||||
|
||||
- [x] 新增只读 `usage_stats_query/result/error` 协议
|
||||
- [x] 响应携带稳定的 `schemaVersion=1`
|
||||
- [x] 请求参数仅包含时间范围、时区和内部固定上限
|
||||
- [x] 响应包含概览、趋势、MCP 明细、Skill 显式使用排行和会话明细
|
||||
- [x] 不修改 `session_list`、`search_sessions`、`load_session` 等现有协议和负载结构
|
||||
- [x] 增加排行/明细上限、频率限制和异常隔离
|
||||
- **状态:** complete
|
||||
|
||||
### Phase 4:独立看板工作区
|
||||
|
||||
- [x] 在侧栏固定 footer 增加独立“统计”入口
|
||||
- [x] 看板使用自己的前端状态、DOM 根节点和样式命名空间
|
||||
- [x] 顶部提供本周、本月、自定义时间和刷新
|
||||
- [x] 展示可靠指标,不展示无法准确解释的“活跃会话”
|
||||
- [x] MCP 支持明细窗口,会话行可返回对应会话
|
||||
- [x] 关闭看板后恢复原聊天滚动、焦点和输入选区
|
||||
- **状态:** complete
|
||||
|
||||
### Phase 5:全部现有主题适配
|
||||
|
||||
- [x] 枚举 `THEME_OPTIONS` 中全部 11 个现有主题
|
||||
- [x] 适配侧栏底部入口、概览、趋势、表格、空态、错误态和加载态
|
||||
- [x] 主题差异只通过语义变量和主题限定选择器实现,没有复制业务 DOM 或 JS
|
||||
- [x] 11 主题 × 5 视口完成真实浏览器几何验收
|
||||
- [x] 检查 `prefers-reduced-motion`、文字对比度、日期图标和焦点可见性
|
||||
- **状态:** complete
|
||||
|
||||
### Phase 6:兼容性、性能与故障回归
|
||||
|
||||
- [x] 执行现有全量回归并记录结果
|
||||
- [x] 增加统计协议、时间边界、失败状态、空数据、限额和隐私测试
|
||||
- [x] 增加入口隔离、刷新、错误态和 reduced-motion 前端合同
|
||||
- [x] 验证统计查询后会话加载、会话搜索及 `session_list` 均不受影响
|
||||
- [x] 验证索引缓存复用、增量、删除、空文件和损坏文件隔离
|
||||
- [x] 对当前 83.3 MB 会话数据完成性能验收
|
||||
- **状态:** complete
|
||||
|
||||
### Phase 7:灰度启用与回滚
|
||||
|
||||
- [x] 使用功能开关控制统计入口和统计查询服务
|
||||
- [x] 当前环境已重启启用,用户已确认在线效果
|
||||
- [x] 记录 CPU、内存、回填、查询耗时和索引比例
|
||||
- [x] 回滚时可设 `CC_WEB_USAGE_STATISTICS=0`,不涉及会话数据迁移
|
||||
- **状态:** complete
|
||||
|
||||
## 统计口径
|
||||
|
||||
| 指标 | 定义 | 时间归属 | 可靠性 |
|
||||
|---|---|---|---|
|
||||
| 新建会话 | `created` 落在时间范围内的会话数量,兼容 `createdAt` 历史兜底 | 会话创建时间 | 保留会话范围内高 |
|
||||
| 发送消息 | 时间范围内当前仍被保留的用户消息数量 | 用户消息时间 | 保留数据范围内高 |
|
||||
| 自动协作消息 | 非人工触发、由协作链路产生的消息数量 | 对应消息时间 | 先验证字段;无法稳定分类则首版不展示 |
|
||||
| MCP 调用 | assistant message 中 `toolCalls` 的 MCP 调用条目数量 | 暂按所属 assistant 消息时间 | 中 |
|
||||
| MCP 失败 | MCP 调用中 `status=failed` 的条目数量 | 暂按所属 assistant 消息时间 | 中 |
|
||||
| Skill 显式使用 | 用户消息 `composerMentions` 中显式 `$skill` mention 数量 | 用户消息时间 | 保留数据范围内高 |
|
||||
|
||||
## 当前版本明确不做
|
||||
|
||||
- 不统计 Skill 文件被模型实际读取或执行的次数。
|
||||
- 不将“活跃会话”作为指标。
|
||||
- 不修改历史或未来的会话 JSON schema。
|
||||
- 不把统计字段塞入 `session_list` 或会话列表负载。
|
||||
- 不为统计而改造现有消息发送、会话检索或 MCP 执行链路。
|
||||
- 不承诺历史 MCP 的精确调用时刻和耗时。
|
||||
- 不在页面每次刷新时全量扫描所有会话 JSON。
|
||||
|
||||
## 核心架构决策
|
||||
|
||||
| 决策 | 理由 |
|
||||
|---|---|
|
||||
| 采用“历史回填 + 异步增量索引 + 独立查询” | 当前全量扫描可用但长期不适合每次看板请求执行 |
|
||||
| 首版使用可重建的旁路索引,不引入数据库依赖 | 降低发布和 CentOS 单文件打包风险;当前数据规模足够使用 |
|
||||
| 索引与会话源数据完全分离 | 索引可以删除重建,不给现有会话数据带来迁移风险 |
|
||||
| 新增独立 WebSocket 消息类型 | 避免改变现有会话列表、搜索和加载协议 |
|
||||
| 看板作为独立工作区 | 页面结构可复用现有外壳,同时隔离聊天状态和交互 |
|
||||
| 提供可立即关闭的功能开关 | 用户确认落地后默认展示入口;出现问题时可只关闭看板,不回滚会话功能 |
|
||||
| MCP 历史数据暂按 assistant 消息时间聚合 | 当前工具调用记录本身没有时间戳,必须在界面说明口径 |
|
||||
|
||||
## 看板界面基线
|
||||
|
||||
- **视觉主张:** 延续 cc-web 现有应用外壳,形成安静、紧凑、可快速扫描的运营工作区;不单独设计营销主题。
|
||||
- **内容结构:** 时间与刷新控制 → 核心数字 → 趋势和 MCP 明细 → 状态与 Skill 排行 → 最近会话。
|
||||
- **交互主张:** 切换看板时保留聊天现场;筛选刷新只更新看板区域;明细查看在看板内部完成,返回后保留筛选条件。
|
||||
- **组件原则:** 只有可交互或需要独立语义的区域使用卡片,其余优先使用分栏、分隔线、图表和紧凑表格,避免卡片拼贴。
|
||||
- **入口位置:** 固定在侧栏底部操作区,处于会话列表下方,与截图中的 `CC-Web` 设置入口同一区域;不插入会话列表滚动内容。
|
||||
|
||||
## 不影响现有功能的硬性验收门槛
|
||||
|
||||
1. 关闭功能开关时,前端 DOM、交互和服务端行为与改动前一致。
|
||||
2. 统计模块异常、索引缺失或查询超时,消息发送和会话操作仍正常。
|
||||
3. `session_list`、`search_sessions`、`load_session` 的请求和响应合同不变。
|
||||
4. 不修改现有会话文件内容;统计索引可随时删除并从源数据重建。
|
||||
5. 全量现有回归通过,新增统计回归通过后才允许灰度开启。
|
||||
6. 不在正常消息处理的同步关键路径中执行全量扫描或重聚合。
|
||||
7. 看板必须显示“基于当前保留数据”和最后更新时间,不能把保留期数据表述为永久审计数据。
|
||||
8. `THEME_OPTIONS` 中全部现有主题都必须完成桌面和窄屏验收;侧栏入口位置一致,看板内容可读,且聊天恢复、会话切换、搜索、消息发送和 MCP 功能不受影响。
|
||||
|
||||
## 发布门禁建议
|
||||
|
||||
- 基线:先记录当前回归结果和典型聊天操作耗时。
|
||||
- 开发态:仅显式开启功能开关后显示入口。
|
||||
- 灰度态:限制单用户或指定环境开启,观察至少一个完整统计周期。
|
||||
- 正式态:只有错误率、CPU、内存和 P95 查询耗时满足阈值才默认开启。
|
||||
- 回滚:关闭功能开关;保留独立索引无害,必要时可后续清理。
|
||||
|
||||
## 首版性能阈值
|
||||
|
||||
- 当前约 82 MB 会话数据的专项回填目标不超过 3 秒。
|
||||
- 索引就绪后的本周/本月查询 P95 目标不超过 100 ms。
|
||||
- 专项回填进程峰值 RSS 目标不超过 160 MB。
|
||||
- 旁路索引文件目标不超过源会话 JSON 总量的 35%。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---:|---|
|
||||
| 无 | 1 | — |
|
||||
| `task.py list-context ... implement` 参数不被当前脚本接受 | 1 | `task.py validate` 已确认 context JSONL 合法;后续直接读取任务上下文文件,不重复该命令 |
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
34
.trellis/tasks/08-03-advanced-conversation-search/prd.md
Normal file
34
.trellis/tasks/08-03-advanced-conversation-search/prd.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 高级会话检索
|
||||
|
||||
## 背景
|
||||
|
||||
会话数量增长后,现有侧栏检索只能使用标题、项目、路径和 ID 等元数据,无法通过曾经讨论过的消息正文找回会话。
|
||||
|
||||
## 目标
|
||||
|
||||
保留现有检索不变,在其旁边新增高级检索按钮,打开独立检索工作台,支持搜索历史用户消息和助手回复、查看上下文摘要并跳转到原消息。
|
||||
|
||||
## 功能范围
|
||||
|
||||
- 新增高级检索入口和独立覆盖面板。
|
||||
- 搜索标题、项目、路径、ID、用户消息和助手回复。
|
||||
- 支持相关优先/最新优先、整词/包含。
|
||||
- 展示结果数、耗时、命中来源、摘要和更新时间。
|
||||
- 点击结果打开会话并定位命中消息。
|
||||
- 服务端维护可重建的派生索引,保存、重命名、删除后增量同步。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改变现有检索框、过滤函数和普通会话列表协议。
|
||||
- 不引入拼音、模糊纠错、向量语义检索或外部搜索服务。
|
||||
- 不索引 system、工具参数、工具结果和附件内容。
|
||||
- 不修改现有会话 JSON schema。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 现有检索行为和回归保持不变。
|
||||
- 只出现在历史用户/助手消息里的中英文片段可找到正确会话。
|
||||
- 搜索模式视觉结构与参考图一致:连续结果流、青蓝标题、暖黄命中、弱化时间、无卡片堆叠。
|
||||
- 新消息持久化后 2 秒内可检索,重命名和删除同步生效。
|
||||
- 缓存缺失/损坏可重建,查询 p95 低于 50ms,响应最多 50 条。
|
||||
- 桌面、参考视口和移动端真实浏览器验收通过。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "advanced-search-reference",
|
||||
"role": "完整视觉参考",
|
||||
"original_path": "sessions/_attachments/237582ef-4157-4175-84fa-38629613df7f.png",
|
||||
"archived_path": "references/source-assets/advanced-search-reference.png",
|
||||
"sha256": "39e5b93d26deca64ea88a46b09bed7f7c47d2af733e8f2f857a9d35fc7895692",
|
||||
"size": [733, 673],
|
||||
"format": "PNG RGBA",
|
||||
"usage": "仅用于构图与视觉验收,不进入运行时"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# 高级检索视觉规格
|
||||
|
||||
## 设计方向
|
||||
|
||||
沿用当前主题背景和语义 token,不创建新主题。高级检索是覆盖主工作区的独立操作面,参考图只提供构图、层级和结果阅读方式。
|
||||
|
||||
## 关键尺寸
|
||||
|
||||
- 桌面面板:覆盖 `.chat-main`,最小宽度 0,顶栏 58–64px。
|
||||
- 内容最大宽度:960px;参考视口下左右 46–48px,窄屏下 18px。
|
||||
- 控件高度:24–30px;结果标题与正文间距 12–16px;结果垂直内边距 18–22px。
|
||||
- 摘要最多两段,每段最多约 220 字符;时间位于结果底部右侧。
|
||||
|
||||
## 状态覆盖
|
||||
|
||||
- 未输入、输入不足两个字符、查询中、索引构建中、无结果、错误、有结果。
|
||||
- 长标题、长路径、长摘要、中文/英文、代码片段、特殊 HTML 字符。
|
||||
- 参考视口 733×673、常规桌面 1440×900、移动端 390×844。
|
||||
|
||||
## 安全
|
||||
|
||||
- 标题和摘要只用文本节点渲染,高亮拆分 text/mark 节点。
|
||||
- 尊重 `prefers-reduced-motion`,移动端不使用大幅位移。
|
||||
33
.trellis/tasks/08-03-advanced-conversation-search/task.json
Normal file
33
.trellis/tasks/08-03-advanced-conversation-search/task.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "advanced-conversation-search",
|
||||
"name": "advanced-conversation-search",
|
||||
"title": "高级会话检索",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shiyue",
|
||||
"assignee": "shiyue",
|
||||
"createdAt": "2026-08-03",
|
||||
"completedAt": "2026-08-03",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"lib/session-search-index.js",
|
||||
"server.js",
|
||||
"public/index.html",
|
||||
"public/app.js",
|
||||
"public/style.css",
|
||||
"scripts/regression.js"
|
||||
],
|
||||
"notes": "高级会话检索已完成专项、全量回归和 1672/1440/390 三视口真实 Chromium 验收。",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"检查统计异常不会影响现有功能"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查数据边界、性能和测试"}
|
||||
{"file":".trellis/spec/frontend/state-management.md","reason":"检查看板与聊天状态隔离"}
|
||||
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"检查主题、响应式和可访问性"}
|
||||
{"file":".trellis/tasks/08-03-usage-statistics-dashboard/research/current-findings.md","reason":"检查是否满足用户入口和统计口径约束"}
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"统计模块必须旁路失败并与聊天链路隔离"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"约束统计聚合与协议实现质量"}
|
||||
{"file":".trellis/spec/frontend/state-management.md","reason":"统计工作区必须与聊天状态隔离并可恢复"}
|
||||
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"约束看板 DOM、响应式和交互实现"}
|
||||
{"file":".trellis/tasks/08-03-usage-statistics-dashboard/research/current-findings.md","reason":"提供数据口径、脏工作树和入口位置约束"}
|
||||
58
.trellis/tasks/08-03-usage-statistics-dashboard/prd.md
Normal file
58
.trellis/tasks/08-03-usage-statistics-dashboard/prd.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 使用统计看板 PRD
|
||||
|
||||
## 目标
|
||||
|
||||
为 cc-web 增加一个只读、可回滚的使用统计看板,同时保持聊天、会话列表、会话检索、消息发送和 MCP 执行链路的既有行为不变。
|
||||
|
||||
## 用户入口
|
||||
|
||||
- 入口固定在侧栏底部操作区。
|
||||
- 位于会话列表下方,与现有 `CC-Web` 设置入口同一区域。
|
||||
- 入口不能成为会话列表滚动项。
|
||||
- 点击后切换到独立统计工作区;返回聊天时保留原会话、滚动位置和输入草稿。
|
||||
|
||||
参考图:`sessions/_attachments/750cb7a2-a109-44a9-ae3d-6ca352d4aba0.jpg`。
|
||||
|
||||
## 首版功能
|
||||
|
||||
- 时间范围:本周、本月、自定义范围。
|
||||
- 概览:新建会话、发送消息、MCP 调用、MCP 失败、Skill 显式使用。
|
||||
- 趋势:按日展示上述指标。
|
||||
- MCP:按 server/tool 统计调用、成功、失败,并可查看该工具的调用明细。
|
||||
- Skill:按 `composerMentions` 统计显式 `$skill` 排行。
|
||||
- 会话:展示所选时间范围内的最近会话统计明细。
|
||||
- 页面显示数据范围说明和最后统计时间。
|
||||
|
||||
## 数据口径
|
||||
|
||||
- 只统计当前保留的 `sessions/*.json` 数据。
|
||||
- MCP 历史调用没有独立时间戳,归属到所属 assistant 消息时间。
|
||||
- Skill 只统计用户显式 `$skill` mention,不统计模型实际读取或执行。
|
||||
- 不使用“活跃会话”这一含糊指标。
|
||||
- 自动协作消息只有在现有字段可稳定区分时才单独展示。
|
||||
|
||||
## 架构边界
|
||||
|
||||
- 新增独立统计模块和独立查询协议。
|
||||
- 不修改会话 JSON schema,不回写源会话文件。
|
||||
- 不改变 `session_list`、`search_sessions`、`load_session` 等现有协议。
|
||||
- 不在消息处理同步链路执行统计扫描。
|
||||
- 统计索引和错误必须与聊天功能隔离。
|
||||
- 提供可立即关闭统计入口和查询的功能开关。
|
||||
|
||||
## 前端基线
|
||||
|
||||
- 视觉主张:延续 cc-web 现有外壳,安静、紧凑、可快速扫描。
|
||||
- 内容结构:筛选与刷新 → 核心数字 → 趋势与 MCP 明细 → 状态与 Skill 排行 → 最近会话。
|
||||
- 交互主张:切换保留聊天现场;看板内部刷新;明细返回后保留筛选。
|
||||
- 避免通用 SaaS 卡片拼盘;卡片只用于真正可交互或需要独立语义的区域。
|
||||
|
||||
## 完成标准
|
||||
|
||||
1. 核心统计模块、独立查询协议和完整看板可用。
|
||||
2. 入口位置符合用户截图指定区域。
|
||||
3. 所有现有主题完成样式适配,窄屏可用。
|
||||
4. 现有回归与新增专项回归通过。
|
||||
5. 统计失败或索引损坏不影响消息、会话、搜索和 MCP。
|
||||
6. 真实浏览器完成桌面、移动端及动态状态验收。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 当前调研与实施约束
|
||||
|
||||
- 当前工作树已有高级会话检索的大量未提交修改:`public/app.js`、`public/index.html`、`public/style.css`、`scripts/regression.js`、`server.js`。
|
||||
- 必须保留这些改动,只在稳定锚点附近做小范围追加。
|
||||
- 134 个会话 JSON,总量约 82 MB;一次全量扫描约 0.93 秒、峰值 RSS 约 116 MB。
|
||||
- 现有保留数据中可解析 1,678 次 MCP 调用,其中 completed 1,578、failed 100;工具调用本身没有时间戳。
|
||||
- 显式 Skill mention 当前共 7 次,来源是用户消息的 `composerMentions`。
|
||||
- 推荐旁路:源会话只读 → 可重建索引 → 独立查询 → 独立看板。
|
||||
- 用户指定入口在侧栏底部操作区,不在会话列表滚动内容中。
|
||||
- 主题适配必须使用语义变量和已有主题系统,不复制业务逻辑或第二套看板 DOM。
|
||||
- 最终实现使用 `lib/usage-statistics.js` 的懒加载派生索引,查询协议为 `usage_stats_query/result/error`,schemaVersion 为 1。
|
||||
- 真实数据最终性能:83.3 MB 回填 1.15 秒、查询 P95 51.79 ms、峰值 RSS 116.7 MB、索引占比 0.47%。
|
||||
- Chrome 151 已完成 11 主题 × 5 视口验收,并覆盖 loading、MCP 明细、空态和错误态。
|
||||
26
.trellis/tasks/08-03-usage-statistics-dashboard/task.json
Normal file
26
.trellis/tasks/08-03-usage-statistics-dashboard/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "usage-statistics-dashboard",
|
||||
"name": "usage-statistics-dashboard",
|
||||
"title": "使用统计看板",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shiyue",
|
||||
"assignee": "shiyue",
|
||||
"createdAt": "2026-08-03",
|
||||
"completedAt": "2026-08-03T17:52:38+08:00",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
Binary file not shown.
842
lib/session-search-index.js
Normal file
842
lib/session-search-index.js
Normal file
@@ -0,0 +1,842 @@
|
||||
'use strict';
|
||||
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const CACHE_VERSION = 1;
|
||||
const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_MAX_QUERY_CHARS = 200;
|
||||
const DEFAULT_MAX_RESULTS = 50;
|
||||
const DEFAULT_SNIPPET_CHARS = 220;
|
||||
const MAX_QUERY_CHARS_HARD_LIMIT = 200;
|
||||
const MAX_RESULTS_HARD_LIMIT = 50;
|
||||
const UPSERT_DEBOUNCE_MS = 250;
|
||||
const PERSIST_DEBOUNCE_MS = 500;
|
||||
const BUILD_BATCH_SIZE = 25;
|
||||
|
||||
const SESSION_ID_RE = /^[a-zA-Z0-9-]+$/;
|
||||
|
||||
function isObject(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function clampInteger(value, fallback, min, max) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
const integer = Math.floor(number);
|
||||
if (integer < min) return min;
|
||||
if (integer > max) return max;
|
||||
return integer;
|
||||
}
|
||||
|
||||
function cleanDisplayText(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isWordChar(char) {
|
||||
return /[\p{L}\p{N}_]/u.test(char);
|
||||
}
|
||||
|
||||
function tokenizeSearchText(value) {
|
||||
const text = normalizeSearchText(value);
|
||||
if (!text) return [];
|
||||
const tokens = [];
|
||||
let current = '';
|
||||
for (const char of text) {
|
||||
if (isWordChar(char)) {
|
||||
current += char;
|
||||
} else if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
}
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function uniqueList(values) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of values) {
|
||||
if (!value || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function containsQueryTerms(queryText) {
|
||||
if (!queryText) return [];
|
||||
const parts = queryText.split(/\s+/).filter(Boolean);
|
||||
return uniqueList(parts.length > 1 ? parts : [queryText]);
|
||||
}
|
||||
|
||||
function sanitizeSessionId(sessionId) {
|
||||
return String(sessionId || '').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
}
|
||||
|
||||
function legalSessionId(sessionId) {
|
||||
const id = String(sessionId || '');
|
||||
return !!id && SESSION_ID_RE.test(id);
|
||||
}
|
||||
|
||||
function sessionIdFromFileName(fileName) {
|
||||
if (typeof fileName !== 'string' || !fileName.endsWith('.json')) return '';
|
||||
const id = fileName.slice(0, -5);
|
||||
return legalSessionId(id) ? id : '';
|
||||
}
|
||||
|
||||
function filePathInsideRoot(rootDir, fileName) {
|
||||
const root = path.resolve(rootDir);
|
||||
const target = path.resolve(root, fileName);
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
function sessionFilePath(rootDir, sessionId) {
|
||||
const id = sanitizeSessionId(sessionId);
|
||||
if (!legalSessionId(id)) return null;
|
||||
return filePathInsideRoot(rootDir, `${id}.json`);
|
||||
}
|
||||
|
||||
function sessionIdForDocument(sessionId, fallbackId) {
|
||||
const fallback = String(fallbackId || '');
|
||||
if (legalSessionId(fallback)) return fallback;
|
||||
const id = String(sessionId || '');
|
||||
return legalSessionId(id) ? id : '';
|
||||
}
|
||||
|
||||
function fileFingerprint(stat) {
|
||||
return {
|
||||
dev: Number(stat.dev) || 0,
|
||||
ino: Number(stat.ino) || 0,
|
||||
size: Number(stat.size) || 0,
|
||||
mtimeMs: Number(stat.mtimeMs) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function sameFingerprint(left, right) {
|
||||
return !!left
|
||||
&& !!right
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs;
|
||||
}
|
||||
|
||||
function safeIsoFromValue(value, fallbackMs) {
|
||||
const date = value ? new Date(value) : new Date(fallbackMs || Date.now());
|
||||
if (Number.isNaN(date.getTime())) return new Date(fallbackMs || Date.now()).toISOString();
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function basenameFromCwd(cwd) {
|
||||
const text = cleanDisplayText(cwd);
|
||||
if (!text) return '';
|
||||
return path.basename(text.replace(/[\\/]+$/, ''));
|
||||
}
|
||||
|
||||
function normalizeAgent(agent) {
|
||||
const text = normalizeSearchText(String(agent || ''));
|
||||
return text || 'codex';
|
||||
}
|
||||
|
||||
// 只抽取 message.content 中的可见文本,显式排除工具结果和附件类内容。
|
||||
function messageContentToText(content) {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((part) => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (!isObject(part)) return '';
|
||||
const type = normalizeSearchText(String(part.type || ''));
|
||||
if (type === 'tool_use' || type === 'tool_result' || type === 'image' || type === 'attachment') return '';
|
||||
return typeof part.text === 'string' ? part.text : '';
|
||||
}).filter(Boolean).join('\n');
|
||||
}
|
||||
if (isObject(content) && typeof content.text === 'string') return content.text;
|
||||
return '';
|
||||
}
|
||||
|
||||
function makeSearchField(value, weight) {
|
||||
const text = cleanDisplayText(value);
|
||||
const normalized = normalizeSearchText(text);
|
||||
return {
|
||||
text,
|
||||
normalized,
|
||||
words: tokenizeSearchText(normalized),
|
||||
weight,
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionDocument(session, stat, fallbackId) {
|
||||
const safeSession = isObject(session) ? session : {};
|
||||
const sessionId = sessionIdForDocument(safeSession.id, fallbackId);
|
||||
if (!legalSessionId(sessionId)) return null;
|
||||
|
||||
const cwd = cleanDisplayText(safeSession.cwd || '');
|
||||
const projectName = cleanDisplayText(safeSession.projectName || safeSession.project || basenameFromCwd(cwd));
|
||||
const title = cleanDisplayText(safeSession.title || 'Untitled') || 'Untitled';
|
||||
const updated = safeIsoFromValue(safeSession.updated || safeSession.updatedAt || safeSession.created, stat?.mtimeMs);
|
||||
const created = safeSession.created ? safeIsoFromValue(safeSession.created, stat?.birthtimeMs || stat?.mtimeMs) : null;
|
||||
const agent = normalizeAgent(safeSession.agent);
|
||||
|
||||
const metaFields = [
|
||||
makeSearchField(title, 8),
|
||||
makeSearchField(projectName, 5),
|
||||
makeSearchField(cwd, 3),
|
||||
makeSearchField(sessionId, 4),
|
||||
].filter((field) => field.normalized);
|
||||
|
||||
const messages = [];
|
||||
const sourceMessages = Array.isArray(safeSession.messages) ? safeSession.messages : [];
|
||||
for (let index = 0; index < sourceMessages.length; index += 1) {
|
||||
const message = sourceMessages[index];
|
||||
if (!isObject(message)) continue;
|
||||
const role = normalizeSearchText(String(message.role || ''));
|
||||
if (role !== 'user' && role !== 'assistant') continue;
|
||||
const text = cleanDisplayText(messageContentToText(message.content));
|
||||
if (!text) continue;
|
||||
const normalized = normalizeSearchText(text);
|
||||
if (!normalized) continue;
|
||||
messages.push({
|
||||
messageIndex: index,
|
||||
role,
|
||||
timestamp: message.timestamp || message.created || message.createdAt || null,
|
||||
text,
|
||||
normalized,
|
||||
words: tokenizeSearchText(normalized),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
agent,
|
||||
title,
|
||||
projectName,
|
||||
cwd,
|
||||
updated,
|
||||
created,
|
||||
metaFields,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
function countContains(text, term) {
|
||||
if (!text || !term) return 0;
|
||||
let count = 0;
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
const index = text.indexOf(term, offset);
|
||||
if (index < 0) break;
|
||||
count += 1;
|
||||
offset = index + Math.max(term.length, 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function countWordHits(words, terms) {
|
||||
if (!Array.isArray(words) || !words.length || !terms.length) return 0;
|
||||
const counts = new Map();
|
||||
for (const word of words) counts.set(word, (counts.get(word) || 0) + 1);
|
||||
let hits = 0;
|
||||
for (const term of terms) hits += counts.get(term) || 0;
|
||||
return hits;
|
||||
}
|
||||
|
||||
function fieldMatchScore(field, terms, matchMode) {
|
||||
if (!field || !terms.length) return 0;
|
||||
if (matchMode === 'word') {
|
||||
const hits = countWordHits(field.words, terms);
|
||||
return hits > 0 ? hits * field.weight : 0;
|
||||
}
|
||||
let hits = 0;
|
||||
for (const term of terms) hits += countContains(field.normalized, term);
|
||||
return hits > 0 ? hits * field.weight : 0;
|
||||
}
|
||||
|
||||
function messageMatchScore(message, terms, matchMode) {
|
||||
if (!message || !terms.length) return 0;
|
||||
if (matchMode === 'word') return countWordHits(message.words, terms);
|
||||
let hits = 0;
|
||||
for (const term of terms) hits += countContains(message.normalized, term);
|
||||
return hits;
|
||||
}
|
||||
|
||||
function findSnippetIndex(normalizedText, terms, matchMode) {
|
||||
if (!normalizedText || !terms.length) return 0;
|
||||
if (matchMode === 'word') {
|
||||
let best = -1;
|
||||
for (const term of terms) {
|
||||
const index = normalizedText.indexOf(term);
|
||||
if (index >= 0 && (best < 0 || index < best)) best = index;
|
||||
}
|
||||
return best >= 0 ? best : 0;
|
||||
}
|
||||
let best = -1;
|
||||
for (const term of terms) {
|
||||
const index = normalizedText.indexOf(term);
|
||||
if (index >= 0 && (best < 0 || index < best)) best = index;
|
||||
}
|
||||
return best >= 0 ? best : 0;
|
||||
}
|
||||
|
||||
function makeSnippet(text, terms, matchMode, snippetChars) {
|
||||
const display = cleanDisplayText(text);
|
||||
if (!display) return '';
|
||||
const limit = clampInteger(snippetChars, DEFAULT_SNIPPET_CHARS, 80, 1000);
|
||||
if (display.length <= limit) return display;
|
||||
const normalized = normalizeSearchText(display);
|
||||
const matchIndex = findSnippetIndex(normalized, terms, matchMode);
|
||||
const half = Math.floor(limit / 2);
|
||||
let start = Math.max(0, matchIndex - half);
|
||||
let end = Math.min(display.length, start + limit);
|
||||
start = Math.max(0, end - limit);
|
||||
const prefix = start > 0 ? '...' : '';
|
||||
const suffix = end < display.length ? '...' : '';
|
||||
return `${prefix}${display.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
function buildMetadataSnippet(doc) {
|
||||
return [
|
||||
doc.title,
|
||||
doc.projectName,
|
||||
doc.cwd,
|
||||
doc.sessionId,
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function updatedTime(doc) {
|
||||
const time = new Date(doc?.updated || 0).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function buildQuery(rawQuery, options) {
|
||||
const maxQueryChars = clampInteger(
|
||||
options.maxQueryChars,
|
||||
DEFAULT_MAX_QUERY_CHARS,
|
||||
1,
|
||||
MAX_QUERY_CHARS_HARD_LIMIT,
|
||||
);
|
||||
const queryText = normalizeSearchText(String(rawQuery || '').slice(0, maxQueryChars));
|
||||
const matchMode = options.matchMode === 'word' ? 'word' : 'contains';
|
||||
const terms = matchMode === 'word' ? uniqueList(tokenizeSearchText(queryText)) : containsQueryTerms(queryText);
|
||||
return { queryText, terms, matchMode, maxQueryChars };
|
||||
}
|
||||
|
||||
function sleepImmediate() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function timerUnref(timer) {
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
}
|
||||
|
||||
function safeError(err) {
|
||||
return String(err?.message || err || '').slice(0, 240);
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(filePath, data) {
|
||||
const dir = path.dirname(filePath);
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
||||
const json = `${JSON.stringify(data)}\n`;
|
||||
try {
|
||||
// 缓存包含派生文本,按私有文件写入后再原子替换。
|
||||
await fsp.writeFile(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
|
||||
await fsp.rename(tmpPath, filePath);
|
||||
await fsp.chmod(filePath, 0o600).catch(() => {});
|
||||
} catch (err) {
|
||||
await fsp.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionSearchIndex(options = {}) {
|
||||
const sessionsDir = path.resolve(options.sessionsDir || path.join(process.cwd(), 'sessions'));
|
||||
const cacheFile = path.resolve(
|
||||
options.cacheFile
|
||||
? (path.isAbsolute(options.cacheFile) ? options.cacheFile : path.join(sessionsDir, options.cacheFile))
|
||||
: path.join(sessionsDir, '_search', 'index-v1.json'),
|
||||
);
|
||||
const maxFileBytes = clampInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 1024, Number.MAX_SAFE_INTEGER);
|
||||
const maxQueryChars = clampInteger(options.maxQueryChars, DEFAULT_MAX_QUERY_CHARS, 1, MAX_QUERY_CHARS_HARD_LIMIT);
|
||||
const maxResults = clampInteger(options.maxResults, DEFAULT_MAX_RESULTS, 1, MAX_RESULTS_HARD_LIMIT);
|
||||
const snippetChars = clampInteger(options.snippetChars, DEFAULT_SNIPPET_CHARS, 80, 1000);
|
||||
const logger = options.logger || null;
|
||||
|
||||
const documents = new Map();
|
||||
const fileMeta = new Map();
|
||||
const upsertTimers = new Map();
|
||||
let state = 'ready';
|
||||
let lastError = null;
|
||||
let initialized = false;
|
||||
let buildPromise = null;
|
||||
let dirty = false;
|
||||
let persistTimer = null;
|
||||
let persistPromise = null;
|
||||
let lastPersistedAt = null;
|
||||
let lastBuiltAt = null;
|
||||
let skippedFiles = 0;
|
||||
let failedFiles = 0;
|
||||
|
||||
function log(level, event, details = {}) {
|
||||
if (!logger) return;
|
||||
try {
|
||||
const fn = logger[level] || logger.warn || logger.log;
|
||||
if (typeof fn === 'function') fn.call(logger, event, details);
|
||||
} catch {
|
||||
// 日志不能影响索引主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function serializeCache() {
|
||||
return {
|
||||
version: CACHE_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sessionsDir,
|
||||
files: Object.fromEntries(fileMeta.entries()),
|
||||
sessions: Object.fromEntries(documents.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
function applyCache(cache) {
|
||||
if (!isObject(cache) || cache.version !== CACHE_VERSION || !isObject(cache.files) || !isObject(cache.sessions)) {
|
||||
return false;
|
||||
}
|
||||
documents.clear();
|
||||
fileMeta.clear();
|
||||
for (const [sessionId, meta] of Object.entries(cache.files)) {
|
||||
if (!legalSessionId(sessionId) || !isObject(meta)) continue;
|
||||
fileMeta.set(sessionId, {
|
||||
dev: Number(meta.dev) || 0,
|
||||
ino: Number(meta.ino) || 0,
|
||||
size: Number(meta.size) || 0,
|
||||
mtimeMs: Number(meta.mtimeMs) || 0,
|
||||
});
|
||||
}
|
||||
for (const [sessionId, doc] of Object.entries(cache.sessions)) {
|
||||
if (!legalSessionId(sessionId) || !isObject(doc) || doc.sessionId !== sessionId) continue;
|
||||
documents.set(sessionId, doc);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadCache() {
|
||||
try {
|
||||
const raw = await fsp.readFile(cacheFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!applyCache(parsed)) {
|
||||
log('warn', 'session_search_cache_ignored', { reason: 'version_or_shape' });
|
||||
documents.clear();
|
||||
fileMeta.clear();
|
||||
dirty = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && err.code !== 'ENOENT') {
|
||||
log('warn', 'session_search_cache_load_failed', { error: safeError(err) });
|
||||
}
|
||||
documents.clear();
|
||||
fileMeta.clear();
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSessionFiles() {
|
||||
await fsp.mkdir(sessionsDir, { recursive: true });
|
||||
const entries = await fsp.readdir(sessionsDir, { withFileTypes: true });
|
||||
const files = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const sessionId = sessionIdFromFileName(entry.name);
|
||||
if (!sessionId) continue;
|
||||
const filePath = filePathInsideRoot(sessionsDir, entry.name);
|
||||
if (!filePath) continue;
|
||||
try {
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
files.set(sessionId, { filePath, stat, fingerprint: fileFingerprint(stat) });
|
||||
} catch (err) {
|
||||
failedFiles += 1;
|
||||
log('warn', 'session_search_stat_failed', { sessionId: sessionId.slice(0, 8), error: safeError(err) });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function readAndIndexFile(sessionId, filePath, stat, fingerprint) {
|
||||
if (stat.size > maxFileBytes) {
|
||||
skippedFiles += 1;
|
||||
documents.delete(sessionId);
|
||||
fileMeta.delete(sessionId);
|
||||
dirty = true;
|
||||
log('warn', 'session_search_file_skipped', { sessionId: sessionId.slice(0, 8), reason: 'max_file_bytes', size: stat.size });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(raw);
|
||||
const doc = createSessionDocument(session, stat, sessionId);
|
||||
if (!doc) {
|
||||
documents.delete(sessionId);
|
||||
fileMeta.delete(sessionId);
|
||||
dirty = true;
|
||||
return;
|
||||
}
|
||||
documents.set(sessionId, doc);
|
||||
fileMeta.set(sessionId, fingerprint);
|
||||
dirty = true;
|
||||
} catch (err) {
|
||||
failedFiles += 1;
|
||||
documents.delete(sessionId);
|
||||
fileMeta.delete(sessionId);
|
||||
dirty = true;
|
||||
log('warn', 'session_search_file_index_failed', { sessionId: sessionId.slice(0, 8), error: safeError(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function markDirtyAndSchedulePersist() {
|
||||
dirty = true;
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null;
|
||||
persistNow().catch((err) => {
|
||||
state = 'error';
|
||||
lastError = safeError(err);
|
||||
log('error', 'session_search_cache_persist_failed', { error: lastError });
|
||||
});
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
timerUnref(persistTimer);
|
||||
}
|
||||
|
||||
async function persistNow() {
|
||||
if (persistPromise) return persistPromise;
|
||||
persistPromise = (async () => {
|
||||
while (dirty) {
|
||||
dirty = false;
|
||||
await writeJsonAtomic(cacheFile, serializeCache());
|
||||
lastPersistedAt = new Date().toISOString();
|
||||
}
|
||||
})().finally(() => {
|
||||
persistPromise = null;
|
||||
});
|
||||
return persistPromise;
|
||||
}
|
||||
|
||||
async function rebuildChangedFiles() {
|
||||
state = 'building';
|
||||
lastError = null;
|
||||
skippedFiles = 0;
|
||||
failedFiles = 0;
|
||||
await loadCache();
|
||||
const scanned = await scanSessionFiles();
|
||||
|
||||
// 以 sessions 根目录下当前合法 JSON 文件为准,删除派生缓存里的孤儿项。
|
||||
for (const sessionId of Array.from(documents.keys())) {
|
||||
if (!scanned.has(sessionId)) {
|
||||
documents.delete(sessionId);
|
||||
fileMeta.delete(sessionId);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
for (const sessionId of Array.from(fileMeta.keys())) {
|
||||
if (!scanned.has(sessionId)) {
|
||||
fileMeta.delete(sessionId);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
const changed = [];
|
||||
for (const [sessionId, entry] of scanned.entries()) {
|
||||
if (!sameFingerprint(fileMeta.get(sessionId), entry.fingerprint) || !documents.has(sessionId)) {
|
||||
changed.push([sessionId, entry]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < changed.length; index += 1) {
|
||||
const [sessionId, entry] = changed[index];
|
||||
await readAndIndexFile(sessionId, entry.filePath, entry.stat, entry.fingerprint);
|
||||
if ((index + 1) % BUILD_BATCH_SIZE === 0) await sleepImmediate();
|
||||
}
|
||||
|
||||
if (dirty) await persistNow();
|
||||
lastBuiltAt = new Date().toISOString();
|
||||
state = 'ready';
|
||||
initialized = true;
|
||||
return status();
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (buildPromise) return buildPromise;
|
||||
buildPromise = rebuildChangedFiles()
|
||||
.catch((err) => {
|
||||
state = 'error';
|
||||
lastError = safeError(err);
|
||||
log('error', 'session_search_initialize_failed', { error: lastError });
|
||||
return status();
|
||||
})
|
||||
.finally(() => {
|
||||
buildPromise = null;
|
||||
});
|
||||
return buildPromise;
|
||||
}
|
||||
|
||||
async function upsertNow(sessionId) {
|
||||
const id = sanitizeSessionId(sessionId);
|
||||
if (!legalSessionId(id)) return false;
|
||||
const filePath = sessionFilePath(sessionsDir, id);
|
||||
if (!filePath) return false;
|
||||
try {
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
documents.delete(id);
|
||||
fileMeta.delete(id);
|
||||
markDirtyAndSchedulePersist();
|
||||
return true;
|
||||
}
|
||||
await readAndIndexFile(id, filePath, stat, fileFingerprint(stat));
|
||||
markDirtyAndSchedulePersist();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') {
|
||||
documents.delete(id);
|
||||
fileMeta.delete(id);
|
||||
markDirtyAndSchedulePersist();
|
||||
return true;
|
||||
}
|
||||
failedFiles += 1;
|
||||
log('warn', 'session_search_upsert_failed', { sessionId: id.slice(0, 8), error: safeError(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpsert(sessionId) {
|
||||
const id = sanitizeSessionId(sessionId);
|
||||
if (!legalSessionId(id)) return false;
|
||||
const existing = upsertTimers.get(id);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
upsertTimers.delete(id);
|
||||
upsertNow(id).catch((err) => {
|
||||
state = 'error';
|
||||
lastError = safeError(err);
|
||||
log('error', 'session_search_schedule_upsert_failed', { sessionId: id.slice(0, 8), error: lastError });
|
||||
});
|
||||
}, UPSERT_DEBOUNCE_MS);
|
||||
timerUnref(timer);
|
||||
upsertTimers.set(id, timer);
|
||||
return true;
|
||||
}
|
||||
|
||||
function remove(sessionId) {
|
||||
const id = sanitizeSessionId(sessionId);
|
||||
if (!legalSessionId(id)) return false;
|
||||
const timer = upsertTimers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
upsertTimers.delete(id);
|
||||
const hadDocument = documents.delete(id);
|
||||
const hadFileMeta = fileMeta.delete(id);
|
||||
const existed = hadDocument || hadFileMeta;
|
||||
if (existed) markDirtyAndSchedulePersist();
|
||||
return existed;
|
||||
}
|
||||
|
||||
function docContainsAllTerms(doc, terms, matchMode) {
|
||||
if (!terms.length) return false;
|
||||
if (matchMode === 'word') {
|
||||
// word 模式用受控 tokenizer,不用用户输入拼接正则。
|
||||
const seen = new Set();
|
||||
for (const field of doc.metaFields || []) {
|
||||
for (const word of field.words || []) seen.add(word);
|
||||
}
|
||||
for (const message of doc.messages || []) {
|
||||
for (const word of message.words || []) seen.add(word);
|
||||
}
|
||||
return terms.every((term) => seen.has(term));
|
||||
}
|
||||
|
||||
for (const term of terms) {
|
||||
let found = false;
|
||||
for (const field of doc.metaFields || []) {
|
||||
if (field.normalized.includes(term)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
for (const message of doc.messages || []) {
|
||||
if (message.normalized.includes(term)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildSearchResult(doc, terms, matchMode) {
|
||||
let score = 0;
|
||||
let metaMatched = false;
|
||||
for (const field of doc.metaFields || []) {
|
||||
const fieldScore = fieldMatchScore(field, terms, matchMode);
|
||||
if (fieldScore > 0) metaMatched = true;
|
||||
score += fieldScore;
|
||||
}
|
||||
|
||||
const matchedMessages = [];
|
||||
for (const message of doc.messages || []) {
|
||||
const messageScore = messageMatchScore(message, terms, matchMode);
|
||||
if (messageScore <= 0) continue;
|
||||
score += messageScore;
|
||||
matchedMessages.push({ message, score: messageScore });
|
||||
}
|
||||
|
||||
matchedMessages.sort((a, b) => b.score - a.score || a.message.messageIndex - b.message.messageIndex);
|
||||
const matches = [];
|
||||
for (const entry of matchedMessages.slice(0, 2)) {
|
||||
matches.push({
|
||||
sessionId: doc.sessionId,
|
||||
messageIndex: entry.message.messageIndex,
|
||||
role: entry.message.role,
|
||||
timestamp: entry.message.timestamp || null,
|
||||
snippet: makeSnippet(entry.message.text, terms, matchMode, snippetChars),
|
||||
score: entry.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (!matches.length && metaMatched) {
|
||||
matches.push({
|
||||
sessionId: doc.sessionId,
|
||||
messageIndex: null,
|
||||
role: 'metadata',
|
||||
timestamp: doc.updated,
|
||||
snippet: makeSnippet(buildMetadataSnippet(doc), terms, matchMode, snippetChars),
|
||||
score,
|
||||
});
|
||||
}
|
||||
|
||||
const firstMatch = matches[0] || null;
|
||||
return {
|
||||
sessionId: doc.sessionId,
|
||||
messageIndex: firstMatch ? firstMatch.messageIndex : null,
|
||||
role: firstMatch ? firstMatch.role : null,
|
||||
timestamp: firstMatch ? firstMatch.timestamp : null,
|
||||
title: doc.title,
|
||||
projectName: doc.projectName,
|
||||
updated: doc.updated,
|
||||
score,
|
||||
matchedMessageCount: matchedMessages.length,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
|
||||
function search(params = {}) {
|
||||
const started = process.hrtime.bigint();
|
||||
const query = buildQuery(params.query, {
|
||||
maxQueryChars: Math.min(maxQueryChars, MAX_QUERY_CHARS_HARD_LIMIT),
|
||||
matchMode: params.matchMode,
|
||||
});
|
||||
const limit = clampInteger(params.limit, maxResults, 1, Math.min(maxResults, MAX_RESULTS_HARD_LIMIT));
|
||||
const sort = params.sort === 'newest' ? 'newest' : 'relevance';
|
||||
const agentFilter = params.agent ? normalizeAgent(params.agent) : '';
|
||||
|
||||
if (!query.queryText || !query.terms.length) {
|
||||
return {
|
||||
total: 0,
|
||||
tookMs: Number((process.hrtime.bigint() - started) / 1000000n),
|
||||
indexState: state,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const doc of documents.values()) {
|
||||
if (agentFilter && doc.agent !== agentFilter) continue;
|
||||
if (!docContainsAllTerms(doc, query.terms, query.matchMode)) continue;
|
||||
const result = buildSearchResult(doc, query.terms, query.matchMode);
|
||||
if (result.score <= 0) continue;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (sort === 'newest') return updatedTime(b) - updatedTime(a) || b.score - a.score || a.sessionId.localeCompare(b.sessionId);
|
||||
return b.score - a.score || updatedTime(b) - updatedTime(a) || a.sessionId.localeCompare(b.sessionId);
|
||||
});
|
||||
|
||||
return {
|
||||
total: results.length,
|
||||
tookMs: Number((process.hrtime.bigint() - started) / 1000000n),
|
||||
indexState: state,
|
||||
results: results.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (buildPromise) await buildPromise;
|
||||
const pendingIds = Array.from(upsertTimers.keys());
|
||||
for (const id of pendingIds) {
|
||||
const timer = upsertTimers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
upsertTimers.delete(id);
|
||||
await upsertNow(id);
|
||||
}
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = null;
|
||||
}
|
||||
if (dirty) await persistNow();
|
||||
if (persistPromise) await persistPromise;
|
||||
return status();
|
||||
}
|
||||
|
||||
function status() {
|
||||
return {
|
||||
state,
|
||||
building: state === 'building',
|
||||
ready: state === 'ready',
|
||||
initialized,
|
||||
error: lastError,
|
||||
sessionsDir,
|
||||
cacheFile,
|
||||
indexedSessions: documents.size,
|
||||
trackedFiles: fileMeta.size,
|
||||
pendingUpserts: upsertTimers.size,
|
||||
dirty,
|
||||
skippedFiles,
|
||||
failedFiles,
|
||||
lastBuiltAt,
|
||||
lastPersistedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
scheduleUpsert,
|
||||
remove,
|
||||
search,
|
||||
status,
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSessionSearchIndex,
|
||||
normalizeSearchText,
|
||||
tokenizeSearchText,
|
||||
sanitizeSessionId,
|
||||
createSessionDocument,
|
||||
};
|
||||
859
lib/usage-statistics.js
Normal file
859
lib/usage-statistics.js
Normal file
@@ -0,0 +1,859 @@
|
||||
'use strict';
|
||||
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const CACHE_VERSION = 1;
|
||||
const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_MAX_RANGE_DAYS = 370;
|
||||
const DEFAULT_RECENT_LIMIT = 50;
|
||||
const DEFAULT_MCP_TOOL_LIMIT = 200;
|
||||
const DEFAULT_SKILL_LIMIT = 100;
|
||||
const DEFAULT_MCP_DETAIL_LIMIT = 250;
|
||||
const BUILD_BATCH_SIZE = 1;
|
||||
const UPSERT_DEBOUNCE_MS = 300;
|
||||
const PERSIST_DEBOUNCE_MS = 600;
|
||||
const SESSION_ID_RE = /^[a-zA-Z0-9-]+$/;
|
||||
|
||||
class UsageStatisticsError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = 'UsageStatisticsError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function clampInteger(value, fallback, min, max) {
|
||||
const number = Number.parseInt(String(value ?? ''), 10);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(min, Math.min(max, number));
|
||||
}
|
||||
|
||||
function cleanDisplayText(value, maxChars = 240) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value
|
||||
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxChars);
|
||||
}
|
||||
|
||||
function sanitizeSessionId(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
}
|
||||
|
||||
function legalSessionId(value) {
|
||||
return !!value && SESSION_ID_RE.test(String(value));
|
||||
}
|
||||
|
||||
function safeTimestamp(value) {
|
||||
if (!value) return null;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function safeIso(value, fallbackMs = null) {
|
||||
const timestamp = safeTimestamp(value);
|
||||
if (timestamp !== null) return new Date(timestamp).toISOString();
|
||||
return Number.isFinite(fallbackMs) ? new Date(fallbackMs).toISOString() : null;
|
||||
}
|
||||
|
||||
function basenameFromCwd(value) {
|
||||
const cwd = cleanDisplayText(value, 1200);
|
||||
return cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '';
|
||||
}
|
||||
|
||||
function normalizeAgent(value) {
|
||||
return cleanDisplayText(String(value || 'codex'), 40).toLowerCase() || 'codex';
|
||||
}
|
||||
|
||||
function parseMaybeObject(value) {
|
||||
if (isObject(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return isObject(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMcpStatus(value, done = false) {
|
||||
const status = String(value || '').trim().toLowerCase().replace(/[\s_-]+/g, '');
|
||||
if (status === 'failed' || status === 'error') return 'failed';
|
||||
if (status === 'completed' || status === 'complete' || status === 'succeeded' || status === 'success') {
|
||||
return 'completed';
|
||||
}
|
||||
if (status === 'cancelled' || status === 'canceled') return 'cancelled';
|
||||
if (status === 'inprogress' || status === 'running' || status === 'pending') return 'in_progress';
|
||||
return done ? 'completed' : 'in_progress';
|
||||
}
|
||||
|
||||
function splitMcpSubtitle(value) {
|
||||
const subtitle = cleanDisplayText(value, 320);
|
||||
const index = subtitle.indexOf('.');
|
||||
if (index <= 0 || index >= subtitle.length - 1) return null;
|
||||
return {
|
||||
server: subtitle.slice(0, index),
|
||||
tool: subtitle.slice(index + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function extractMcpToolCall(toolCall) {
|
||||
if (!isObject(toolCall)) return null;
|
||||
const meta = isObject(toolCall.meta) ? toolCall.meta : {};
|
||||
const compactKind = String(toolCall.kind || meta.kind || '').toLowerCase().replace(/[\s_-]+/g, '');
|
||||
const compactName = String(toolCall.name || '').toLowerCase().replace(/[\s_-]+/g, '');
|
||||
const input = parseMaybeObject(toolCall.input) || {};
|
||||
const looksLikeMcp = compactKind === 'mcptoolcall'
|
||||
|| compactName === 'mcptoolcall'
|
||||
|| (typeof toolCall.name === 'string' && toolCall.name.startsWith('mcp__'))
|
||||
|| (!!input.server && !!input.tool);
|
||||
if (!looksLikeMcp) return null;
|
||||
|
||||
let server = cleanDisplayText(String(input.server || ''), 160);
|
||||
let tool = cleanDisplayText(String(input.tool || ''), 200);
|
||||
if ((!server || !tool) && typeof toolCall.name === 'string' && toolCall.name.startsWith('mcp__')) {
|
||||
const parts = toolCall.name.split('__');
|
||||
if (parts.length >= 3) {
|
||||
server ||= cleanDisplayText(parts[1], 160);
|
||||
tool ||= cleanDisplayText(parts.slice(2).join('__'), 200);
|
||||
}
|
||||
}
|
||||
if (!server || !tool) {
|
||||
const subtitle = splitMcpSubtitle(meta.subtitle);
|
||||
if (subtitle) {
|
||||
server ||= subtitle.server;
|
||||
tool ||= subtitle.tool;
|
||||
}
|
||||
}
|
||||
if (!server || !tool) return null;
|
||||
return {
|
||||
server,
|
||||
tool,
|
||||
status: normalizeMcpStatus(meta.status || toolCall.status, !!toolCall.done),
|
||||
};
|
||||
}
|
||||
|
||||
function extractSkillMention(mention) {
|
||||
if (!isObject(mention) || String(mention.kind || '').toLowerCase() !== 'skill') return null;
|
||||
const name = cleanDisplayText(String(mention.name || mention.title || mention.label || ''), 160).replace(/^\$/, '');
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
label: cleanDisplayText(String(mention.label || mention.title || `$${name}`), 180) || `$${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
function fileFingerprint(stat) {
|
||||
return {
|
||||
dev: Number(stat?.dev) || 0,
|
||||
ino: Number(stat?.ino) || 0,
|
||||
size: Number(stat?.size) || 0,
|
||||
mtimeMs: Number(stat?.mtimeMs) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function sameFingerprint(left, right) {
|
||||
return !!left
|
||||
&& !!right
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs;
|
||||
}
|
||||
|
||||
function createUsageDocument(session, stat, fallbackId) {
|
||||
const source = isObject(session) ? session : {};
|
||||
const fallback = sanitizeSessionId(fallbackId);
|
||||
const sourceId = sanitizeSessionId(source.id);
|
||||
const sessionId = legalSessionId(fallback) ? fallback : sourceId;
|
||||
if (!legalSessionId(sessionId)) return null;
|
||||
|
||||
const created = safeIso(source.created || source.createdAt, stat?.birthtimeMs || stat?.mtimeMs);
|
||||
const updated = safeIso(source.updated || source.updatedAt, stat?.mtimeMs) || created;
|
||||
const cwd = cleanDisplayText(String(source.cwd || ''), 1200);
|
||||
const title = cleanDisplayText(String(source.title || '未命名会话'), 240) || '未命名会话';
|
||||
const events = [];
|
||||
const messages = Array.isArray(source.messages) ? source.messages : [];
|
||||
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (!isObject(message)) continue;
|
||||
const timestampMs = safeTimestamp(message.timestamp || message.created || message.createdAt);
|
||||
if (timestampMs === null) continue;
|
||||
const timestamp = new Date(timestampMs).toISOString();
|
||||
const role = String(message.role || '').toLowerCase();
|
||||
|
||||
if (role === 'user') {
|
||||
events.push({
|
||||
type: 'message',
|
||||
timestamp,
|
||||
channel: message.crossConversation ? 'cross_conversation' : 'direct',
|
||||
});
|
||||
for (const mention of Array.isArray(message.composerMentions) ? message.composerMentions : []) {
|
||||
const skill = extractSkillMention(mention);
|
||||
if (!skill) continue;
|
||||
events.push({ type: 'skill', timestamp, name: skill.name, label: skill.label });
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
for (const toolCall of Array.isArray(message.toolCalls) ? message.toolCalls : []) {
|
||||
const mcp = extractMcpToolCall(toolCall);
|
||||
if (!mcp) continue;
|
||||
events.push({
|
||||
type: 'mcp',
|
||||
timestamp,
|
||||
server: mcp.server,
|
||||
tool: mcp.tool,
|
||||
status: mcp.status,
|
||||
messageIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
title,
|
||||
cwd,
|
||||
projectName: cleanDisplayText(String(source.projectName || source.project || basenameFromCwd(cwd)), 240),
|
||||
agent: normalizeAgent(source.agent),
|
||||
created,
|
||||
updated,
|
||||
sourceMessageCount: messages.length,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function validateTimeZone(value) {
|
||||
const timeZone = cleanDisplayText(String(value || 'UTC'), 80) || 'UTC';
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone }).format(0);
|
||||
} catch {
|
||||
throw new UsageStatisticsError('invalid_time_zone', '无效的时区');
|
||||
}
|
||||
return timeZone;
|
||||
}
|
||||
|
||||
function normalizeUsageRange(params = {}, maxRangeDays = DEFAULT_MAX_RANGE_DAYS) {
|
||||
const fromMs = safeTimestamp(params.from);
|
||||
const toMs = safeTimestamp(params.to);
|
||||
if (fromMs === null || toMs === null) {
|
||||
throw new UsageStatisticsError('invalid_range', '统计时间范围无效');
|
||||
}
|
||||
if (fromMs >= toMs) {
|
||||
throw new UsageStatisticsError('invalid_range', '统计开始时间必须早于结束时间');
|
||||
}
|
||||
const maxMs = Math.max(1, maxRangeDays) * 24 * 60 * 60 * 1000;
|
||||
if (toMs - fromMs > maxMs) {
|
||||
throw new UsageStatisticsError('range_too_large', `统计时间范围不能超过 ${maxRangeDays} 天`);
|
||||
}
|
||||
return {
|
||||
fromMs,
|
||||
toMs,
|
||||
from: new Date(fromMs).toISOString(),
|
||||
to: new Date(toMs).toISOString(),
|
||||
timeZone: validateTimeZone(params.timeZone),
|
||||
bucket: 'day',
|
||||
};
|
||||
}
|
||||
|
||||
function createDayKeyFormatter(timeZone) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return (timestampMs) => {
|
||||
const parts = formatter.formatToParts(new Date(timestampMs));
|
||||
const values = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'literal') values[part.type] = part.value;
|
||||
}
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
};
|
||||
}
|
||||
|
||||
function createTrendBucket(date) {
|
||||
return {
|
||||
date,
|
||||
newSessions: 0,
|
||||
messages: 0,
|
||||
directMessages: 0,
|
||||
crossConversationMessages: 0,
|
||||
mcpCalls: 0,
|
||||
mcpFailures: 0,
|
||||
skillMentions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createTrendBuckets(range, dayKey) {
|
||||
const keys = new Set([dayKey(range.fromMs), dayKey(range.toMs - 1)]);
|
||||
const halfDayMs = 12 * 60 * 60 * 1000;
|
||||
for (let cursor = range.fromMs; cursor < range.toMs; cursor += halfDayMs) {
|
||||
keys.add(dayKey(cursor));
|
||||
}
|
||||
const map = new Map();
|
||||
Array.from(keys).sort().forEach((key) => map.set(key, createTrendBucket(key)));
|
||||
return map;
|
||||
}
|
||||
|
||||
function eventInRange(timestamp, range) {
|
||||
const value = safeTimestamp(timestamp);
|
||||
return value !== null && value >= range.fromMs && value < range.toMs ? value : null;
|
||||
}
|
||||
|
||||
function aggregateUsageStatistics(documents, params = {}, meta = {}) {
|
||||
const range = normalizeUsageRange(params, meta.maxRangeDays || DEFAULT_MAX_RANGE_DAYS);
|
||||
const recentLimit = clampInteger(params.recentLimit, DEFAULT_RECENT_LIMIT, 1, 100);
|
||||
const mcpToolLimit = clampInteger(params.mcpToolLimit, DEFAULT_MCP_TOOL_LIMIT, 1, 500);
|
||||
const skillLimit = clampInteger(params.skillLimit, DEFAULT_SKILL_LIMIT, 1, 500);
|
||||
const mcpDetailLimit = clampInteger(params.mcpDetailLimit, DEFAULT_MCP_DETAIL_LIMIT, 1, 500);
|
||||
const dayKey = createDayKeyFormatter(range.timeZone);
|
||||
const trendMap = createTrendBuckets(range, dayKey);
|
||||
const overview = {
|
||||
newSessions: 0,
|
||||
messages: 0,
|
||||
directMessages: 0,
|
||||
crossConversationMessages: 0,
|
||||
mcpCalls: 0,
|
||||
mcpFailures: 0,
|
||||
skillMentions: 0,
|
||||
};
|
||||
const mcpStatus = { completed: 0, failed: 0, other: 0 };
|
||||
const mcpTools = new Map();
|
||||
const skills = new Map();
|
||||
const mcpRecentCalls = [];
|
||||
const recentSessions = [];
|
||||
let retainedMessages = 0;
|
||||
|
||||
for (const doc of documents || []) {
|
||||
if (!doc || !doc.sessionId) continue;
|
||||
retainedMessages += Math.max(0, Number(doc.sourceMessageCount || 0));
|
||||
const sessionSummary = {
|
||||
sessionId: doc.sessionId,
|
||||
title: doc.title || '未命名会话',
|
||||
agent: doc.agent || 'codex',
|
||||
cwd: doc.cwd || '',
|
||||
projectName: doc.projectName || '',
|
||||
created: doc.created || null,
|
||||
lastActivity: null,
|
||||
newSession: false,
|
||||
messages: 0,
|
||||
directMessages: 0,
|
||||
crossConversationMessages: 0,
|
||||
mcpCalls: 0,
|
||||
mcpFailures: 0,
|
||||
skillMentions: 0,
|
||||
};
|
||||
|
||||
const createdMs = eventInRange(doc.created, range);
|
||||
if (createdMs !== null) {
|
||||
overview.newSessions += 1;
|
||||
sessionSummary.newSession = true;
|
||||
sessionSummary.lastActivity = new Date(createdMs).toISOString();
|
||||
const bucket = trendMap.get(dayKey(createdMs));
|
||||
if (bucket) bucket.newSessions += 1;
|
||||
}
|
||||
|
||||
for (const event of Array.isArray(doc.events) ? doc.events : []) {
|
||||
const timestampMs = eventInRange(event.timestamp, range);
|
||||
if (timestampMs === null) continue;
|
||||
const timestamp = new Date(timestampMs).toISOString();
|
||||
if (!sessionSummary.lastActivity || timestamp > sessionSummary.lastActivity) {
|
||||
sessionSummary.lastActivity = timestamp;
|
||||
}
|
||||
const bucket = trendMap.get(dayKey(timestampMs));
|
||||
if (!bucket) continue;
|
||||
|
||||
if (event.type === 'message') {
|
||||
overview.messages += 1;
|
||||
sessionSummary.messages += 1;
|
||||
bucket.messages += 1;
|
||||
if (event.channel === 'cross_conversation') {
|
||||
overview.crossConversationMessages += 1;
|
||||
sessionSummary.crossConversationMessages += 1;
|
||||
bucket.crossConversationMessages += 1;
|
||||
} else {
|
||||
overview.directMessages += 1;
|
||||
sessionSummary.directMessages += 1;
|
||||
bucket.directMessages += 1;
|
||||
}
|
||||
} else if (event.type === 'mcp') {
|
||||
overview.mcpCalls += 1;
|
||||
sessionSummary.mcpCalls += 1;
|
||||
bucket.mcpCalls += 1;
|
||||
const failed = event.status === 'failed';
|
||||
if (failed) {
|
||||
overview.mcpFailures += 1;
|
||||
sessionSummary.mcpFailures += 1;
|
||||
bucket.mcpFailures += 1;
|
||||
mcpStatus.failed += 1;
|
||||
} else if (event.status === 'completed') {
|
||||
mcpStatus.completed += 1;
|
||||
} else {
|
||||
mcpStatus.other += 1;
|
||||
}
|
||||
const key = `${event.server}\u0000${event.tool}`;
|
||||
const current = mcpTools.get(key) || {
|
||||
key: `${event.server}/${event.tool}`,
|
||||
server: event.server,
|
||||
tool: event.tool,
|
||||
calls: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
other: 0,
|
||||
successRate: 0,
|
||||
lastUsedAt: null,
|
||||
};
|
||||
current.calls += 1;
|
||||
if (failed) current.failed += 1;
|
||||
else if (event.status === 'completed') current.completed += 1;
|
||||
else current.other += 1;
|
||||
if (!current.lastUsedAt || timestamp > current.lastUsedAt) current.lastUsedAt = timestamp;
|
||||
mcpTools.set(key, current);
|
||||
mcpRecentCalls.push({
|
||||
timestamp,
|
||||
server: event.server,
|
||||
tool: event.tool,
|
||||
status: event.status,
|
||||
sessionId: doc.sessionId,
|
||||
sessionTitle: doc.title || '未命名会话',
|
||||
agent: doc.agent || 'codex',
|
||||
messageIndex: Number.isFinite(Number(event.messageIndex)) ? Number(event.messageIndex) : null,
|
||||
});
|
||||
} else if (event.type === 'skill') {
|
||||
overview.skillMentions += 1;
|
||||
sessionSummary.skillMentions += 1;
|
||||
bucket.skillMentions += 1;
|
||||
const name = cleanDisplayText(String(event.name || ''), 160);
|
||||
if (!name) continue;
|
||||
const current = skills.get(name) || {
|
||||
name,
|
||||
label: cleanDisplayText(String(event.label || `$${name}`), 180) || `$${name}`,
|
||||
uses: 0,
|
||||
lastUsedAt: null,
|
||||
};
|
||||
current.uses += 1;
|
||||
if (!current.lastUsedAt || timestamp > current.lastUsedAt) current.lastUsedAt = timestamp;
|
||||
skills.set(name, current);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionSummary.lastActivity) recentSessions.push(sessionSummary);
|
||||
}
|
||||
|
||||
const mcpToolRows = Array.from(mcpTools.values());
|
||||
for (const row of mcpToolRows) {
|
||||
row.successRate = row.calls > 0 ? Math.round((row.completed / row.calls) * 1000) / 10 : 0;
|
||||
}
|
||||
mcpToolRows.sort((a, b) => b.calls - a.calls
|
||||
|| b.failed - a.failed
|
||||
|| a.server.localeCompare(b.server)
|
||||
|| a.tool.localeCompare(b.tool));
|
||||
const skillRows = Array.from(skills.values()).sort((a, b) => b.uses - a.uses || a.name.localeCompare(b.name));
|
||||
recentSessions.sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)) || a.sessionId.localeCompare(b.sessionId));
|
||||
mcpRecentCalls.sort((a, b) => b.timestamp.localeCompare(a.timestamp)
|
||||
|| a.server.localeCompare(b.server)
|
||||
|| a.tool.localeCompare(b.tool)
|
||||
|| a.sessionId.localeCompare(b.sessionId));
|
||||
const returnedMcpTools = mcpToolRows.slice(0, mcpToolLimit);
|
||||
const returnedMcpToolKeys = new Set(returnedMcpTools.map((row) => `${row.server}\u0000${row.tool}`));
|
||||
const returnedMcpRecentCalls = mcpRecentCalls
|
||||
.filter((call) => returnedMcpToolKeys.has(`${call.server}\u0000${call.tool}`))
|
||||
.slice(0, mcpDetailLimit);
|
||||
const returnedDetailCounts = new Map();
|
||||
for (const call of returnedMcpRecentCalls) {
|
||||
const key = `${call.server}\u0000${call.tool}`;
|
||||
returnedDetailCounts.set(key, (returnedDetailCounts.get(key) || 0) + 1);
|
||||
}
|
||||
for (const row of returnedMcpTools) {
|
||||
row.detailCallsReturned = returnedDetailCounts.get(`${row.server}\u0000${row.tool}`) || 0;
|
||||
}
|
||||
const returnedSkills = skillRows.slice(0, skillLimit);
|
||||
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
timeZone: range.timeZone,
|
||||
bucket: range.bucket,
|
||||
semantics: '[from,to)',
|
||||
},
|
||||
coverage: {
|
||||
scope: 'retained_sessions',
|
||||
indexedSessions: Number(meta.indexedSessions ?? (documents || []).length) || 0,
|
||||
trackedFiles: Number(meta.trackedFiles ?? (documents || []).length) || 0,
|
||||
retainedMessages,
|
||||
skippedFiles: Number(meta.skippedFiles || 0),
|
||||
failedFiles: Number(meta.failedFiles || 0),
|
||||
distinctMcpTools: mcpToolRows.length,
|
||||
returnedMcpTools: returnedMcpTools.length,
|
||||
distinctSkills: skillRows.length,
|
||||
returnedSkills: returnedSkills.length,
|
||||
lastBuiltAt: meta.lastBuiltAt || null,
|
||||
mcpTimestampBasis: 'assistant_message',
|
||||
skillBasis: 'explicit_composer_mention',
|
||||
},
|
||||
overview,
|
||||
trend: Array.from(trendMap.values()),
|
||||
mcpStatus,
|
||||
limits: {
|
||||
mcpTools: mcpToolLimit,
|
||||
skills: skillLimit,
|
||||
mcpRecentCalls: mcpDetailLimit,
|
||||
recentSessions: recentLimit,
|
||||
},
|
||||
mcpTools: returnedMcpTools,
|
||||
mcpRecentCalls: returnedMcpRecentCalls,
|
||||
skills: returnedSkills,
|
||||
recentSessions: recentSessions.slice(0, recentLimit),
|
||||
};
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
return cleanDisplayText(error?.message || String(error || 'unknown error'), 500);
|
||||
}
|
||||
|
||||
function sleepImmediate() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function timerUnref(timer) {
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(filePath, value) {
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await fsp.writeFile(tempFile, JSON.stringify(value), 'utf8');
|
||||
await fsp.rename(tempFile, filePath);
|
||||
} catch (error) {
|
||||
await fsp.unlink(tempFile).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createUsageStatisticsIndex(options = {}) {
|
||||
const sessionsDir = path.resolve(options.sessionsDir || path.join(process.cwd(), 'sessions'));
|
||||
const cacheFile = path.resolve(options.cacheFile || path.join(sessionsDir, '_usage', 'index-v1.json'));
|
||||
const maxFileBytes = clampInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 64 * 1024, 1024 * 1024 * 1024);
|
||||
const maxRangeDays = clampInteger(options.maxRangeDays, DEFAULT_MAX_RANGE_DAYS, 1, 3660);
|
||||
const logger = options.logger || null;
|
||||
const documents = new Map();
|
||||
const fileMeta = new Map();
|
||||
const upsertTimers = new Map();
|
||||
let state = 'idle';
|
||||
let initialized = false;
|
||||
let buildPromise = null;
|
||||
let persistPromise = null;
|
||||
let persistTimer = null;
|
||||
let dirty = false;
|
||||
let skippedFiles = 0;
|
||||
let failedFiles = 0;
|
||||
let lastBuiltAt = null;
|
||||
let lastPersistedAt = null;
|
||||
let lastError = null;
|
||||
|
||||
function log(level, event, data = {}) {
|
||||
try {
|
||||
if (logger && typeof logger[level] === 'function') logger[level](event, data);
|
||||
else if (logger && typeof logger.log === 'function') logger.log(event, data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function status() {
|
||||
return {
|
||||
state,
|
||||
ready: state === 'ready',
|
||||
building: state === 'building',
|
||||
initialized,
|
||||
error: lastError,
|
||||
sessionsDir,
|
||||
cacheFile,
|
||||
indexedSessions: documents.size,
|
||||
trackedFiles: fileMeta.size,
|
||||
pendingUpserts: upsertTimers.size,
|
||||
skippedFiles,
|
||||
failedFiles,
|
||||
lastBuiltAt,
|
||||
lastPersistedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeCache() {
|
||||
return {
|
||||
version: CACHE_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
files: Object.fromEntries(fileMeta.entries()),
|
||||
documents: Array.from(documents.values()),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCache() {
|
||||
try {
|
||||
const stat = await fsp.stat(cacheFile);
|
||||
if (!stat.isFile() || stat.size <= 0 || stat.size > 256 * 1024 * 1024) return;
|
||||
const parsed = JSON.parse(await fsp.readFile(cacheFile, 'utf8'));
|
||||
if (!isObject(parsed) || parsed.version !== CACHE_VERSION || !Array.isArray(parsed.documents)) return;
|
||||
documents.clear();
|
||||
fileMeta.clear();
|
||||
for (const doc of parsed.documents) {
|
||||
if (doc && legalSessionId(doc.sessionId)) documents.set(doc.sessionId, doc);
|
||||
}
|
||||
if (isObject(parsed.files)) {
|
||||
for (const [sessionId, fingerprint] of Object.entries(parsed.files)) {
|
||||
if (legalSessionId(sessionId) && isObject(fingerprint)) fileMeta.set(sessionId, fingerprint);
|
||||
}
|
||||
}
|
||||
lastPersistedAt = safeIso(parsed.generatedAt);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
dirty = true;
|
||||
log('warn', 'usage_statistics_cache_load_failed', { error: safeError(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSessionFiles() {
|
||||
const scanned = new Map();
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fsp.readdir(sessionsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return scanned;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
||||
const sessionId = entry.name.slice(0, -5);
|
||||
if (!legalSessionId(sessionId)) continue;
|
||||
const filePath = path.join(sessionsDir, entry.name);
|
||||
try {
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (!stat.isFile() || stat.size <= 0 || stat.size > maxFileBytes) {
|
||||
skippedFiles += 1;
|
||||
continue;
|
||||
}
|
||||
scanned.set(sessionId, { filePath, stat, fingerprint: fileFingerprint(stat) });
|
||||
} catch (error) {
|
||||
failedFiles += 1;
|
||||
log('warn', 'usage_statistics_file_stat_failed', { sessionId: sessionId.slice(0, 8), error: safeError(error) });
|
||||
}
|
||||
}
|
||||
return scanned;
|
||||
}
|
||||
|
||||
async function readAndIndexFile(sessionId, filePath, stat, fingerprint) {
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, 'utf8');
|
||||
if (Buffer.byteLength(raw) > maxFileBytes) {
|
||||
skippedFiles += 1;
|
||||
return false;
|
||||
}
|
||||
const doc = createUsageDocument(JSON.parse(raw), stat, sessionId);
|
||||
if (!doc) throw new Error('invalid session document');
|
||||
documents.set(sessionId, doc);
|
||||
fileMeta.set(sessionId, fingerprint || fileFingerprint(stat));
|
||||
dirty = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
failedFiles += 1;
|
||||
documents.delete(sessionId);
|
||||
fileMeta.delete(sessionId);
|
||||
dirty = true;
|
||||
log('warn', 'usage_statistics_file_index_failed', { sessionId: sessionId.slice(0, 8), error: safeError(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistNow() {
|
||||
if (persistPromise) return persistPromise;
|
||||
persistPromise = (async () => {
|
||||
while (dirty) {
|
||||
dirty = false;
|
||||
await writeJsonAtomic(cacheFile, serializeCache());
|
||||
lastPersistedAt = new Date().toISOString();
|
||||
}
|
||||
})().finally(() => {
|
||||
persistPromise = null;
|
||||
});
|
||||
return persistPromise;
|
||||
}
|
||||
|
||||
function schedulePersist() {
|
||||
dirty = true;
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null;
|
||||
persistNow().catch((error) => {
|
||||
state = 'error';
|
||||
lastError = safeError(error);
|
||||
log('error', 'usage_statistics_cache_persist_failed', { error: lastError });
|
||||
});
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
timerUnref(persistTimer);
|
||||
}
|
||||
|
||||
async function rebuildChangedFiles() {
|
||||
state = 'building';
|
||||
lastError = null;
|
||||
skippedFiles = 0;
|
||||
failedFiles = 0;
|
||||
await loadCache();
|
||||
const scanned = await scanSessionFiles();
|
||||
for (const sessionId of Array.from(documents.keys())) {
|
||||
if (!scanned.has(sessionId)) {
|
||||
documents.delete(sessionId);
|
||||
fileMeta.delete(sessionId);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
const changed = [];
|
||||
for (const [sessionId, entry] of scanned.entries()) {
|
||||
if (!sameFingerprint(fileMeta.get(sessionId), entry.fingerprint) || !documents.has(sessionId)) {
|
||||
changed.push([sessionId, entry]);
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < changed.length; index += 1) {
|
||||
const [sessionId, entry] = changed[index];
|
||||
await readAndIndexFile(sessionId, entry.filePath, entry.stat, entry.fingerprint);
|
||||
if ((index + 1) % BUILD_BATCH_SIZE === 0) await sleepImmediate();
|
||||
}
|
||||
if (dirty) await persistNow();
|
||||
lastBuiltAt = new Date().toISOString();
|
||||
initialized = true;
|
||||
state = 'ready';
|
||||
return status();
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (buildPromise) return buildPromise;
|
||||
if (initialized && state === 'ready') return Promise.resolve(status());
|
||||
buildPromise = rebuildChangedFiles()
|
||||
.catch((error) => {
|
||||
state = 'error';
|
||||
lastError = safeError(error);
|
||||
log('error', 'usage_statistics_initialize_failed', { error: lastError });
|
||||
return status();
|
||||
})
|
||||
.finally(() => {
|
||||
buildPromise = null;
|
||||
});
|
||||
return buildPromise;
|
||||
}
|
||||
|
||||
async function upsertNow(sessionId) {
|
||||
const id = sanitizeSessionId(sessionId);
|
||||
if (!legalSessionId(id)) return false;
|
||||
const filePath = path.join(sessionsDir, `${id}.json`);
|
||||
try {
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (!stat.isFile() || stat.size <= 0 || stat.size > maxFileBytes) {
|
||||
const existed = documents.delete(id) || fileMeta.delete(id);
|
||||
fileMeta.delete(id);
|
||||
if (existed) schedulePersist();
|
||||
return false;
|
||||
}
|
||||
const changed = !sameFingerprint(fileMeta.get(id), fileFingerprint(stat)) || !documents.has(id);
|
||||
if (!changed) return true;
|
||||
const result = await readAndIndexFile(id, filePath, stat, fileFingerprint(stat));
|
||||
if (result) schedulePersist();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return remove(id);
|
||||
failedFiles += 1;
|
||||
log('warn', 'usage_statistics_upsert_failed', { sessionId: id.slice(0, 8), error: safeError(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpsert(sessionId) {
|
||||
if (!initialized && !buildPromise) return false;
|
||||
const id = sanitizeSessionId(sessionId);
|
||||
if (!legalSessionId(id)) return false;
|
||||
const existing = upsertTimers.get(id);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
upsertTimers.delete(id);
|
||||
upsertNow(id).catch((error) => {
|
||||
lastError = safeError(error);
|
||||
log('error', 'usage_statistics_schedule_upsert_failed', { sessionId: id.slice(0, 8), error: lastError });
|
||||
});
|
||||
}, UPSERT_DEBOUNCE_MS);
|
||||
timerUnref(timer);
|
||||
upsertTimers.set(id, timer);
|
||||
return true;
|
||||
}
|
||||
|
||||
function remove(sessionId) {
|
||||
if (!initialized && !buildPromise) return false;
|
||||
const id = sanitizeSessionId(sessionId);
|
||||
if (!legalSessionId(id)) return false;
|
||||
const timer = upsertTimers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
upsertTimers.delete(id);
|
||||
const existed = documents.delete(id) || fileMeta.delete(id);
|
||||
fileMeta.delete(id);
|
||||
if (existed) schedulePersist();
|
||||
return existed;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (buildPromise) await buildPromise;
|
||||
for (const id of Array.from(upsertTimers.keys())) {
|
||||
const timer = upsertTimers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
upsertTimers.delete(id);
|
||||
await upsertNow(id);
|
||||
}
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = null;
|
||||
}
|
||||
if (dirty) await persistNow();
|
||||
if (persistPromise) await persistPromise;
|
||||
return status();
|
||||
}
|
||||
|
||||
function query(params = {}) {
|
||||
if (!initialized || state !== 'ready') {
|
||||
throw new UsageStatisticsError('index_unavailable', '统计索引暂不可用');
|
||||
}
|
||||
return aggregateUsageStatistics(Array.from(documents.values()), params, {
|
||||
...status(),
|
||||
maxRangeDays,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
scheduleUpsert,
|
||||
remove,
|
||||
flush,
|
||||
query,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCHEMA_VERSION,
|
||||
UsageStatisticsError,
|
||||
createUsageStatisticsIndex,
|
||||
createUsageDocument,
|
||||
aggregateUsageStatistics,
|
||||
normalizeUsageRange,
|
||||
extractMcpToolCall,
|
||||
extractSkillMention,
|
||||
};
|
||||
1012
public/app.js
1012
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
||||
document.documentElement.dataset.dividerTime = dividerTime;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=20260730-sidebar-title-refresh-storm">
|
||||
<link rel="stylesheet" href="style.css?v=20260803-usage-statistics">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -57,14 +57,24 @@
|
||||
<div id="new-chat-dropdown" class="new-chat-dropdown" hidden>
|
||||
<button id="import-session-btn">导入本地 CLI 会话</button>
|
||||
</div>
|
||||
<div class="session-search">
|
||||
<input id="session-search-input" class="session-search-input" type="search" placeholder="检索会话 / 项目" autocomplete="off" aria-label="检索会话或项目">
|
||||
<button id="session-search-clear" class="session-search-clear" type="button" title="清空检索" aria-label="清空检索" hidden>×</button>
|
||||
<div class="session-search-row">
|
||||
<div class="session-search">
|
||||
<input id="session-search-input" class="session-search-input" type="search" placeholder="检索会话 / 项目" autocomplete="off" aria-label="检索会话或项目">
|
||||
<button id="session-search-clear" class="session-search-clear" type="button" title="清空检索" aria-label="清空检索" hidden>×</button>
|
||||
</div>
|
||||
<button id="advanced-search-open" class="advanced-search-open" type="button" title="高级会话检索" aria-label="打开高级会话检索" aria-controls="advanced-search-panel" aria-expanded="false">⌘</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="session-list" class="session-list"></div>
|
||||
<div class="sidebar-footer">
|
||||
<button id="settings-btn" class="settings-btn" title="设置">⚙</button>
|
||||
<div class="sidebar-footer-actions" role="group" aria-label="CC-Web 工具">
|
||||
<button id="settings-btn" class="settings-btn" type="button" title="设置" aria-label="打开设置">⚙</button>
|
||||
<button id="usage-dashboard-open" class="usage-dashboard-open" type="button" title="使用统计" aria-label="打开使用统计" aria-controls="usage-dashboard-panel" aria-expanded="false" hidden>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 21v-6"></path><path d="M12 21V9"></path><path d="M19 21V3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="brand">CC-Web</span>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -110,6 +120,165 @@
|
||||
<!-- Slash command menu -->
|
||||
<div id="cmd-menu" class="cmd-menu" hidden></div>
|
||||
|
||||
<section id="advanced-search-panel" class="advanced-search-panel" role="dialog" aria-labelledby="advanced-search-title" hidden>
|
||||
<div class="advanced-search-shell">
|
||||
<header class="advanced-search-header">
|
||||
<button id="advanced-search-close" class="advanced-search-close" type="button" aria-label="关闭高级会话检索">‹</button>
|
||||
<div class="advanced-search-field">
|
||||
<label id="advanced-search-title" class="advanced-search-title" for="advanced-search-input">高级会话检索</label>
|
||||
<div class="advanced-search-input-wrap">
|
||||
<input id="advanced-search-input" class="advanced-search-input" type="search" placeholder="搜索会话标题、路径与消息内容" autocomplete="off" aria-label="高级会话检索关键词" aria-describedby="advanced-search-status">
|
||||
<button id="advanced-search-clear" class="advanced-search-clear" type="button" aria-label="清空高级检索关键词" hidden>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="advanced-search-submit" class="advanced-search-submit" type="button" aria-label="执行高级会话检索">↵</button>
|
||||
</header>
|
||||
|
||||
<div class="advanced-search-toolbar" aria-label="高级检索选项">
|
||||
<div class="advanced-search-toggle-group" role="group" aria-label="排序方式">
|
||||
<button id="advanced-search-sort-relevance" class="advanced-search-pill" type="button" aria-pressed="false">最相关</button>
|
||||
<button id="advanced-search-sort-newest" class="advanced-search-pill" type="button" aria-pressed="true">最新优先</button>
|
||||
</div>
|
||||
<div class="advanced-search-toggle-group" role="group" aria-label="匹配方式">
|
||||
<button id="advanced-search-match-contains" class="advanced-search-pill" type="button" aria-pressed="false">包含</button>
|
||||
<button id="advanced-search-match-word" class="advanced-search-pill" type="button" aria-pressed="true">整词</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="advanced-search-status" class="advanced-search-status" role="status" aria-live="polite">输入至少 2 个字符开始检索</div>
|
||||
<div id="advanced-search-results" class="advanced-search-results" role="list" aria-label="高级检索结果"></div>
|
||||
<div id="advanced-search-empty" class="advanced-search-empty" role="status" hidden>没有匹配的会话</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="usage-dashboard-panel" class="usage-dashboard" role="dialog" aria-labelledby="usage-dashboard-title" aria-describedby="usage-dashboard-status" data-state="idle" hidden>
|
||||
<div class="usage-dashboard__shell">
|
||||
<header class="usage-dashboard__header">
|
||||
<button id="usage-dashboard-close" class="usage-dashboard__icon-button usage-dashboard__back" type="button" aria-label="返回聊天" title="返回聊天">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="usage-dashboard__heading">
|
||||
<h2 id="usage-dashboard-title">使用统计</h2>
|
||||
<p id="usage-dashboard-status" role="status" aria-live="polite">基于当前保留的会话数据</p>
|
||||
</div>
|
||||
<div class="usage-dashboard__header-actions">
|
||||
<time id="usage-dashboard-generated-at" class="usage-dashboard__generated-at"></time>
|
||||
<button id="usage-dashboard-refresh" class="usage-dashboard__icon-button" type="button" aria-label="刷新统计" title="刷新统计">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 0 0-15.12-6.6L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 15.12 6.6L21 16"></path><path d="M16 16h5v5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="usage-dashboard__toolbar" aria-label="统计范围">
|
||||
<div class="usage-dashboard__periods" role="group" aria-label="快捷时间范围">
|
||||
<button id="usage-dashboard-period-week" class="usage-dashboard__period" type="button" aria-pressed="true">本周</button>
|
||||
<button id="usage-dashboard-period-month" class="usage-dashboard__period" type="button" aria-pressed="false">本月</button>
|
||||
</div>
|
||||
<div class="usage-dashboard__dates">
|
||||
<label>从 <input id="usage-dashboard-from" type="date" aria-label="统计开始日期"></label>
|
||||
<span aria-hidden="true">至</span>
|
||||
<label><input id="usage-dashboard-to" type="date" aria-label="统计结束日期"></label>
|
||||
<button id="usage-dashboard-apply" class="usage-dashboard__apply" type="button">应用</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-dashboard__body">
|
||||
<div id="usage-dashboard-loading" class="usage-dashboard__loading" role="status" hidden>正在整理统计数据</div>
|
||||
<div id="usage-dashboard-error" class="usage-dashboard__error" role="alert" hidden></div>
|
||||
|
||||
<section class="usage-dashboard__metric-grid" aria-label="统计概览">
|
||||
<article class="usage-dashboard__metric">
|
||||
<span>新建会话</span><strong data-usage-metric="newSessions">0</strong><small>所选时间内</small>
|
||||
</article>
|
||||
<article class="usage-dashboard__metric">
|
||||
<span>发送消息</span><strong data-usage-metric="messages">0</strong><small data-usage-message-split>直接 0 · 跨会话 0</small>
|
||||
</article>
|
||||
<article class="usage-dashboard__metric">
|
||||
<span>MCP 调用</span><strong data-usage-metric="mcpCalls">0</strong><small>按 assistant 消息时间</small>
|
||||
</article>
|
||||
<article class="usage-dashboard__metric usage-dashboard__metric--danger">
|
||||
<span>MCP 失败</span><strong data-usage-metric="mcpFailures">0</strong><small data-usage-failure-rate>失败率 0%</small>
|
||||
</article>
|
||||
<article class="usage-dashboard__metric">
|
||||
<span>Skill 显式使用</span><strong data-usage-metric="skillMentions">0</strong><small>仅统计 $skill mention</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="usage-dashboard__content-grid">
|
||||
<section class="usage-dashboard__panel usage-dashboard__panel--trend" aria-labelledby="usage-dashboard-trend-title">
|
||||
<div class="usage-dashboard__panel-heading">
|
||||
<div><h3 id="usage-dashboard-trend-title">每日趋势</h3><p>会话、消息、MCP 与 Skill</p></div>
|
||||
<div id="usage-dashboard-trend-legend" class="usage-dashboard__legend" aria-label="趋势图例"></div>
|
||||
</div>
|
||||
<div class="usage-dashboard__chart-wrap">
|
||||
<svg id="usage-dashboard-trend" class="usage-dashboard__chart" viewBox="0 0 720 280" role="img" aria-label="每日使用趋势"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="usage-dashboard__panel usage-dashboard__panel--status" aria-labelledby="usage-dashboard-mcp-status-title">
|
||||
<div class="usage-dashboard__panel-heading">
|
||||
<div><h3 id="usage-dashboard-mcp-status-title">MCP 状态</h3><p>调用结果分布</p></div>
|
||||
</div>
|
||||
<div id="usage-dashboard-mcp-status" class="usage-dashboard__status-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="usage-dashboard__panel" aria-labelledby="usage-dashboard-mcp-title">
|
||||
<div class="usage-dashboard__panel-heading">
|
||||
<div><h3 id="usage-dashboard-mcp-title">MCP 工具</h3><p>按调用次数排序</p></div>
|
||||
</div>
|
||||
<div class="usage-dashboard__table-wrap">
|
||||
<table class="usage-dashboard__table">
|
||||
<thead><tr><th scope="col">工具</th><th scope="col">调用</th><th scope="col">成功</th><th scope="col">失败</th><th scope="col">成功率</th><th scope="col"><span class="sr-only">操作</span></th></tr></thead>
|
||||
<tbody id="usage-dashboard-mcp-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="usage-dashboard-mcp-empty" class="usage-dashboard__empty" hidden>所选时间内没有 MCP 调用</div>
|
||||
</section>
|
||||
|
||||
<section id="usage-dashboard-detail" class="usage-dashboard__panel usage-dashboard__detail" aria-labelledby="usage-dashboard-detail-title" hidden>
|
||||
<div class="usage-dashboard__panel-heading">
|
||||
<div><h3 id="usage-dashboard-detail-title">调用明细</h3><p id="usage-dashboard-detail-meta"></p></div>
|
||||
<button id="usage-dashboard-detail-close" class="usage-dashboard__icon-button" type="button" aria-label="关闭调用明细" title="关闭调用明细">×</button>
|
||||
</div>
|
||||
<div class="usage-dashboard__table-wrap">
|
||||
<table class="usage-dashboard__table">
|
||||
<thead><tr><th scope="col">时间</th><th scope="col">状态</th><th scope="col">会话</th><th scope="col">Agent</th></tr></thead>
|
||||
<tbody id="usage-dashboard-detail-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="usage-dashboard__content-grid usage-dashboard__content-grid--lower">
|
||||
<section class="usage-dashboard__panel" aria-labelledby="usage-dashboard-skills-title">
|
||||
<div class="usage-dashboard__panel-heading">
|
||||
<div><h3 id="usage-dashboard-skills-title">Skill 显式使用</h3><p>按 mention 次数排序</p></div>
|
||||
</div>
|
||||
<ol id="usage-dashboard-skill-rows" class="usage-dashboard__rank-list"></ol>
|
||||
<div id="usage-dashboard-skill-empty" class="usage-dashboard__empty" hidden>所选时间内没有显式 Skill 使用</div>
|
||||
</section>
|
||||
|
||||
<section class="usage-dashboard__panel" aria-labelledby="usage-dashboard-sessions-title">
|
||||
<div class="usage-dashboard__panel-heading">
|
||||
<div><h3 id="usage-dashboard-sessions-title">最近会话</h3><p>点击可返回会话</p></div>
|
||||
</div>
|
||||
<div class="usage-dashboard__table-wrap">
|
||||
<table class="usage-dashboard__table">
|
||||
<thead><tr><th scope="col">会话</th><th scope="col">消息</th><th scope="col">MCP</th><th scope="col">Skill</th><th scope="col">最近时间</th></tr></thead>
|
||||
<tbody id="usage-dashboard-session-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="usage-dashboard-session-empty" class="usage-dashboard__empty" hidden>所选时间内没有会话记录</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="chat-controls" aria-label="会话控制">
|
||||
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
|
||||
@@ -183,6 +352,6 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
|
||||
<script src="app.js?v=20260730-sidebar-title-refresh-storm"></script>
|
||||
<script src="app.js?v=20260803-usage-statistics"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1583
public/style.css
1583
public/style.css
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@ const WINDOWS_START_PATH = path.join(REPO_DIR, 'start.bat');
|
||||
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
|
||||
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
|
||||
const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
|
||||
const SESSION_SEARCH_INDEX_PATH = path.join(REPO_DIR, 'lib', 'session-search-index.js');
|
||||
const USAGE_STATISTICS_PATH = path.join(REPO_DIR, 'lib', 'usage-statistics.js');
|
||||
const USAGE_STATISTICS_UNIT_PATH = path.join(REPO_DIR, 'scripts', 'usage-statistics-unit.js');
|
||||
const GILDED_THEME_ASSETS = [
|
||||
{
|
||||
filename: 'gilded-wasteland.png',
|
||||
@@ -619,8 +622,8 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm')
|
||||
&& indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'),
|
||||
indexSource.includes('style.css?v=20260803-usage-statistics')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
}
|
||||
@@ -943,8 +946,8 @@ function assertPlanListProgressContract() {
|
||||
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
|
||||
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
}
|
||||
|
||||
function assertFrontendGildedThemeContract() {
|
||||
@@ -1059,8 +1062,8 @@ function assertFrontendGildedThemeContract() {
|
||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
}
|
||||
|
||||
function assertFrontendWastelandThemeContract() {
|
||||
@@ -1313,8 +1316,8 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
function assertFrontendCcwebPromptContract() {
|
||||
@@ -3321,6 +3324,7 @@ function assertSessionRequestIdRaceContract() {
|
||||
function cloneMessages(messages) { return messages.slice(); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||
function scheduleAdvancedSearchJump() {}
|
||||
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
|
||||
function handleHistoryMessage(msg) {
|
||||
switch (msg.type) {
|
||||
@@ -3449,6 +3453,7 @@ function assertRecoverCurrentHistoryMergeContract() {
|
||||
function cloneMessages(messages) { return messages.map((message) => ({ ...message })); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||
function scheduleAdvancedSearchJump() {}
|
||||
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
|
||||
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
|
||||
${finalizeLoadedSessionSource}
|
||||
@@ -3541,6 +3546,9 @@ function assertServerSessionHistoryRequestIdContract() {
|
||||
const activeProcesses = new Map();
|
||||
const activeCodexAppTurns = new Map();
|
||||
const wsSessionMap = new Map();
|
||||
const INITIAL_HISTORY_COUNT = 12;
|
||||
const HISTORY_CHUNK_SIZE = 24;
|
||||
const HISTORY_PREFETCH_CHUNKS = 3;
|
||||
function sanitizeId(value) { return String(value || ''); }
|
||||
function reconcilePendingCrossConversationReplies() {}
|
||||
function loadSession() { return fixture; }
|
||||
@@ -4425,6 +4433,457 @@ function assertMultiAgentV2CompatibilityContract() {
|
||||
assert(frontendSource.includes('entry.agentPath ? `路径: ${entry.agentPath}`'), 'Sub-agent cards should expose the canonical agent path');
|
||||
}
|
||||
|
||||
function assertAdvancedSessionSearchContract() {
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
const searchIndexSource = fs.readFileSync(SESSION_SEARCH_INDEX_PATH, 'utf8');
|
||||
|
||||
const sidebarSearchIndex = indexSource.indexOf('id="session-search-input"');
|
||||
const advancedOpenIndex = indexSource.indexOf('id="advanced-search-open"');
|
||||
const sessionListIndex = indexSource.indexOf('id="session-list"');
|
||||
assert(sidebarSearchIndex >= 0 && advancedOpenIndex > sidebarSearchIndex && sessionListIndex > advancedOpenIndex,
|
||||
'Advanced search button should sit beside the unchanged sidebar search before the session list');
|
||||
assert(indexSource.includes('id="session-search-clear" class="session-search-clear" type="button" title="清空检索" aria-label="清空检索" hidden'),
|
||||
'Existing sidebar search clear control contract should remain unchanged');
|
||||
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
|
||||
'Advanced search workspace should expose stable panel and result hooks');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
|
||||
'Advanced search CSS and frontend script should share the reviewed cache-bust');
|
||||
|
||||
const panelStyleStart = styleSource.indexOf('.advanced-search-panel {');
|
||||
const panelStyleEnd = styleSource.indexOf('.advanced-search-panel[hidden]', panelStyleStart);
|
||||
const panelStyle = styleSource.slice(panelStyleStart, panelStyleEnd);
|
||||
assert(panelStyle.includes('position: absolute') && panelStyle.includes('inset: 0'),
|
||||
'Advanced search should be a chat-main-local overlay rather than a global modal');
|
||||
assert(styleSource.includes('.advanced-search-results') && styleSource.includes('overflow-y: auto'),
|
||||
'Advanced search results should own their scroll container');
|
||||
assert(/html\[data-theme='wasteland'\] \.session-search-row \.session-search\s*\{[^}]*margin-top:\s*0;/.test(styleSource)
|
||||
&& /html\[data-theme='wasteland'\] \.session-search-row \.advanced-search-open\s*\{[^}]*width:\s*36px;[^}]*height:\s*36px;[^}]*flex-basis:\s*36px;/.test(styleSource),
|
||||
'Wasteland advanced button and framed search input should share one 36px row axis');
|
||||
assert(styleSource.includes('@media (max-width: 768px)') && styleSource.includes('@media (prefers-reduced-motion: reduce)'),
|
||||
'Advanced search should cover mobile and reduced-motion states');
|
||||
|
||||
const legacyInputBlock = frontendSource.slice(
|
||||
frontendSource.indexOf('if (sessionSearchInput) {'),
|
||||
frontendSource.indexOf('// Split new-chat button'),
|
||||
);
|
||||
assert(legacyInputBlock.includes('sessionSearchQuery = sessionSearchInput.value;')
|
||||
&& legacyInputBlock.includes("if (e.key === 'Escape' && normalizeSessionSearchQuery(sessionSearchQuery))")
|
||||
&& legacyInputBlock.includes('sessionSearchQuery = \'\';\n renderSessionList();'),
|
||||
'Existing sidebar search input, Escape and clear behavior should remain intact');
|
||||
const advancedFunctionStart = frontendSource.indexOf('function normalizeAdvancedSearchQuery');
|
||||
const advancedFunctionEnd = frontendSource.indexOf('function getProjectCollapseKey', advancedFunctionStart);
|
||||
const advancedFunctions = frontendSource.slice(advancedFunctionStart, advancedFunctionEnd);
|
||||
assert(!/sessionSearchQuery\s*=/.test(advancedFunctions),
|
||||
'Advanced search state machine must not write the existing sidebar query');
|
||||
assert(advancedFunctions.includes("type: 'search_sessions'")
|
||||
&& advancedFunctions.includes('createTextNode')
|
||||
&& advancedFunctions.includes('mark.textContent ='),
|
||||
'Advanced search should use its independent WS request and DOM-safe highlight rendering');
|
||||
assert(advancedFunctions.includes('targetMessageIndex') && advancedFunctions.includes('forceSync: true'),
|
||||
'Advanced search result navigation should carry messageIndex through a fresh session load');
|
||||
|
||||
const sessionListBlock = extractFunctionSource(serverSource, 'sendSessionList');
|
||||
assert(!sessionListBlock.includes('message.content') && !sessionListBlock.includes('session_search_results'),
|
||||
'Existing session_list payload must remain lightweight and independent from body search');
|
||||
assert(serverSource.includes("case 'search_sessions':")
|
||||
&& serverSource.includes("type: 'session_search_results'")
|
||||
&& serverSource.includes('sessionSearchIndex.scheduleUpsert(session.id)')
|
||||
&& serverSource.includes('sessionSearchIndex.remove(sessionId)'),
|
||||
'Server should expose the independent search protocol and synchronize save/delete lifecycle');
|
||||
const loadSessionBlock = extractFunctionSource(serverSource, 'handleLoadSession');
|
||||
assert(loadSessionBlock.includes('targetMessageIndex') && loadSessionBlock.includes('targetPrefetchChunks'),
|
||||
'Targeted session loads should prefetch enough history to expose the matched message');
|
||||
|
||||
assert(searchIndexSource.includes("role !== 'user' && role !== 'assistant'")
|
||||
&& searchIndexSource.includes("type === 'tool_use'")
|
||||
&& searchIndexSource.includes('MAX_QUERY_CHARS_HARD_LIMIT = 200')
|
||||
&& searchIndexSource.includes('MAX_RESULTS_HARD_LIMIT = 50')
|
||||
&& !searchIndexSource.includes('new RegExp('),
|
||||
'Search index should constrain input and exclude system/tool/attachment content without dynamic regexes');
|
||||
}
|
||||
|
||||
function assertUsageStatisticsContract() {
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
const usageSource = fs.readFileSync(USAGE_STATISTICS_PATH, 'utf8');
|
||||
|
||||
const sessionListIndex = indexSource.indexOf('id="session-list"');
|
||||
const footerIndex = indexSource.indexOf('class="sidebar-footer"');
|
||||
const settingsIndex = indexSource.indexOf('id="settings-btn"', footerIndex);
|
||||
const usageOpenIndex = indexSource.indexOf('id="usage-dashboard-open"', footerIndex);
|
||||
const chatMainIndex = indexSource.indexOf('class="chat-main"');
|
||||
const usagePanelIndex = indexSource.indexOf('id="usage-dashboard-panel"');
|
||||
assert(sessionListIndex >= 0 && footerIndex > sessionListIndex && settingsIndex > footerIndex && usageOpenIndex > settingsIndex,
|
||||
'Usage entry should stay in the fixed sidebar footer after settings, outside the session list');
|
||||
assert(chatMainIndex >= 0 && usagePanelIndex > chatMainIndex,
|
||||
'Usage dashboard should be a chat-main-local workspace');
|
||||
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
|
||||
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
|
||||
'Usage dashboard CSS and frontend script should share the current cache-bust');
|
||||
|
||||
const usageFunctionsStart = frontendSource.indexOf('function usageDateInputValue');
|
||||
const usageFunctionsEnd = frontendSource.indexOf('function getProjectCollapseKey', usageFunctionsStart);
|
||||
const usageFunctions = frontendSource.slice(usageFunctionsStart, usageFunctionsEnd);
|
||||
assert(usageFunctions.includes("type: 'usage_stats_query'")
|
||||
&& usageFunctions.includes('usageDashboardState.requestId')
|
||||
&& usageFunctions.includes("behavior: reduceMotion ? 'auto' : 'smooth'"),
|
||||
'Usage dashboard should use an independent request id and respect reduced-motion while navigating details');
|
||||
assert(!/\bcurrentSessionId\s*=/.test(usageFunctions)
|
||||
&& !/\bsessions\s*=/.test(usageFunctions)
|
||||
&& !/\bsessionSearchQuery\s*=/.test(usageFunctions)
|
||||
&& !/\bisGenerating\s*=/.test(usageFunctions)
|
||||
&& !/msgInput\.value\s*=/.test(usageFunctions)
|
||||
&& !/\bpendingAttachments\s*=/.test(usageFunctions)
|
||||
&& !/messagesDiv\.(?:innerHTML|replaceChildren)/.test(usageFunctions),
|
||||
'Usage dashboard state must not replace chat/session/search/generation/draft state or mounted messages');
|
||||
|
||||
const queryHandler = extractFunctionSource(serverSource, 'handleUsageStatisticsQuery');
|
||||
assert(serverSource.includes("case 'usage_stats_query':")
|
||||
&& serverSource.includes("type: 'usage_stats_result'")
|
||||
&& serverSource.includes("type: 'usage_stats_error'")
|
||||
&& serverSource.includes('features: { usageStatistics: USAGE_STATISTICS_ENABLED }'),
|
||||
'Server should expose the versioned usage query protocol and authenticated feature flag');
|
||||
assert(!queryHandler.includes('sendSessionList') && !queryHandler.includes('broadcastSessionList'),
|
||||
'Usage queries must not refresh or replace session_list');
|
||||
assert(serverSource.includes("process.env.CC_WEB_USAGE_STATISTICS")
|
||||
&& serverSource.includes('scheduleUsageStatisticsUpsert(session.id)')
|
||||
&& serverSource.includes('removeUsageStatisticsSession(sessionId)'),
|
||||
'Usage indexing should be independently gated and synchronized through safe save/delete hooks');
|
||||
|
||||
assert(usageSource.includes("scope: 'retained_sessions'")
|
||||
&& usageSource.includes("semantics: '[from,to)'")
|
||||
&& usageSource.includes("skillBasis: 'explicit_composer_mention'")
|
||||
&& usageSource.includes("mcpTimestampBasis: 'assistant_message'"),
|
||||
'Usage response should publish its retained-data scope and exact counting semantics');
|
||||
assert(usageSource.includes("String(mention.kind || '').toLowerCase() !== 'skill'")
|
||||
&& usageSource.includes('const meta = isObject(toolCall.meta) ? toolCall.meta : {}')
|
||||
&& usageSource.includes('normalizeMcpStatus(meta.status || toolCall.status, !!toolCall.done)')
|
||||
&& !usageSource.includes("content: message.content"),
|
||||
'Usage index should count explicit Skill mentions and MCP status without retaining message bodies');
|
||||
assert(usageSource.includes('DEFAULT_MCP_TOOL_LIMIT = 200')
|
||||
&& usageSource.includes('DEFAULT_SKILL_LIMIT = 100')
|
||||
&& usageSource.includes('detailCallsReturned')
|
||||
&& frontendSource.includes('最近 ${formatUsageNumber(returnedCalls)} / 共 ${formatUsageNumber(totalCalls)} 条'),
|
||||
'Usage result rankings and detail payloads should be bounded and visibly disclose partial detail windows');
|
||||
|
||||
assert(styleSource.includes('/* === 使用统计看板:独立工作区 === */')
|
||||
&& styleSource.includes("html[data-theme='wasteland'] .usage-dashboard-open")
|
||||
&& styleSource.includes("html[data-theme='gilded'] .usage-dashboard")
|
||||
&& styleSource.includes("html[data-theme='coolvibe'] .usage-dashboard-open")
|
||||
&& styleSource.includes('--usage-dashboard-accent-ink: #0d1b1f')
|
||||
&& /html\[data-theme='wasteland'\][\s\S]*?\.usage-dashboard__dates input\s*\{\s*color-scheme:\s*dark;/.test(styleSource)
|
||||
&& /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.usage-dashboard/.test(styleSource),
|
||||
'Usage dashboard should provide isolated theme surfaces, readable native controls, mobile sizing, and reduced-motion coverage');
|
||||
}
|
||||
|
||||
function assertUsageStatisticsUnitChecks() {
|
||||
const result = spawnSync(process.execPath, [USAGE_STATISTICS_UNIT_PATH], {
|
||||
cwd: REPO_DIR,
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
});
|
||||
assert(result.status === 0,
|
||||
`Usage statistics unit checks failed: ${result.stderr || result.stdout || result.signal || 'unknown error'}`);
|
||||
}
|
||||
|
||||
async function runUsageStatisticsRegression() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-regression-'));
|
||||
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 sessionId = 'usage-regression-session';
|
||||
const sessionPath = path.join(sessionsDir, `${sessionId}.json`);
|
||||
fs.writeFileSync(sessionPath, JSON.stringify({
|
||||
id: sessionId,
|
||||
title: '使用统计回归会话',
|
||||
agent: 'codexapp',
|
||||
cwd: homeDir,
|
||||
created: '2026-08-01T00:00:00.000Z',
|
||||
updated: '2026-08-02T00:00:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'private-message-body',
|
||||
timestamp: '2026-08-01T01:00:00.000Z',
|
||||
composerMentions: [{ kind: 'skill', name: 'openai-docs', label: '$openai-docs' }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'private-assistant-body',
|
||||
timestamp: '2026-08-01T01:01:00.000Z',
|
||||
toolCalls: [{
|
||||
name: 'McpToolCall',
|
||||
kind: 'mcp_tool_call',
|
||||
input: { server: 'ccweb', tool: 'ccweb_list_conversations', arguments: { private: true } },
|
||||
result: 'private-tool-result',
|
||||
done: true,
|
||||
meta: { kind: 'mcp_tool_call', status: 'completed' },
|
||||
}],
|
||||
},
|
||||
],
|
||||
}, null, 2));
|
||||
const beforeContent = fs.readFileSync(sessionPath, 'utf8');
|
||||
const beforeMtime = fs.statSync(sessionPath).mtimeMs;
|
||||
|
||||
try {
|
||||
const port = await getFreePort();
|
||||
const password = 'UsageRegression!234';
|
||||
await withServer({
|
||||
PORT: String(port),
|
||||
CC_WEB_PASSWORD: password,
|
||||
CC_WEB_INTERNAL_MCP_TOKEN: 'UsageRegressionMcp!234',
|
||||
CC_WEB_CONFIG_DIR: configDir,
|
||||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||||
CC_WEB_LOGS_DIR: logsDir,
|
||||
HOME: homeDir,
|
||||
CLAUDE_PATH: MOCK_CLAUDE,
|
||||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||
}, async () => {
|
||||
const { ws, messages, receivedMessages } = await connectWs(port, password, { trackReceived: true });
|
||||
const authResult = receivedMessages.find((msg) => msg.type === 'auth_result');
|
||||
assert(authResult?.features?.usageStatistics === true,
|
||||
'Authenticated clients should receive the enabled usageStatistics feature flag');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||
const baselineSessionLists = receivedMessages.filter((msg) => msg.type === 'session_list').length;
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'usage_stats_query',
|
||||
requestId: 'usage-regression-query',
|
||||
from: '2026-08-01T00:00:00.000Z',
|
||||
to: '2026-08-02T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
}));
|
||||
const response = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'usage_stats_result' && msg.requestId === 'usage-regression-query'
|
||||
));
|
||||
assert(response.schemaVersion === 1 && response.range?.semantics === '[from,to)',
|
||||
'Usage response should carry schemaVersion and half-open range semantics');
|
||||
assert(response.overview?.newSessions === 1
|
||||
&& response.overview?.messages === 1
|
||||
&& response.overview?.mcpCalls === 1
|
||||
&& response.overview?.skillMentions === 1,
|
||||
'Usage WebSocket query should return the expected aggregate counts');
|
||||
const serialized = JSON.stringify(response);
|
||||
['private-message-body', 'private-assistant-body', 'private-tool-result', 'arguments'].forEach((privateValue) => {
|
||||
assert(!serialized.includes(privateValue), `Usage response should not expose ${privateValue}`);
|
||||
});
|
||||
await sleep(160);
|
||||
assert(receivedMessages.filter((msg) => msg.type === 'session_list').length === baselineSessionLists,
|
||||
'Usage query must not trigger an extra session_list response');
|
||||
assert(fs.readFileSync(sessionPath, 'utf8') === beforeContent && fs.statSync(sessionPath).mtimeMs === beforeMtime,
|
||||
'Usage query must not modify retained session files');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'load_session',
|
||||
sessionId,
|
||||
requestId: 'usage-regression-load',
|
||||
}));
|
||||
const loaded = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' && msg.requestId === 'usage-regression-load'
|
||||
));
|
||||
assert(loaded.sessionId === sessionId, 'Session loading should remain available after a usage query');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'search_sessions',
|
||||
requestId: 'usage-regression-search',
|
||||
query: 'private-message-body',
|
||||
agent: 'codexapp',
|
||||
matchMode: 'contains',
|
||||
}));
|
||||
const search = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_search_results' && msg.requestId === 'usage-regression-search'
|
||||
));
|
||||
assert(search.total === 1 && search.results[0]?.sessionId === sessionId,
|
||||
'Advanced session search should remain available after a usage query');
|
||||
assert(receivedMessages.filter((msg) => msg.type === 'session_list').length === baselineSessionLists,
|
||||
'Usage, load and search requests must not refresh session_list');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
const disabledPort = await getFreePort();
|
||||
await withServer({
|
||||
PORT: String(disabledPort),
|
||||
CC_WEB_PASSWORD: 'UsageDisabled!234',
|
||||
CC_WEB_USAGE_STATISTICS: '0',
|
||||
CC_WEB_INTERNAL_MCP_TOKEN: 'UsageDisabledMcp!234',
|
||||
CC_WEB_CONFIG_DIR: path.join(tempRoot, 'disabled-config'),
|
||||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||||
CC_WEB_LOGS_DIR: path.join(tempRoot, 'disabled-logs'),
|
||||
HOME: homeDir,
|
||||
CLAUDE_PATH: MOCK_CLAUDE,
|
||||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||
}, async () => {
|
||||
const { ws, messages, receivedMessages } = await connectWs(disabledPort, 'UsageDisabled!234', { trackReceived: true });
|
||||
const authResult = receivedMessages.find((msg) => msg.type === 'auth_result');
|
||||
assert(authResult?.features?.usageStatistics === false,
|
||||
'Disabled usage statistics should be advertised as unavailable');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'usage_stats_query',
|
||||
requestId: 'usage-disabled-query',
|
||||
from: '2026-08-01T00:00:00.000Z',
|
||||
to: '2026-08-02T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
}));
|
||||
const error = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'usage_stats_error' && msg.requestId === 'usage-disabled-query'
|
||||
));
|
||||
assert(error.code === 'disabled', 'Disabled usage statistics should reject queries without affecting the server');
|
||||
ws.close();
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runAdvancedSessionSearchRegression() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-advanced-search-regression-'));
|
||||
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 sessionId = 'advanced-search-session';
|
||||
const storedMessages = Array.from({ length: 180 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `普通回归消息 ${index}`,
|
||||
timestamp: new Date(Date.UTC(2026, 7, 3, 0, index % 60)).toISOString(),
|
||||
}));
|
||||
storedMessages[0] = {
|
||||
role: 'user',
|
||||
content: '这里保存着云杉锚点,点击结果应回到第一条消息。',
|
||||
timestamp: '2026-08-03T00:00:00.000Z',
|
||||
};
|
||||
storedMessages[1] = {
|
||||
role: 'system',
|
||||
content: 'tool-only-secret 不应被高级检索索引',
|
||||
timestamp: '2026-08-03T00:01:00.000Z',
|
||||
};
|
||||
storedMessages[2] = {
|
||||
role: 'assistant',
|
||||
content: 'anchored-only-token',
|
||||
timestamp: '2026-08-03T00:02:00.000Z',
|
||||
};
|
||||
fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({
|
||||
id: sessionId,
|
||||
title: '高级检索回归会话',
|
||||
agent: 'codexapp',
|
||||
cwd: homeDir,
|
||||
created: '2026-08-03T00:00:00.000Z',
|
||||
updated: '2026-08-03T03:00:00.000Z',
|
||||
messages: storedMessages,
|
||||
}));
|
||||
|
||||
const port = await getFreePort();
|
||||
const password = 'AdvancedSearch!234';
|
||||
await withServer({
|
||||
PORT: String(port),
|
||||
CC_WEB_PASSWORD: password,
|
||||
CC_WEB_INTERNAL_MCP_TOKEN: 'AdvancedSearchMcp!234',
|
||||
CC_WEB_CONFIG_DIR: configDir,
|
||||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||||
CC_WEB_LOGS_DIR: logsDir,
|
||||
HOME: homeDir,
|
||||
CLAUDE_PATH: MOCK_CLAUDE,
|
||||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||
}, async () => {
|
||||
const { ws, messages, receivedMessages } = await connectWs(port, password, { trackReceived: true });
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||
const baselineSessionLists = receivedMessages.filter((msg) => msg.type === 'session_list').length;
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'search_sessions',
|
||||
requestId: 'advanced-search-hit',
|
||||
query: '云杉锚点',
|
||||
agent: 'codexapp',
|
||||
sort: 'relevance',
|
||||
matchMode: 'contains',
|
||||
limit: 50,
|
||||
}));
|
||||
const hitResponse = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_search_results' && msg.requestId === 'advanced-search-hit'
|
||||
));
|
||||
assert(hitResponse.total === 1 && hitResponse.results[0]?.sessionId === sessionId,
|
||||
'Advanced body search should return the matching session');
|
||||
assert(hitResponse.results[0]?.matches?.[0]?.messageIndex === 0
|
||||
&& /云杉锚点/.test(hitResponse.results[0]?.matches?.[0]?.snippet || ''),
|
||||
'Advanced body search should return a bounded snippet and stable messageIndex');
|
||||
|
||||
await sleep(140);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'search_sessions',
|
||||
requestId: 'advanced-search-private-exclusion',
|
||||
query: 'tool-only-secret',
|
||||
agent: 'codexapp',
|
||||
matchMode: 'contains',
|
||||
}));
|
||||
const excludedResponse = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_search_results' && msg.requestId === 'advanced-search-private-exclusion'
|
||||
));
|
||||
assert(excludedResponse.total === 0, 'System/tool-only content should not enter the advanced search index');
|
||||
|
||||
await sleep(140);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'search_sessions',
|
||||
requestId: 'advanced-search-word-boundary',
|
||||
query: 'anchored',
|
||||
agent: 'codexapp',
|
||||
matchMode: 'word',
|
||||
}));
|
||||
const wordResponse = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_search_results' && msg.requestId === 'advanced-search-word-boundary'
|
||||
));
|
||||
assert(wordResponse.total === 1, 'Whole-word search should match an exact normalized token');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'load_session',
|
||||
sessionId,
|
||||
requestId: 'advanced-search-target-load',
|
||||
targetMessageIndex: 0,
|
||||
}));
|
||||
const sessionInfo = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' && msg.requestId === 'advanced-search-target-load'
|
||||
));
|
||||
assert(sessionInfo.historyPending === true, 'Targeted load fixture should stream older history chunks');
|
||||
const historyChunks = [];
|
||||
let finalChunk = null;
|
||||
do {
|
||||
finalChunk = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_history_chunk' && msg.requestId === 'advanced-search-target-load'
|
||||
));
|
||||
historyChunks.push(finalChunk);
|
||||
} while (finalChunk.remaining > 0);
|
||||
assert(Math.min(...historyChunks.map((chunk) => Number(chunk.historyBaseIndex))) === 0,
|
||||
'Targeted session load should prefetch history through the matched message index');
|
||||
assert(historyChunks.some((chunk) => (
|
||||
Number(chunk.historyBaseIndex) === 0 && /云杉锚点/.test(chunk.messages?.[0]?.content || '')
|
||||
)), 'Targeted history chunks should include the exact matched message');
|
||||
|
||||
await sleep(160);
|
||||
assert(receivedMessages.filter((msg) => msg.type === 'session_list').length === baselineSessionLists,
|
||||
'Advanced search and targeted history loading must not refresh or replace session_list');
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
function assertWindowsStartupContract() {
|
||||
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
@@ -4514,6 +4973,19 @@ async function main() {
|
||||
console.log('Sidebar title refresh storm regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'advanced-session-search') {
|
||||
assertAdvancedSessionSearchContract();
|
||||
await runAdvancedSessionSearchRegression();
|
||||
console.log('Advanced session search regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'usage-statistics') {
|
||||
assertUsageStatisticsUnitChecks();
|
||||
assertUsageStatisticsContract();
|
||||
await runUsageStatisticsRegression();
|
||||
console.log('Usage statistics regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'windows-startup') {
|
||||
assertWindowsStartupContract();
|
||||
console.log('Windows startup regression checks passed.');
|
||||
@@ -4548,6 +5020,9 @@ async function main() {
|
||||
assertTitleHistoryOutlineContract();
|
||||
assertSessionSwitchResilienceContract();
|
||||
assertSessionSwitchRaceContract();
|
||||
assertAdvancedSessionSearchContract();
|
||||
assertUsageStatisticsUnitChecks();
|
||||
assertUsageStatisticsContract();
|
||||
assertCodexAppChildToolRoutingContract();
|
||||
assertMultiAgentV2CompatibilityContract();
|
||||
assertWindowsStartupContract();
|
||||
|
||||
215
scripts/usage-statistics-unit.js
Normal file
215
scripts/usage-statistics-unit.js
Normal file
@@ -0,0 +1,215 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
SCHEMA_VERSION,
|
||||
UsageStatisticsError,
|
||||
createUsageStatisticsIndex,
|
||||
} = require('../lib/usage-statistics');
|
||||
|
||||
function writeSession(sessionsDir, session) {
|
||||
const filePath = path.join(sessionsDir, `${session.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function sessionFixture() {
|
||||
return {
|
||||
id: 'usage-session-a',
|
||||
title: '统计夹具 A',
|
||||
created: '2026-07-27T00:00:00.000Z',
|
||||
updated: '2026-07-29T03:00:00.000Z',
|
||||
cwd: '/tmp/usage-project',
|
||||
agent: 'codexapp',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '不能出现在统计响应里的正文 secret-body',
|
||||
timestamp: '2026-07-28T01:00:00.000Z',
|
||||
composerMentions: [
|
||||
{ kind: 'skill', name: 'openai-docs', label: '$openai-docs' },
|
||||
{ kind: 'file', name: 'AGENTS.md', label: '@AGENTS.md' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '已处理',
|
||||
timestamp: '2026-07-28T02:00:00.000Z',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'McpToolCall',
|
||||
kind: 'mcp_tool_call',
|
||||
input: { server: 'ccweb', tool: 'ccweb_list_conversations', arguments: { secret: true } },
|
||||
done: true,
|
||||
result: 'secret-result',
|
||||
meta: { kind: 'mcp_tool_call', subtitle: 'ccweb.ccweb_list_conversations', status: 'completed' },
|
||||
},
|
||||
{
|
||||
name: 'McpToolCall',
|
||||
kind: 'mcp_tool_call',
|
||||
input: { server: 'ccweb', tool: 'ccweb_send_message', arguments: {} },
|
||||
done: true,
|
||||
result: 'failed secret-result',
|
||||
meta: { kind: 'mcp_tool_call', subtitle: 'ccweb.ccweb_send_message', status: 'failed' },
|
||||
},
|
||||
{
|
||||
name: 'Read',
|
||||
kind: 'function_call',
|
||||
input: { file: 'SKILL.md' },
|
||||
done: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: '跨会话消息',
|
||||
timestamp: '2026-07-29T03:00:00.000Z',
|
||||
crossConversation: { sourceSessionId: 'source-session' },
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '范围外调用',
|
||||
timestamp: '2026-08-03T00:00:00.000Z',
|
||||
toolCalls: [{
|
||||
name: 'McpToolCall',
|
||||
kind: 'mcp_tool_call',
|
||||
input: { server: 'outside', tool: 'outside_tool' },
|
||||
done: true,
|
||||
meta: { status: 'completed' },
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-statistics-'));
|
||||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||||
const cacheFile = path.join(tempRoot, 'cache', 'index-v1.json');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
try {
|
||||
const fixture = sessionFixture();
|
||||
const sourceFile = writeSession(sessionsDir, fixture);
|
||||
writeSession(sessionsDir, {
|
||||
id: 'usage-session-boundary',
|
||||
title: '范围右边界',
|
||||
created: '2026-08-03T00:00:00.000Z',
|
||||
updated: '2026-08-03T00:00:00.000Z',
|
||||
messages: [{ role: 'user', content: '右边界消息', timestamp: '2026-08-03T00:00:00.000Z' }],
|
||||
});
|
||||
fs.writeFileSync(path.join(sessionsDir, 'broken-session.json'), '{broken');
|
||||
const beforeContent = fs.readFileSync(sourceFile, 'utf8');
|
||||
const beforeMtime = fs.statSync(sourceFile).mtimeMs;
|
||||
const index = createUsageStatisticsIndex({ sessionsDir, cacheFile });
|
||||
const initStatus = await index.initialize();
|
||||
assert(initStatus.ready, '统计索引首次构建失败');
|
||||
assert.strictEqual(initStatus.indexedSessions, 2, '损坏会话不应进入统计索引');
|
||||
assert(initStatus.failedFiles >= 1, '损坏会话应记录失败数量');
|
||||
|
||||
const result = index.query({
|
||||
from: '2026-07-27T00:00:00.000Z',
|
||||
to: '2026-08-03T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
assert.strictEqual(result.schemaVersion, SCHEMA_VERSION);
|
||||
assert.strictEqual(result.range.semantics, '[from,to)');
|
||||
assert.strictEqual(result.overview.newSessions, 1, '右边界会话不应计入');
|
||||
assert.strictEqual(result.overview.messages, 2);
|
||||
assert.strictEqual(result.overview.directMessages, 1);
|
||||
assert.strictEqual(result.overview.crossConversationMessages, 1);
|
||||
assert.strictEqual(result.overview.mcpCalls, 2);
|
||||
assert.strictEqual(result.overview.mcpFailures, 1, 'done=true 的失败调用仍应计入失败');
|
||||
assert.strictEqual(result.overview.skillMentions, 1);
|
||||
assert.strictEqual(result.mcpStatus.completed, 1);
|
||||
assert.strictEqual(result.mcpStatus.failed, 1);
|
||||
assert.strictEqual(result.mcpTools[0].server, 'ccweb');
|
||||
assert.strictEqual(result.mcpTools[0].calls, 1, '同调用数时应按工具名稳定排序');
|
||||
assert.strictEqual(result.mcpTools[0].detailCallsReturned, 1);
|
||||
assert.strictEqual(result.skills[0].name, 'openai-docs');
|
||||
assert.strictEqual(result.mcpRecentCalls[0].timestamp, '2026-07-28T02:00:00.000Z');
|
||||
assert(result.trend.find((row) => row.date === '2026-07-28')?.mcpCalls === 2);
|
||||
const serialized = JSON.stringify(result);
|
||||
assert(!serialized.includes('secret-body'));
|
||||
assert(!serialized.includes('secret-result'));
|
||||
assert(!serialized.includes('arguments'));
|
||||
assert.strictEqual(fs.readFileSync(sourceFile, 'utf8'), beforeContent, '统计查询不得修改会话文件');
|
||||
assert.strictEqual(fs.statSync(sourceFile).mtimeMs, beforeMtime, '统计查询不得触碰会话文件 mtime');
|
||||
assert(fs.existsSync(cacheFile), '统计派生缓存应被持久化');
|
||||
|
||||
const limited = index.query({
|
||||
from: '2026-07-27T00:00:00.000Z',
|
||||
to: '2026-08-03T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
mcpToolLimit: 1,
|
||||
skillLimit: 1,
|
||||
mcpDetailLimit: 1,
|
||||
});
|
||||
assert.strictEqual(limited.coverage.distinctMcpTools, 2);
|
||||
assert.strictEqual(limited.coverage.returnedMcpTools, 1);
|
||||
assert.strictEqual(limited.mcpTools.length, 1);
|
||||
assert.strictEqual(limited.mcpRecentCalls.length, 1);
|
||||
assert.strictEqual(limited.mcpTools[0].detailCallsReturned, 1);
|
||||
assert.strictEqual(limited.coverage.distinctSkills, 1);
|
||||
assert.strictEqual(limited.coverage.returnedSkills, 1);
|
||||
|
||||
fixture.messages.push({ role: 'user', content: '增量消息', timestamp: '2026-07-30T04:00:00.000Z' });
|
||||
writeSession(sessionsDir, fixture);
|
||||
assert(index.scheduleUpsert(fixture.id), '索引初始化后应接受增量更新');
|
||||
await index.flush();
|
||||
assert.strictEqual(index.query({
|
||||
from: '2026-07-27T00:00:00.000Z',
|
||||
to: '2026-08-03T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
}).overview.messages, 3, '增量更新未生效');
|
||||
|
||||
fs.writeFileSync(sourceFile, '');
|
||||
assert(index.scheduleUpsert(fixture.id), '空文件更新应进入安全增量路径');
|
||||
await index.flush();
|
||||
assert.strictEqual(index.query({
|
||||
from: '2026-07-27T00:00:00.000Z',
|
||||
to: '2026-08-03T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
}).overview.messages, 0, '异常空文件不应继续保留旧统计文档');
|
||||
|
||||
writeSession(sessionsDir, fixture);
|
||||
assert(index.scheduleUpsert(fixture.id), '恢复后的会话文件应可重新进入索引');
|
||||
await index.flush();
|
||||
|
||||
fs.unlinkSync(sourceFile);
|
||||
assert(index.remove(fixture.id), '删除会话应移除统计文档');
|
||||
await index.flush();
|
||||
assert.strictEqual(index.query({
|
||||
from: '2026-07-27T00:00:00.000Z',
|
||||
to: '2026-08-03T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
}).overview.messages, 0, '删除后的会话仍出现在统计中');
|
||||
|
||||
const reused = createUsageStatisticsIndex({ sessionsDir, cacheFile });
|
||||
const reusedStatus = await reused.initialize();
|
||||
assert(reusedStatus.ready, '派生缓存无法重新加载');
|
||||
assert.strictEqual(reusedStatus.indexedSessions, 1);
|
||||
|
||||
assert.throws(() => reused.query({
|
||||
from: '2026-08-03T00:00:00.000Z',
|
||||
to: '2026-07-27T00:00:00.000Z',
|
||||
timeZone: 'UTC',
|
||||
}), (error) => error instanceof UsageStatisticsError && error.code === 'invalid_range');
|
||||
assert.throws(() => reused.query({
|
||||
from: '2026-07-27T00:00:00.000Z',
|
||||
to: '2026-08-03T00:00:00.000Z',
|
||||
timeZone: 'Invalid/Zone',
|
||||
}), (error) => error instanceof UsageStatisticsError && error.code === 'invalid_time_zone');
|
||||
|
||||
console.log('Usage statistics unit checks passed.');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
188
server.js
188
server.js
@@ -10,6 +10,8 @@ const { createCodexAppServerClient } = require('./lib/codex-app-server-client');
|
||||
const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client');
|
||||
const { createCodexAppRuntime } = require('./lib/codex-app-runtime');
|
||||
const { createCodexRolloutStore } = require('./lib/codex-rollouts');
|
||||
const { createSessionSearchIndex } = require('./lib/session-search-index');
|
||||
const { createUsageStatisticsIndex, UsageStatisticsError } = require('./lib/usage-statistics');
|
||||
const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server');
|
||||
const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' };
|
||||
|
||||
@@ -126,6 +128,7 @@ const CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV = 'CC_WEB_CODEX_APP_MCP_TOKEN';
|
||||
const CODEX_APP_CCWEB_MCP_TRANSPORT = normalizeCodexAppCcwebMcpTransport(process.env.CC_WEB_CODEX_APP_CCWEB_MCP_TRANSPORT);
|
||||
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
|
||||
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
|
||||
const USAGE_STATISTICS_ENABLED = !/^(0|false|no|off)$/i.test(String(process.env.CC_WEB_USAGE_STATISTICS || ''));
|
||||
const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [
|
||||
'CC_WEB_MCP_URL',
|
||||
'CC_WEB_MCP_TOKEN',
|
||||
@@ -219,6 +222,56 @@ function plog(level, event, data = {}) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sessionSearchIndex = createSessionSearchIndex({
|
||||
sessionsDir: SESSIONS_DIR,
|
||||
maxFileBytes: SESSION_LOAD_MAX_BYTES,
|
||||
maxQueryChars: 200,
|
||||
maxResults: 50,
|
||||
snippetChars: 220,
|
||||
logger: {
|
||||
warn: (event, data) => plog('WARN', event, data),
|
||||
error: (event, data) => plog('ERROR', event, data),
|
||||
log: (event, data) => plog('INFO', event, data),
|
||||
},
|
||||
});
|
||||
const sessionSearchIndexReady = sessionSearchIndex.initialize();
|
||||
const usageStatisticsIndex = USAGE_STATISTICS_ENABLED ? createUsageStatisticsIndex({
|
||||
sessionsDir: SESSIONS_DIR,
|
||||
maxFileBytes: SESSION_LOAD_MAX_BYTES,
|
||||
maxRangeDays: 370,
|
||||
logger: {
|
||||
warn: (event, data) => plog('WARN', event, data),
|
||||
error: (event, data) => plog('ERROR', event, data),
|
||||
log: (event, data) => plog('INFO', event, data),
|
||||
},
|
||||
}) : null;
|
||||
|
||||
function scheduleUsageStatisticsUpsert(sessionId) {
|
||||
if (!usageStatisticsIndex) return false;
|
||||
try {
|
||||
return usageStatisticsIndex.scheduleUpsert(sessionId);
|
||||
} catch (err) {
|
||||
plog('WARN', 'usage_statistics_schedule_ignored', {
|
||||
sessionId: String(sessionId || '').slice(0, 8),
|
||||
error: err?.message || String(err || ''),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeUsageStatisticsSession(sessionId) {
|
||||
if (!usageStatisticsIndex) return false;
|
||||
try {
|
||||
return usageStatisticsIndex.remove(sessionId);
|
||||
} catch (err) {
|
||||
plog('WARN', 'usage_statistics_remove_ignored', {
|
||||
sessionId: String(sessionId || '').slice(0, 8),
|
||||
error: err?.message || String(err || ''),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === Notification System ===
|
||||
const DEFAULT_SUMMARY_CONFIG = {
|
||||
enabled: false,
|
||||
@@ -3896,6 +3949,8 @@ function saveSession(session) {
|
||||
});
|
||||
}
|
||||
updateSessionRuntimeThreadIndex(session);
|
||||
sessionSearchIndex.scheduleUpsert(session.id);
|
||||
scheduleUsageStatisticsUpsert(session.id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
plog('ERROR', 'session_save_failed', {
|
||||
@@ -4517,6 +4572,108 @@ function sendSessionList(ws) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearchSessions(ws, msg = {}) {
|
||||
const requestId = String(msg.requestId || '').trim().slice(0, 160);
|
||||
const query = String(msg.query || '').trim().slice(0, 200);
|
||||
const sendSearchError = (code, message) => wsSend(ws, {
|
||||
type: 'session_search_error',
|
||||
requestId,
|
||||
code,
|
||||
message,
|
||||
});
|
||||
|
||||
if (!requestId) return sendSearchError('invalid_request_id', '检索请求缺少 requestId');
|
||||
if (query.length < 2) return sendSearchError('invalid_query', '请输入至少 2 个字符');
|
||||
|
||||
const now = Date.now();
|
||||
const previousSearchAt = Number(ws._ccWebSessionSearchAt || 0);
|
||||
if (previousSearchAt && now - previousSearchAt < 120) {
|
||||
return sendSearchError('rate_limited', '检索过于频繁,请稍后重试');
|
||||
}
|
||||
ws._ccWebSessionSearchAt = now;
|
||||
|
||||
try {
|
||||
const status = await sessionSearchIndexReady;
|
||||
if (!status?.ready) return sendSearchError('index_unavailable', '会话索引暂不可用,请稍后重试');
|
||||
await sessionSearchIndex.flush();
|
||||
const result = sessionSearchIndex.search({
|
||||
query,
|
||||
agent: msg.agent,
|
||||
sort: msg.sort === 'newest' ? 'newest' : 'relevance',
|
||||
matchMode: msg.matchMode === 'word' ? 'word' : 'contains',
|
||||
limit: Math.max(1, Math.min(50, Number.parseInt(String(msg.limit || '50'), 10) || 50)),
|
||||
});
|
||||
const totalMatches = result.results.reduce((sum, item) => {
|
||||
return sum + Math.max(1, Number(item.matchedMessageCount || item.matches?.length || 0));
|
||||
}, 0);
|
||||
wsSend(ws, {
|
||||
type: 'session_search_results',
|
||||
requestId,
|
||||
total: result.total,
|
||||
totalMatches,
|
||||
tookMs: result.tookMs,
|
||||
indexState: result.indexState,
|
||||
results: result.results,
|
||||
});
|
||||
} catch (err) {
|
||||
plog('WARN', 'session_search_request_failed', {
|
||||
requestId: requestId.slice(0, 32),
|
||||
error: err?.message || String(err || ''),
|
||||
});
|
||||
sendSearchError('search_failed', '检索失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUsageStatisticsQuery(ws, msg = {}) {
|
||||
const requestId = String(msg.requestId || '').trim().slice(0, 160);
|
||||
const sendUsageError = (code, message) => wsSend(ws, {
|
||||
type: 'usage_stats_error',
|
||||
requestId,
|
||||
code,
|
||||
message,
|
||||
});
|
||||
|
||||
if (!requestId) return sendUsageError('invalid_request_id', '统计请求缺少 requestId');
|
||||
if (!USAGE_STATISTICS_ENABLED || !usageStatisticsIndex) {
|
||||
return sendUsageError('disabled', '使用统计功能当前未启用');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const previousQueryAt = Number(ws._ccWebUsageStatisticsAt || 0);
|
||||
if (previousQueryAt && now - previousQueryAt < 250) {
|
||||
return sendUsageError('rate_limited', '统计刷新过于频繁,请稍后重试');
|
||||
}
|
||||
ws._ccWebUsageStatisticsAt = now;
|
||||
|
||||
try {
|
||||
const status = await usageStatisticsIndex.initialize();
|
||||
if (!status?.ready) return sendUsageError('index_unavailable', '统计索引暂不可用,请稍后重试');
|
||||
await usageStatisticsIndex.flush();
|
||||
const result = usageStatisticsIndex.query({
|
||||
from: String(msg.from || '').slice(0, 80),
|
||||
to: String(msg.to || '').slice(0, 80),
|
||||
timeZone: String(msg.timeZone || 'UTC').slice(0, 80),
|
||||
recentLimit: 50,
|
||||
mcpToolLimit: 200,
|
||||
skillLimit: 100,
|
||||
mcpDetailLimit: 250,
|
||||
});
|
||||
wsSend(ws, {
|
||||
type: 'usage_stats_result',
|
||||
requestId,
|
||||
...result,
|
||||
});
|
||||
} catch (err) {
|
||||
const known = err instanceof UsageStatisticsError;
|
||||
plog(known ? 'WARN' : 'ERROR', 'usage_statistics_query_failed', {
|
||||
requestId: requestId.slice(0, 32),
|
||||
code: known ? err.code : 'query_failed',
|
||||
error: err?.message || String(err || ''),
|
||||
});
|
||||
sendUsageError(known ? err.code : 'query_failed', known ? err.message : '统计查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSessionList() {
|
||||
if (!wss) return;
|
||||
for (const client of wss.clients) {
|
||||
@@ -6392,7 +6549,13 @@ wss.on('connection', (ws, req) => {
|
||||
authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
|
||||
activeTokens.add(authToken);
|
||||
authenticated = true;
|
||||
wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
|
||||
wsSend(ws, {
|
||||
type: 'auth_result',
|
||||
success: true,
|
||||
token: authToken,
|
||||
mustChangePassword: !!authConfig.mustChange,
|
||||
features: { usageStatistics: USAGE_STATISTICS_ENABLED },
|
||||
});
|
||||
sendSessionList(ws);
|
||||
} else {
|
||||
const justBanned = recordAuthFailure(clientIP);
|
||||
@@ -6433,6 +6596,12 @@ wss.on('connection', (ws, req) => {
|
||||
case 'load_history_page':
|
||||
handleLoadHistoryPage(ws, msg);
|
||||
break;
|
||||
case 'search_sessions':
|
||||
handleSearchSessions(ws, msg);
|
||||
break;
|
||||
case 'usage_stats_query':
|
||||
handleUsageStatisticsQuery(ws, msg);
|
||||
break;
|
||||
case 'delete_session':
|
||||
handleDeleteSession(ws, msg.sessionId);
|
||||
break;
|
||||
@@ -7714,7 +7883,20 @@ function handleLoadSession(ws, msg) {
|
||||
saveSession(refreshedSession);
|
||||
}
|
||||
}
|
||||
const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(refreshedSession.messages);
|
||||
const requestedTargetMessageIndex = Number.parseInt(String(
|
||||
typeof msg === 'object' ? msg?.targetMessageIndex ?? '' : '',
|
||||
), 10);
|
||||
const targetMessageIndex = Number.isFinite(requestedTargetMessageIndex)
|
||||
? Math.max(0, Math.min(refreshedSession.messages.length - 1, requestedTargetMessageIndex))
|
||||
: null;
|
||||
const recentHistoryBaseIndex = Math.max(0, refreshedSession.messages.length - INITIAL_HISTORY_COUNT);
|
||||
const targetPrefetchChunks = targetMessageIndex !== null && targetMessageIndex < recentHistoryBaseIndex
|
||||
? Math.ceil((recentHistoryBaseIndex - targetMessageIndex) / HISTORY_CHUNK_SIZE)
|
||||
: 0;
|
||||
const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(
|
||||
refreshedSession.messages,
|
||||
{ prefetchChunks: Math.max(HISTORY_PREFETCH_CHUNKS, targetPrefetchChunks) },
|
||||
);
|
||||
const effectiveCwd = refreshedSession.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
|
||||
const waitState = crossConversationWaitState(sessionId);
|
||||
|
||||
@@ -7884,6 +8066,8 @@ function handleDeleteSession(ws, sessionId) {
|
||||
removeAttachmentById(attachmentId);
|
||||
}
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
sessionSearchIndex.remove(sessionId);
|
||||
removeUsageStatisticsSession(sessionId);
|
||||
if (sessionAgent === 'codex') {
|
||||
const result = deleteCodexLocalSession(session);
|
||||
plog('INFO', 'codex_local_session_deleted', {
|
||||
|
||||
Reference in New Issue
Block a user