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:
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")))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user