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

@@ -0,0 +1,145 @@
package top.yeij.cyrene.data.repository
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SessionsApi
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 会话内搜索的**数据层**:`q` 到底有没有上线上、没传时线上请求是不是一字未变。
*
* 用 JDK 自带的 `com.sun.net.httpserver` 跑真 OkHttp + 真 Retrofit(形状与本仓库既有的
* `ChatRepositoryTest` 一致,不引新依赖),断言落在**收到的原始 URI** 上——这是唯一能证明
* 「不传 `q` 时既有调用逐字不变」的地方:Retrofit 对 null 的 `@Query` 是**整个省掉参数**,
* 而不是发一个空的 `q=`。
*/
class MessageSearchRepositoryTest {
private val backend = FakeMessagesBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = MessageRepository(
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
)
@Test
fun `a plain page never carries q`() = runBlocking {
backend.reply(200, """{"data":[${message("m1", 100)}]}""")
repository().loadPage("web", limit = 50).getOrThrow()
val uri = backend.uris.single()
assertFalse(uri, uri.contains("q="))
assertTrue(uri, uri.contains("limit=50"))
}
@Test
fun `search sends q together with the paging cursor`() = runBlocking {
backend.reply(200, """{"data":[${message("m2", 200)}, ${message("m1", 100)}]}""")
val page = repository().search("web", "下雨", limit = 30, before = 300).getOrThrow()
val uri = backend.uris.single()
assertTrue(uri, uri.contains("q="))
assertTrue(uri, uri.contains("before=300"))
assertTrue(uri, uri.contains("limit=30"))
// 命中仍按服务端的倒序回来,仓库统一转成升序(与普通分页同一口径)。
assertEquals(listOf("m1", "m2"), page.messages.map { it.id })
assertEquals(100L, page.oldestCreatedAt)
}
@Test
fun `the query keeps its original case and is only trimmed`() = runBlocking {
backend.reply(200, """{"data":[]}""")
repository().search("web", " Compose ").getOrThrow()
// 大小写敏感的是**客户端**这里:大小写不敏感是服务端 LIKE 的行为,客户端擅自 lower()
// 只是把用户输入改掉(对 `%` / `_` 的转义同理,两边都转必然错一边)。
val uri = backend.uris.single()
assertTrue(uri, uri.contains("q=Compose"))
}
@Test
fun `a blank query does not hit the network at all`() = runBlocking {
val page = repository().search("web", " ").getOrThrow()
assertTrue(backend.uris.isEmpty())
assertTrue(page.messages.isEmpty())
assertFalse(page.hasMore)
}
@Test
fun `a server error surfaces as a failure instead of an empty result`() = runBlocking {
backend.reply(400, """{"error":{"code":"bad_request","message":"invalid limit"}}""")
val error = repository().search("web", "下雨").exceptionOrNull() as CyreneException
assertEquals("bad_request", (error.error as CyreneError.Server).code)
}
@Test
fun `normalize only trims and treats whitespace as no query`() {
assertEquals("下雨", normalizeSearchQuery(" 下雨 "))
assertEquals("a b", normalizeSearchQuery("a b"))
assertEquals(null, normalizeSearchQuery(""))
assertEquals(null, normalizeSearchQuery(" \n\t "))
// `%` / `_` / `\` 原样保留:服务端自己把它们当字面量处理。
assertEquals("100%_\\", normalizeSearchQuery(" 100%_\\ "))
}
private fun message(id: String, createdAt: Long): String =
"""{"id":"$id","sessionId":"web","role":"user","content":"内容","createdAt":$createdAt}"""
/** 只回一份固定响应、但把每次请求的原始 URI 记下来的假后端。 */
private inner class FakeMessagesBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
val uris = CopyOnWriteArrayList<String>()
@Volatile
private var status: Int = 200
@Volatile
private var body: String = """{"data":[]}"""
fun reply(status: Int, body: String) {
this.status = status
this.body = body
}
private fun handle(exchange: HttpExchange) {
uris += exchange.requestURI.toString()
val bytes = body.toByteArray()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
override fun close() {
server.stop(0)
}
}
}
@@ -0,0 +1,308 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import java.time.ZoneId
/**
* 会话内搜索(D2)的纯逻辑回归钉子:片段投影与高亮区间、结果合并与游标推进、
* 「这条在不在窗口里」的判定、失败文案。
*
* 这些判断错了都不会抛异常(只是屏幕上看着不对),所以必须在这里钉住,而不能指望 UI 测试。
*/
class SessionSearchModelTest {
private fun message(
id: String,
createdAt: Long,
role: String = "user",
content: String = "内容",
) = HistoryMessageDto(
id = id,
sessionId = "web",
role = role,
content = content,
createdAt = createdAt,
)
// ------------------------------------------------------------------ 片段投影
@Test
fun `plain content keeps the exact match offsets`() {
val snippet = searchSnippet("她说今天下午要下雨", "下雨")
assertEquals("她说今天下午要下雨", snippet.text)
assertEquals(7, snippet.matchStart)
assertEquals(9, snippet.matchEnd)
assertTrue(snippet.highlighted)
}
@Test
fun `match is case insensitive like the server side like`() {
val snippet = searchSnippet("用 Compose 写的界面", "compose")
assertEquals(2, snippet.matchStart)
assertEquals(9, snippet.matchEnd)
}
@Test
fun `markup is projected to plain text instead of being rendered`() {
val content = "<action>她凑近了一点</action><p>今天下午要下雨</p>"
val snippet = searchSnippet(content, "下雨")
// 标记没了、旁白与正文都还在,命中落在正文里。
assertEquals("她凑近了一点 今天下午要下雨", snippet.text)
assertEquals(12, snippet.matchStart)
assertEquals(14, snippet.matchEnd)
assertTrue(snippet.highlighted)
}
@Test
fun `a hit inside a narration is still located`() {
val snippet = searchSnippet("<action>她凑近了一点</action><p>正文</p>", "凑近")
assertEquals(1, snippet.matchStart)
assertEquals(3, snippet.matchEnd)
}
@Test
fun `a keyword that only exists in the markup is shown without a fake highlight`() {
// 服务端在**原始内容**上做 LIKE,所以搜 `action` 真的会命中这条(标记里就有这四个字母);
// 但投影后的纯文本里没有它 —— 这时**不许**高亮任何东西去假装命中。
val snippet = searchSnippet("<action>她凑近了一点</action>", "action")
assertFalse(snippet.highlighted)
assertEquals(-1, snippet.matchStart)
assertTrue(snippet.text, snippet.text.contains("凑近"))
}
@Test
fun `whitespace and newlines collapse so the snippet stays one line`() {
val snippet = searchSnippet("<p>第一行\n\n第二行 要下雨</p>", "下雨")
assertEquals("第一行 第二行 要下雨", snippet.text)
assertEquals(9, snippet.matchStart)
}
@Test
fun `a long message is windowed around the hit with ellipsis on both sides`() {
val content = "甲".repeat(200) + "需要找的词" + "乙".repeat(200)
val snippet = searchSnippet(content, "需要找的词")
assertTrue(snippet.text.startsWith("…"))
assertTrue(snippet.text.endsWith("…"))
assertTrue(snippet.highlighted)
// 关键不变量:高亮区间切出来的就是用户搜的那个词。
assertEquals("需要找的词", snippet.text.substring(snippet.matchStart, snippet.matchEnd))
// 窗口本身有上限(多了两个省略号)。
assertEquals(SEARCH_SNIPPET_CHARS + 2, snippet.text.length)
}
@Test
fun `a hit at the very start gets no leading ellipsis`() {
val snippet = searchSnippet("开头就命中" + "乙".repeat(300), "开头")
assertFalse(snippet.text.startsWith("…"))
assertTrue(snippet.text.endsWith("…"))
assertEquals(0, snippet.matchStart)
assertEquals(2, snippet.matchEnd)
}
@Test
fun `a hit at the very end is not cut off by the window`() {
val snippet = searchSnippet("甲".repeat(300) + "结尾命中", "结尾命中")
assertTrue(snippet.text.endsWith("结尾命中"))
assertEquals(snippet.text.length, snippet.matchEnd)
}
@Test
fun `a missing needle shows the head of the message without a highlight`() {
val snippet = searchSnippet("甲".repeat(300), "不存在")
assertFalse(snippet.highlighted)
assertEquals(SEARCH_SNIPPET_CHARS + 1, snippet.text.length)
assertTrue(snippet.text.endsWith("…"))
}
@Test
fun `empty content and blank query are handled without a highlight`() {
assertEquals("", searchSnippet("", "下雨").text)
assertEquals("", searchSnippet("<p></p>", "下雨").text)
assertFalse(searchSnippet("有内容", " ").highlighted)
}
// ------------------------------------------------------------------ 角色与时间
@Test
fun `role labels fall back to the raw value for unknown roles`() {
assertEquals("我", searchRoleLabel("user"))
assertEquals("昔涟", searchRoleLabel("assistant"))
// role 是开放集合:未知取值原样显示,不丢弃也不占位。
assertEquals("system", searchRoleLabel("system"))
}
@Test
fun `epoch millis are rendered as local minute precision time`() {
// 1759644000000 = 2025-10-05 14:00:00 +08:00
assertEquals("10-05 14:00", searchHitTime(1_759_644_000_000L, ZoneId.of("Asia/Shanghai")))
assertEquals("10-05 06:00", searchHitTime(1_759_644_000_000L, ZoneId.of("UTC")))
}
// ------------------------------------------------------------------ 结果合并
@Test
fun `hits are ordered newest first`() {
val merge = mergeSearchPage(
existing = emptyList(),
incoming = listOf(message("a", 100), message("c", 300), message("b", 200)),
pageHasMore = true,
)
assertEquals(listOf("c", "b", "a"), merge.hits.map { it.id })
assertTrue(merge.hasMore)
}
@Test
fun `same millisecond is broken by id descending so the order is stable`() {
val merge = mergeSearchPage(
existing = emptyList(),
incoming = listOf(message("m1", 100), message("m3", 100), message("m2", 100)),
pageHasMore = false,
)
assertEquals(listOf("m3", "m2", "m1"), merge.hits.map { it.id })
}
@Test
fun `duplicate ids are deduplicated and the newer copy wins`() {
val merge = mergeSearchPage(
existing = listOf(message("c", 300), message("b", 200, content = "旧的"), message("a", 100)),
incoming = listOf(message("b", 200, content = "服务端更新的"), message("d", 50)),
pageHasMore = true,
)
assertEquals(listOf("c", "b", "a", "d"), merge.hits.map { it.id })
assertEquals("服务端更新的", merge.hits.first { it.id == "b" }.content)
}
@Test
fun `a page that adds nothing stops the pagination instead of looping forever`() {
val loaded = listOf(message("c", 300), message("b", 200), message("a", 100))
// 服务端说「还有更多」,但这一页全是已经拿到的同毫秒消息 —— 游标不会前进,
// 再翻一次还是同一批,所以必须在这里停住。
val merge = mergeSearchPage(loaded, loaded, pageHasMore = true)
assertFalse(merge.hasMore)
assertEquals(loaded.map { it.id }, merge.hits.map { it.id })
}
@Test
fun `an empty page stops pagination and keeps what we already have`() {
val loaded = listOf(message("c", 300), message("a", 100))
val merge = mergeSearchPage(loaded, emptyList(), pageHasMore = true)
assertFalse(merge.hasMore)
assertEquals(loaded, merge.hits)
}
@Test
fun `the cursor is the oldest hit of the whole result set`() {
assertEquals(
100L,
nextSearchCursor(listOf(message("c", 300), message("b", 200), message("a", 100))),
)
assertEquals(null, nextSearchCursor(emptyList()))
}
// ------------------------------------------------------------------ 「在不在窗口里」
@Test
fun `a loaded hit maps to the reverse layout index`() {
val loaded = listOf(message("m1", 100), message("m2", 200), message("m3", 300))
// items = [m1, m2, m3, 待发, 流式] → asReversed() = [流式, 待发, m3, m2, m1],
// 所以升序下标 1(m2)落在下拉列表的第 3 项。
val jump = planSearchJump(message("m2", 200), loaded, displayItemCount = 5)
assertEquals(SearchJump.ScrollTo(lazyIndex = 3, messageId = "m2"), jump)
}
@Test
fun `an older hit is reported as not loaded with the pull up hint`() {
val loaded = listOf(message("m3", 300), message("m4", 400))
val jump = planSearchJump(message("m1", 100), loaded, displayItemCount = 2)
val notice = (jump as SearchJump.NotLoaded).notice
assertTrue(notice, notice.contains("上滑加载更早的消息"))
}
@Test
fun `a newer hit is reported as not loaded as well`() {
val loaded = listOf(message("m1", 100))
val jump = planSearchJump(message("m9", 900), loaded, displayItemCount = 1)
val notice = (jump as SearchJump.NotLoaded).notice
assertTrue(notice, notice.contains("还新"))
}
@Test
fun `a hit inside the time range but absent locally is not silently treated as a jump`() {
val loaded = listOf(message("m1", 100), message("m3", 300))
val jump = planSearchJump(message("m2", 200), loaded, displayItemCount = 2)
val notice = (jump as SearchJump.NotLoaded).notice
assertTrue(notice, notice.contains("不在当前已加载的窗口里"))
}
@Test
fun `an empty window reports that nothing is loaded yet`() {
val jump = planSearchJump(message("m1", 100), emptyList(), displayItemCount = 0)
assertTrue(jump is SearchJump.NotLoaded)
}
// ------------------------------------------------------------------ 失败文案
@Test
fun `failures are spelled out instead of being reported as no results`() {
assertEquals(
"登录状态已失效,请重新登录",
searchFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", ""))),
)
assertEquals(
"当前账号没有权限搜索这个会话",
searchFailureMessage(
CyreneException(CyreneError.Server("forbidden", "forbidden", 403)),
),
)
assertEquals(
"服务端还没有开启消息存储,搜索暂时不可用",
searchFailureMessage(
CyreneException(CyreneError.Server("not_implemented", "", 501)),
),
)
// 服务端有话说就照它说的显示,不自己编。
assertEquals(
"搜索失败:invalid limit",
searchFailureMessage(CyreneException(CyreneError.Server("bad_request", "invalid limit", 400))),
)
assertEquals(
"搜索失败:连接超时,服务器没有响应",
searchFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
)
assertEquals("搜索失败,请稍后重试", searchFailureMessage(RuntimeException("boom")))
}
}