Repository navigation
Conversation
… index The Sessions search only indexed session metadata (ID, title, summary, project directory, source path), so a keyword that appears only in the middle of a conversation could never be found. Add a Rust-side full-text index built on the #7825 session model: - Separate `~/.cc-switch/session-index.db` (not the main database, never backed up or synced); one FTS5 trigram row per user/assistant message, Text blocks only, injected content skipped, 20,000 chars per message - Incremental: sources are re-parsed through `load_messages` only when the `source_fingerprint` (mtime, len) changes; deleted sessions drop their rows; optimize + VACUUM after large rebuilds - Queries shorter than 3 characters fall back to LIKE, or to a Rust-side case-folded scan when they contain non-ASCII cased letters - Up to 3 snippets per session; the reader opens with the find bar on the message behind the clicked snippet - "Search message text" toggle in the Sessions menu; turning it off deletes the index file Closes #7420 Refs #4976, #6636, #3721
The manual called the session search "Full-Text Search" and listed only metadata fields. Rename the section to "Search", describe the metadata and message text scopes, what the text index skips, where it is stored and how to turn it off. Refs #7420
GPT-6 Astra 自动代码审查审查提交: 由 Codex 定时任务使用 GPT-6 Astra 子 agent 审查,并经主 agent 复核。 [P2] 在索引锁内重新检查开关,避免关闭后重新落盘正文位置:src-tauri/src/session_manager/search.rs:536 在后台建索引期间关闭「搜索消息正文」时,with_index 可能先在第 536 行读到 enabled=true,然后线程被暂停;设置保存为 false、clear 持有 INDEX 锁关闭连接并删除文件后,后台线程才获得锁。这里没有锁内复查,会再次创建 session-index.db,并继续执行 index_source 已准备好的 replace_source,把会话正文重新写入磁盘。后续同步循环只会因关闭开关而退出,不会再删除这份索引,因此清理命令已成功返回、功能保持关闭时仍会留下正文副本。这是新的本地隐私清理竞态,不要求设置保存失败。 证据:
建议:至少在取得 INDEX 锁后、打开连接或调用闭包前再次检查 enabled;使关闭与清理成为可阻止旧工作的同步边界,必要时使用任务代次或取消标记。增加带屏障的回归测试,让后台工作暂停于锁前检查之后,完成关闭清理再恢复,断言索引及侧文件不再出现。 [P2] 在截断命中行之前应用 provider 过滤位置:src-tauri/src/session_manager/search.rs:462 当未选应用对某个查询占满全局前 20,000 条命中时,当前应用的真实结果会全部丢失。三个查询分支都先按 MAX_HIT_ROWS 截断,直到这里才过滤 provider;例如 Claude 有 20,000 条「needle xy Éclair」,Codex 有一条「needle xy Éclair target」,仅搜索 Codex 时 needle、xy、éc 都返回零条,尽管 Codex 只有一个匹配会话,远未达到 200 会话上限。若关键词不在该会话元数据中,页面会错误显示没有匹配会话,且刷新或缩小到 Codex 都不能恢复它。 证据:
建议:将 provider 条件下推到 FTS5/LIKE 候选查询,在 LIMIT 之前按 indexed_sources 过滤;短查询的逐行折叠也应只让选中应用消耗命中预算。添加未选应用占满 20,000 行、所选应用仍有匹配的三个分支回归用例。 [P2] 让成功刷新列表触发同步,而非仅监听元数据引用位置:src/hooks/useSessionContentSearch.ts:112 首次索引完成后,如果正文变化而 SessionMeta 不变,用户点击刷新仍不会更新正文索引。一个具体场景是 Grok Build 一轮进行中只追加 chat_history.jsonl,summary.json 尚未更新:列表扫描仅从 summary.json 生成元数据,TanStack Query 默认结构共享保留相等数据的数组引用,页面又通过 useMemo 保留该引用,因此此 effect 的 [enabled, sessions] 不变,syncContentIndex 根本不会调用。新关键词不在标题/摘要中时会持续搜不到,直到其他元数据改变、重新进入页面或重开开关;后端已有的正文源指纹检测也无法补救,因为它从未被触发。 证据:
建议:传入会话列表成功读取的版本(例如 dataUpdatedAt)作为同步依赖,或在成功 refetch 后显式请求同步,不要把元数据数组引用当作正文变更信号。用保持 list_sessions 返回内容不变、只修改合成正文的测试验证刷新会再次调用 syncContentIndex,并让新正文命中可见。 验证与范围子 agent 完整审阅固定三点差异的 24 个文件和必要上下文,实际 merge-base 与指定 base 一致。主 agent 复核 INDEX 锁、后端设置缓存更新和关闭清理顺序,确认旧后台工作可在清理返回后重建正文索引;逐分支确认候选行截断先于 provider 过滤;核对列表刷新、正文源指纹和 effect 依赖。主 agent 另只读核对本机已安装且与本 PR lockfile 一致的 @tanstack/query-core 5.90.3 源码,确认默认 replaceEqualDeep 保留等值数组引用,补齐子 agent 未读取该实现的限制。三条 P2 均保留,发布前三类讨论为空。验证仅为静态和隔离逻辑模型,不表示项目测试已通过。
未验证或受限范围:
本评论仅针对以上提交的代码审查结果,不构成合并批准。 |
- Check the "Search message text" switch after taking the index lock, so a background write that passed the check before the user turned the switch off can no longer recreate session-index.db after clear() deleted it - Apply the app filter inside the candidate queries (FTS5, LIKE and the case-folded scan) instead of after the 20,000-row cap, so a busy app can no longer crowd out the selected app's only matches - Sync the index after every successful session list read (dataUpdatedAt) rather than when the list array changes: structural sharing keeps the same array when only message text changed (e.g. a Grok Build turn in progress), so Refresh never reached the backend fingerprint check Refs #7420
…eat/session-content-search # Conflicts: # docs/user-manual/en/3-extensions/3.4-sessions.md # docs/user-manual/ja/3-extensions/3.4-sessions.md # docs/user-manual/zh/3-extensions/3.4-sessions.md
|
在当前 head
用可注入 loader + 实际
最小修复是读取失败时不调用 我正在准备单独的小补丁来补这两个回归,可针对本 PR 的 feature 分支提交,避免另做一套全文检索。 验证补充:已在隔离工作区补齐官方 Linux 系统依赖,真实项目的 后续回归补充(仍基于此 PR 的
四个小修复的独立组合验收已完成:前端 195 文件 / 2263 测试通过,typecheck 与 renderer build 通过;对应 Rust 源码的完整 lib 测试 3385 passed / 0 failed / 9 ignored,严格 Clippy 通过。未运行忽略项或真实桌面 E2E。补丁仍在本地,尚未发布 PR;会保留为针对本 feature 分支的独立小提交。 |
使用整串小写 shadow 索引和区分大小写的 trigram,短词在规范化正文上精确匹配。 摘录携带完整正文产生的 UTF-16 高亮范围,保留大小写上下文、空白压缩与多字节位置;补充检索、点击定位和安全渲染回归。
- 按提供商记录扫描完整性,仅清理确认扫描成功的源 - 保留读取失败时的正文和指纹,恢复后正常增量同步 - 覆盖目录故障、独立清理、数据库恢复和 UTF-8 尾部读取
farion1231
left a comment
There was a problem hiding this comment.
感谢这版实现!先说核实过的部分:上一轮自动审查提的三处(锁内复查开关、应用过滤下推到截断之前、按 dataUpdatedAt 触发同步)都已修好,我逐条核过。索引只收正文,而阅读页的查找范围包含正文,所以列表里显示命中的会话,点进去一定能找到。和 main 合并后无冲突,cargo test --lib 与 pnpm typecheck / pnpm test:unit 全部通过。
下面两个 P2 修掉就可以合并,P3 和 nit 看你方便。
[P2] OpenCode / Hermes / MiniMax Code 的会话会在每次同步时全部重建索引
这三家的会话存在同一个 SQLite 数据库里,而指纹只看整个库文件(加 WAL)的修改时间和大小,不区分是哪个会话:
cc-switch/src-tauri/src/session_manager/cache.rs
Lines 98 to 105 in 078031d
所以客户端只要写入一条消息,这个应用所有会话的指纹都会变。下一次同步会把它们全部重新解析、重写索引行;重建数一旦达到 50,还会触发 optimize + VACUUM:
cc-switch/src-tauri/src/session_manager/search.rs
Lines 705 to 709 in 078031d
VACUUM 会重写整个索引文件(按 PR 描述约 118MB),期间持有索引锁,搜索要等它跑完。同步的触发时机是会话列表每次刷新成功(useSessionContentSearch.ts#L110-L123)。列表查询 30 秒后过期(queries.ts#L312),加上全局开了窗口获得焦点时重新拉取,所以用户一边用 OpenCode、一边切回会话页,每次切回都会重跑一遍。
建议对 SQLite 来源改用会话自己的最后活跃时间当指纹。扫描结果里已经有这个值:OpenCode 取的是会话和消息 time_updated 的最大值(opencode.rs#L218),MiniMax Code 是 updated_at_ms,Hermes 优先取 last_activity_at(hermes.rs#L229-L234)。Hermes 的旧版库如果没有这一列,会回落到 ended_at / updated_at,请确认会话进行中这个值也会更新。大致改法:run_sync 收集来源时把 last_active_at 一起带上,然后在 index_source 里:
let fingerprint = if matches!(source.location, content::SourceLocation::Sqlite { .. }) {
// 这几家的会话共用一个数据库,整库指纹一写全变;改用会话自己的最后活跃时间
StoredFingerprint { modified_ns: last_active_at, len: 0 }
} else {
let Ok(fp) = cache::source_fingerprint(&source) else { return false };
StoredFingerprint::from(fp)
};另外,整理(optimize + VACUUM)建议不要只看「这次重建了多少个会话」:比如只在首次建库后整理(同步开始时 indexed_sources 为空),这样就不会每隔几次同步就把整个文件重写一遍。
[P2] 会话读取失败时不应写入指纹
search.rs#L733-L736 会把 load_messages 的错误当成零行正文,并照常记下指纹。之后只要指纹不变就不会重试;如果这个会话之前已经收录过,旧正文也会被清掉(@xue-ding-e 在上面的评论里也提到了)。
单看这一条影响有限:指纹是在读取之前取的,因为并发写入导致的读取失败,等写入结束后指纹通常会变,于是会重新读。但上一条改成「按会话最后活跃时间」做指纹之后,这条自动恢复的路就没了:一次失败要等这个会话来新消息才会重试。所以建议两条一起改:
let rows = match super::load_messages(&source.provider_id, &source.load_path()) {
Ok(messages) => message_rows(&messages),
Err(e) => {
log::warn!("读取会话正文失败,下次同步重试: {e}");
return false;
}
};真正坏掉的文件会在每次同步时重试一次解析,代价是一次失败的读取,可以接受。
[P3] 点正文摘录时可能定位到思考块
阅读页的查找始终包含思考内容(「包含思考」只影响复制),而思考步骤和正文带的是同一个消息序号。findMatchPositionForMessage 按文档顺序取第一个序号相同的命中,所以 assistant 消息是 [thinking("… needle …"), text("… needle …")] 时,会定位到思考块,而不是摘录所在的正文。这条消息作为最终回复、或作为中途说明,两种情况我都用最小用例复现了:返回 1,预期 2。建议优先选正文命中:
let sameMessage: number | null = null;
// …循环内命中同一条消息时:
const isText =
match.target === "question" ||
match.target === "final" ||
turn.steps.some((s) => s.kind === "note" && s.id === match.targetId);
if (isText) return position;
sameMessage ??= position;
// …
return sameMessage ?? sameTurn ?? 1;nit
run_sync一开始会再调一次scan_sessions()(search.rs#L672-L678),而它是在list_sessions刚扫完之后被触发的,等于每次刷新扫两遍。虽然在后台线程里跑,会话多的机器上还是能省一次。
修掉两个 P2 就可以合并,推上来后我会尽快复审。
|
补充这项性能 P2 的复现,供当前修订参考(AI 辅助、本地验证):基于 #8150 的 直接把 #8150 已覆盖另一个读取失败 P2:保留旧正文和旧指纹,允许下次重试,同时保留扫描不完整时不清理旧索引的保护。性能项建议单独使用能反映正文变化的会话 revision,普通增量刷新不再仅因 changed >= 50 执行 VACUUM。 本次验证使用提取的生产 Rust 函数与 bundled SQLite 3.45.0、合成数据,结果为 10 项通过、1 项预期失败;属于隔离模块复现,未将其称为完整 Tauri、跨平台测试或大型索引延迟基准。 |
fix(sessions): harden content-search lifecycle and Unicode matching
|
Thanks for the thorough review. The follow-ups are in the current head: SQLite-backed sources (OpenCode/Hermes/MiniMax Code) now fingerprint per session and only compact after the first build, a failed |
…eat/session-content-search # Conflicts: # src-tauri/src/session_manager/providers/hermes.rs
Summary / 概述
会话页的搜索只索引元数据(会话 ID、标题、摘要、项目目录、源文件路径),只出现在对话中段的关键词永远搜不到,而手册把它叫「全文搜索」。本 PR 在 #7825 重写后的会话结构上加了正文搜索,并改写手册。
后端(
session_manager/search.rs)~/.cc-switch/session-index.db:不进主库,不参与备份与云同步,删掉后会重建。load_messages解析,按source_fingerprint(mtime, len)跳过没变的会话;会话删除时同步删除索引行;一次重建 ≥50 个会话后 optimize + VACUUM。LIKE;含 É、Ж 之类有大小写的非 ASCII 字母时改为 Rust 侧逐行折叠比较(SQLiteLIKE只折叠 ASCII)。sync_session_index(后台同步,推送session-index-status进度事件)、search_session_content、clear_session_index;新设置sessionContentSearchEnabled(默认开)。前端
手册:
3.4-sessions.md三种语言的「全文搜索」一节改为「搜索」,写清两部分范围、正文不收录的内容、索引位置和关闭方式。Related Issue / 关联 Issue
Closes #7420
Related: #3721, #2019, #7399, #6711
Builds on: #7825(结构化 blocks 与源指纹缓存)
Prior art / 致谢
本 PR 在 #7825 重写后的会话结构上重新实现了正文搜索,设计参考了以下工作,在此致谢:
load_messages统一分发、无需为每个 provider 单独写扫描逻辑的做法,并指出 SQLiteLIKE只折叠 ASCII 大小写的问题;命中带messageIndex、每个会话多条摘录、点击摘录跳到对应消息、摘录格式和四语言文案也参考了它。本 PR 的search.rs/useSessionContentSearch与其命名相同,但实现是在新地基上重写的(持久化 FTS5 索引,而非每次实时扫描)。两个前置 PR 合并后,可由维护者决定是否关闭 #4976 / #6636。
Performance / 性能(release,本机真实数据)
1672 个会话(Claude Code、Codex、OpenCode 等),收录约 11 万条消息、约 30MB 正文:
Screenshots / 截图
(待补)
Checklist / 检查清单
pnpm typecheckpasses / 通过 TypeScript 类型检查pnpm format:checkpasses / 通过代码格式检查cargo clippypasses (if Rust code changed) / 通过 Clippy 检查(新代码无警告)验证:
cargo test(全部通过;session_manager::search10 个新用例)、pnpm test:unit(190 files, 2203 tests passed;新增页面用例 4 个、turns用例 1 个);开发版与已安装版并行,在本机真实会话上实际使用。