feat(chat): 会话内消息搜索 D2 —— 顶栏入口 + 搜索态面板 + 点结果定位

改了什么
- data/api/SessionsApi.kt:messages 端点加可选 q(契约 §1 行 6:q 是同一个端点上的
  查询参数,不是新端点)。不传 q 时 Retrofit 整个省掉该参数,既有调用线上请求逐字不变
  —— 这条用真 HTTP 服务端读原始 URI 钉住,而不是靠读代码相信。
- data/repository/MessageRepository.kt:新增 search()(走既有 apiCall/Result 风格,
  倒序返回统一转成升序的 PagedMessages),以及 normalizeSearchQuery(只 trim:
  不转义 % _ \、不改大小写 —— 这两件事服务端已做,两边都做必然错一边)。
  空查询不发请求(契约里空 q = 未传,那会把整段历史当成搜索结果回给调用方)。
- ui/chat/SessionSearchModel.kt(新):全部纯逻辑 —— 命中片段投影(先过 domain/parse
  这一处解析器再拼回纯文本,绝不把标记当富文本;标记里才有的词只显示不高亮)、
  结果合并去重排序(倒序,与聊天页的升序 MessageOrder 刻意相反)、游标推进、
  「这条在不在窗口里」的判定、失败文案。
- ui/chat/SessionSearchViewModel.kt(新):只属于聊天页搜索态的小 VM,带代际号,
  连打两次回车时慢的那个请求回来不会顶掉新关键词的结果。
- ui/chat/SessionSearchPanel.kt(新):全屏搜索面板 —— 自动聚焦输入框(IME 搜索键与
  放大镜都能提交)+ 结果列表(角色 / 时间 / 命中片段,BasicComponent 包 PreferenceCard)
  + 往下翻加载更早的命中 + 未搜/搜索中/空结果/失败 四态分开渲染。
- ui/chat/ChatScreen.kt:只加接线 —— 顶栏搜索入口、面板叠层、返回键先关搜索、
  点结果之后的如实告知。

为什么这么做
- 搜索态独立成小 VM:ChatViewModel 正被另一个并行模块改动,而搜索不改消息窗、不发消息、
  不碰 WS,没有任何理由挤进去。
- 面板叠在聊天页之上而不是替换它:底下的 LazyColumn 必须保持挂载,点一条命中要
  animateScrollToItem 定位,而 LazyColumn 一旦被移出组合,滚动就落空。
- 命中不在「已加载窗口」里时不插一条临时气泡,而是如实提示(比一个假的跳转诚实):
  聊天列表是升序倒过来喂给 reverseLayout 的,凭空插一条几千条之前的消息会出现在列表最底
  ——也就是「刚刚发出的位置」——与它自己的时间戳矛盾,还会带偏跟随底部的判断;
  而把消息真的并进窗口要改 ChatViewModel,本模块不许。
- 失败绝不渲染成「没有找到」:两者都是一张空列表,但一个该换关键词、一个该修网络或权限。
- 返回键先关搜索:新处理器注册在 ChatHomeScreen 那几条之后(优先级更高),
  且搜索没开时是 disabled,抽屉 / 管理页 / 设置页的既有层级不变。

测试 706 -> 737(+31:纯逻辑 25 + 数据层 6)
This commit is contained in:
AskaEth committed 2026-10-04 20:44:03 +08:00
1 parent 76d49b73d1
commit 23b46f9310
8 files changed
+1456 -106

No files matched your search

@@ -68,9 +68,18 @@ interface SessionsApi {
/**
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
*
* 普通分页与**会话内内容搜索**共用这一个端点(契约 §1 行 6:`q` 是 messages 端点上的一个
* 可选查询参数,不是另一个端点)。
*
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
* @param platform 来源平台过滤;null = 全量
* @param query `q`:**会话内内容搜索**(D2)。null / 空 = 未传,走普通分页——线上请求因此与本端
* 支持搜索之前**逐字节相同**(Retrofit 会整个省掉 null 的查询参数)。非空时服务端按
* 大小写不敏感的 ASCII LIKE 匹配 `content`(`%` / `_` / `\` 已转义为字面量),
* 仍按时间倒序、`before` / `limit` 同分页语义,搜索**严格限定在 `{id}` 会话内**。
* **`q` 与 `after` 互斥**(同传 `400`)——本方法本来就没有 `after` 参数,天然不冲突。
*/
@GET("api/v1/sessions/{id}/messages")
suspend fun listMessages(
@@ -78,6 +87,7 @@ interface SessionsApi {
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
@Query("platform") platform: String? = null,
@Query("q") query: String? = null,
): Response<ApiEnvelope<List<HistoryMessageDto>>>
/**
@@ -41,12 +41,57 @@ class MessageRepository(
platform: String? = null,
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
/**
* 会话内**内容搜索**(D2,契约 §1 行 6)。
*
* 与 [loadPage] 走的是**同一个端点、同一套分页语义**,唯一差别是会带上 `q`:
* 服务端按大小写不敏感的 ASCII LIKE 在**本会话内**匹配 `content`(`%` / `_` / `\` 已转义为
* 字面量),仍按 `createdAt` 倒序返回,`before` 仍是「上一页最旧那条的时间戳」。
* 搜索绝不跨会话,所以这里没有 `platform` 参数——那是我方对多端来源的过滤,与搜索正交。
*
* **空查询不发请求**:契约里「空 `q` = 未传」意味着服务端会退化成普通分页,
* 把整段历史当成搜索结果回给调用方——那比不发更像一个谎。所以空/纯空白一律返回一个
* 空页(真实的「没有命中」由调用方在**已提交过查询**的前提下渲染)。
*/
suspend fun search(
sessionId: String,
query: String,
limit: Int = DEFAULT_SEARCH_PAGE_SIZE,
before: Long? = null,
): Result<PagedMessages> = withContext(dispatcher) {
val normalized = normalizeSearchQuery(query)
if (normalized == null) return@withContext Result.success(EMPTY_PAGE)
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
apiCall { api.listMessages(sessionId, pageSize, before, platform = null, query = normalized) }
.map { raw -> toPagedMessages(raw, pageSize) }
}
companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 200
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
const val DEFAULT_SEARCH_PAGE_SIZE = 30
private val EMPTY_PAGE = PagedMessages(
messages = emptyList(),
hasMore = false,
oldestCreatedAt = null,
newestCreatedAt = null,
)
}
}
/**
* 规整搜索关键词:去首尾空白后仍为空 → null(调用方据此**不发请求**,见
* [MessageRepository.search])。
*
* 只做 trim,**不改大小写、不转义 `%` / `_` / `\`**:服务端自己把这三个字符当字面量处理
* (契约 §1 行 6),客户端再转一次会把 `\%` 这种输入搜成别的东西——两边都转义必然有一边是错的。
* 大小写同理:匹配由服务端做 ASCII 不敏感比较,客户端保留用户原样输入。
*/
fun normalizeSearchQuery(raw: String): String? = raw.trim().takeIf { it.isNotEmpty() }
private fun toPagedMessages(
rawNewestFirst: List<HistoryMessageDto>,
pageSize: Int,