feat(web): add basic past-session search (round 1)

This commit is contained in:
Hypatia May
2026-07-27 12:15:06 +08:00
parent 79eb3a9035
commit 891e9035e7
56 changed files with 1646 additions and 269 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md
2026-07-27-web-session-search.md: 3cc44ba3652415e9fa32ce67bceefc822c41059b
2026-07-27-web-session-search.zh.md: 2b6ea7a60e1b051757852ff331c0b93c48bd2b57

View File

@@ -0,0 +1,42 @@
# Agent Note: Web past-session search
Status: implemented
English | [中文](2026-07-27-web-session-search.zh.md)
## Problem
The Web sidebar exposes session titles and Workspace membership but cannot retrieve a past conversation from words that appear only inside its messages. Scanning histories in the browser would require attaching or loading every session, duplicate the existing indexed-search service, and make cold persisted sessions both slow and easy to omit. The product also needs a predictable failure path: an unavailable derived index must not erase title matches that the client can compute locally.
## Decision
The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at `.sessions/session-query.db`. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence.
The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store.
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event.
Content matching inherits the SQLite backend's normalized literal token/phrase semantics. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching.
## Failure and visibility contract
Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and the query receives only ids from that baseline. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits.
While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint.
## Alternatives considered
- **Scan every session history in the browser** — rejected because it attaches transport and fold cost to the UI, misses cold logs unless they are loaded, and duplicates the semantic extraction and source reconciliation already owned by `ctx.sessionQuery`.
- **Make trigram or fuzzy search part of the first release** — rejected because it changes index size, ranking, short-query behavior, and product expectations. Trigrams also do not by themselves solve two-character queries. The first release uses the existing backend contract and leaves recall expansion as a separate measured decision.
- **Return event addresses and jump to the exact match** — rejected for this release because conversation virtualization and stable event navigation need a separate UI contract. Session-level navigation is useful without coupling search to that work.
- **Expose cursor pagination in the sidebar** — rejected in favor of a fixed top-20 surface and a narrow-query hint; this keeps the interaction and cancellation state bounded.
## Consequences
Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search.
The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation.
## Testing
Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains.

View File

@@ -0,0 +1,42 @@
# Agent Note: Web 历史会话搜索
Status: implemented
[English](2026-07-27-web-session-search.md) | 中文
## 问题
Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只出现在消息中的词语检索历史对话。在浏览器中扫描历史记录,需要附加或加载每个会话,重复实现现有的索引搜索服务,也会让冷态持久化会话的检索既缓慢又容易遗漏。产品还需要一条可预测的故障路径:派生索引不可用时,不得抹去客户端能够在本地计算出的标题匹配结果。
## 决策
Web 与 headless 共用的组合会在 `.sessions/session-query.db` 挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message``assistant/message``steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重无论常规分组模式如何最终都渲染为扁平列表。每一行显示标题、Workspace并在存在时显示一行摘要片段。选择某一行只会打开对应会话并保留查询条件不会跳转至确切事件。
内容匹配沿用 SQLite 后端经过规范化的字面 token短语语义。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。
## 故障与可见性契约
搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;查询只会接收这条基线提供的 id。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。
首个或后续内容请求仍在处理期间UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。
## 曾考虑的替代方案
- **在浏览器中扫描每个会话的历史记录**:不予采纳,因为这会让 UI 承担传输与折叠开销;除非加载冷态日志,否则还会漏掉这些日志;并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。
- **首版即加入 trigram 或模糊搜索**不予采纳因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端契约,将召回扩展留作另一项基于度量结果的决策。
- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 契约。会话级导航本身已有价值,无需让搜索与这项工作耦合。
- **在侧边栏公开游标分页**:不予采纳,改为固定显示前 20 条结果并提示缩小查询范围;这样可使交互与取消状态保持有界。
## 后果
无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。
首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token短语召回而不是模糊召回或任意子串召回并受上述连续中文限制。结果粒度为会话最多 20 条,不支持分页,也不能跳转到具体消息。
## 测试
宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、取消与故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。