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,
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -51,6 +52,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil3.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -82,6 +84,7 @@ import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.File
import top.yukonga.miuix.kmp.icon.extended.Image
@@ -93,6 +96,9 @@ private const val BOTTOM_TOLERANCE = 64
/** 分享落地前等 WS 连接就绪的上限(毫秒):超时也照样发,让失败落在看得见的地方。 */
private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
private const val SEARCH_NOTICE_MILLIS = 8_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
@@ -135,6 +141,41 @@ fun ChatScreen(
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
// —— 会话内搜索(D2)——
// 搜索态活在**聊天页内部**、自带一个小 VM:它不改消息窗、不发消息、不碰 WS,与另一个并行
// 模块正在改动的 ChatViewModel 毫无关系,所以刻意不并进那个 VM。
var searchOpen by remember { mutableStateOf(false) }
// 点一条结果之后的如实告知(已定位 / 这条不在已加载的窗口里)。面板会关掉,所以这句话挂在页面上。
var searchNotice by remember { mutableStateOf<String?>(null) }
// 待执行的定位请求。命中在窗口里时先关面板再滚,交给这个 effect 在组合完成之后做。
var pendingScroll by remember { mutableStateOf<Int?>(null) }
val searchViewModel: SessionSearchViewModel = viewModel(
key = "session-search:$sessionId",
factory = viewModelFactory {
initializer { SessionSearchViewModel(container, sessionId) }
},
)
val searchState by searchViewModel.state.collectAsState()
// 返回键**先关搜索**:这条处理器在 ChatHomeScreen 那几条之后注册,优先级因此更高;
// 搜索没开时它是 disabled,抽屉 / 管理页 / 设置页的既有返回层级一个字节都不变。
BackHandler(enabled = searchOpen) { searchOpen = false }
LaunchedEffect(pendingScroll) {
val index = pendingScroll ?: return@LaunchedEffect
// 搜索期间底下的消息列表一直挂着(面板是叠上去的一层),所以这里恒能滚得动。
listState.animateScrollToItem(index)
pendingScroll = null
}
// 那句告知自己消失:它描述的是一次刚发生的动作,不该永久占着屏幕上的一行。
LaunchedEffect(searchNotice) {
if (searchNotice != null) {
delay(SEARCH_NOTICE_MILLIS)
searchNotice = null
}
}
// —— 附件预览 ——
// 取字节的缓存层:服务端文档按存储名先看缓存、待发附件先读本地 URI,两条来源汇成同一份
// 「缓存里的文件 + 字节」。`openLocal` 把 ContentResolver 收在这里,类本身可单测。
@@ -314,122 +355,166 @@ fun ChatScreen(
.collect { nearTop -> if (nearTop) viewModel.loadOlder() }
}
Scaffold(
topBar = {
SmallTopAppBar(
title = "与昔涟",
subtitle = connectionLabel(state),
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单")
}
},
)
},
bottomBar = {
InputBar(
value = state.draft,
imageUri = state.draftImage,
documents = state.draftDocuments,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearImage = viewModel::clearDraftImage,
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
onRemoveDocument = viewModel::removeDraftDocument,
onPreviewDocument = { index ->
state.draftDocuments.getOrNull(index)?.let { draft ->
previewNotice = null
previewTarget = DocumentTarget.of(draft)
}
},
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.items.isEmpty() -> CenterHint("还没有消息")
else -> LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = state.items.asReversed(), key = { it.key }) { item ->
when (item) {
is ChatDisplayItem.Committed -> MessageBubble(
message = item.message,
onDocumentClick = { ref ->
previewNotice = null
previewTarget = DocumentTarget.of(ref)
},
// 来源判定在 domain 里(ImageSource.of),这里只把弹层打开——
// 「点一张图」这件事不该在接线层判断它是什么地址。
onImageClick = { raw ->
ImageSource.of(raw)?.let { imageTarget = it }
},
)
is ChatDisplayItem.Pending -> PendingBubble(
item = item.item,
onDocumentClick = { draft ->
previewNotice = null
previewTarget = DocumentTarget.of(draft)
},
onImageClick = { raw ->
ImageSource.of(raw)?.let { imageTarget = it }
},
)
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
SmallTopAppBar(
title = "与昔涟",
subtitle = connectionLabel(state),
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单")
}
}
if (state.hasMore) {
item(key = "__load_older__") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (state.loadingOlder) "加载更早的消息…" else "上滑加载更早的消息",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
},
actions = {
// 搜索态的入口。图标用 Miuix 的 Basic.Search(基础图标集里就这一个放大镜)。
IconButton(onClick = { searchOpen = true }) {
Icon(
imageVector = MiuixIcons.Basic.Search,
contentDescription = "搜索会话里的消息",
)
}
},
)
},
bottomBar = {
InputBar(
value = state.draft,
imageUri = state.draftImage,
documents = state.draftDocuments,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearImage = viewModel::clearDraftImage,
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
onRemoveDocument = viewModel::removeDraftDocument,
onPreviewDocument = { index ->
state.draftDocuments.getOrNull(index)?.let { draft ->
previewNotice = null
previewTarget = DocumentTarget.of(draft)
}
},
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.items.isEmpty() -> CenterHint("还没有消息")
else -> LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = state.items.asReversed(), key = { it.key }) { item ->
when (item) {
is ChatDisplayItem.Committed -> MessageBubble(
message = item.message,
onDocumentClick = { ref ->
previewNotice = null
previewTarget = DocumentTarget.of(ref)
},
// 来源判定在 domain 里(ImageSource.of),这里只把弹层打开——
// 「点一张图」这件事不该在接线层判断它是什么地址。
onImageClick = { raw ->
ImageSource.of(raw)?.let { imageTarget = it }
},
)
is ChatDisplayItem.Pending -> PendingBubble(
item = item.item,
onDocumentClick = { draft ->
previewNotice = null
previewTarget = DocumentTarget.of(draft)
},
onImageClick = { raw ->
ImageSource.of(raw)?.let { imageTarget = it }
},
)
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
}
}
if (state.hasMore) {
item(key = "__load_older__") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (state.loadingOlder) "加载更早的消息…" else "上滑加载更早的消息",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
}
}
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth(),
) {
state.error?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
}
state.shareNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
state.attachmentNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth(),
) {
state.error?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
}
state.shareNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
state.attachmentNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
searchNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
}
}
}
// 搜索面板叠在聊天页**之上**(不替换它):底下的消息列表必须保持挂载,
// 点一条命中要 animateScrollToItem 定位,而 LazyColumn 一旦被移出组合,滚动就落空。
if (searchOpen) {
SessionSearchPanel(
state = searchState,
onQueryChange = searchViewModel::onQueryChange,
onSubmit = searchViewModel::submit,
onLoadMore = { searchViewModel.loadMore() },
onRetryMore = { searchViewModel.loadMore(force = true) },
onHitClick = { hit ->
// 能不能定位是**纯函数**的判定(planSearchJump),接线层不许自己猜。
val jump = planSearchJump(hit, state.messages, state.items.size)
when (jump) {
is SearchJump.ScrollTo -> {
searchOpen = false
pendingScroll = jump.lazyIndex
searchNotice = "已定位到这条消息"
}
is SearchJump.NotLoaded -> {
searchOpen = false
searchNotice = jump.notice
}
}
},
onClose = { searchOpen = false },
)
}
}
previewTarget?.let { target ->
@@ -0,0 +1,236 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.parse
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* 会话内搜索(D2)的**纯逻辑**:片段投影 / 结果合并 / 游标推进 / 「这条在不在窗口里」的判定。
*
* 为什么单独一层:这些判断全是「输入 → 输出」的确定性映射,且**错了也不会有异常**
* (高亮错位、翻页卡住、谎报「已跳过去」都只是屏幕上看着不对),所以它们必须能被单测钉住,
* 而不是藏在 Compose 的 `remember` 与协程里。UI 只做取值与渲染。
*/
/** 命中片段一次最多显示多少个字符(含省略号):手机上一条结果两三行就够,再长没人读。 */
const val SEARCH_SNIPPET_CHARS = 90
/** 省略号:片段被窗口裁过时标记「两头还有内容」。 */
private const val ELLIPSIS = "…"
/** 任意连续空白(含换行)→ 单空格:结果列表是一行行的文本,不能带原始换行。 */
private val WhitespaceRun = Regex("\\s+")
/**
* 一条命中消息的摘要片段:**纯文本** + 命中区间。
*
* [matchStart] / [matchEnd] 为 -1 表示「在这段纯文本里定位不到这次命中」——**不是**错误,
* 而是如实承认:服务端是在**原始 `content`** 上做 LIKE 的,而这里显示的是**剥掉标记之后**的
* 文本,用户搜的词完全可能只出现在标记里(例如 `action` 这个词)。这种情况照旧显示片段,
* 只是不画高亮,绝不把标记本身当命中去高亮一段用户没搜过的东西。
*/
data class SearchSnippet(
val text: String,
val matchStart: Int = -1,
val matchEnd: Int = -1,
) {
/** 是否有一段可以放心画高亮的区间(越界一律当作没有,避免 `AnnotatedString` 抛异常)。 */
val highlighted: Boolean
get() = matchStart >= 0 && matchEnd > matchStart && matchEnd <= text.length
}
/**
* 消息正文 → 摘要片段(带命中区间)。
*
* 正文必须先过 [parse](项目里唯一的 Web 端标记解析器)再拼回纯文本:服务端存的是
* `<p>` / `<action>` / `<narration>` / `*…*` 这套标记,直接渲染出来就是一堆尖括号。
* 「拼成纯文本再上色」与「把标记当富文本渲染」是两件事——后者本端从来没做过(见 `05` 的
* 「别假设文本字段就是纯文本」)。片段里保留**全部**片段文本(正文/旁白/动作一视同仁),
* 因为命中可能落在旁白里,只投影正文会把那次命中藏起来。
*/
fun searchSnippet(
content: String,
query: String,
maxChars: Int = SEARCH_SNIPPET_CHARS,
): SearchSnippet {
val plain = plainSearchText(content)
if (plain.isEmpty()) return SearchSnippet("")
val needle = query.trim()
val match = if (needle.isEmpty()) -1 else plain.indexOf(needle, startIndex = 0, ignoreCase = true)
if (match < 0) {
val tail = if (plain.length > maxChars) ELLIPSIS else ""
return SearchSnippet(plain.take(maxChars) + tail)
}
return window(plain, match, match + needle.length, maxChars)
}
/** 标记 → 纯文本:按渲染顺序取片段文本,再把空白折成单空格。 */
internal fun plainSearchText(raw: String): String =
parse(raw)
.joinToString(" ") { bubble -> bubble.parts.joinToString(" ") { it.text } }
.replace(WhitespaceRun, " ")
.trim()
/** 以命中为中心开一个窗:命中前后都留一点上下文,窗口两端有裁剪就补省略号(下标随之平移)。 */
private fun window(plain: String, matchStart: Int, matchEnd: Int, maxChars: Int): SearchSnippet {
if (plain.length <= maxChars) return SearchSnippet(plain, matchStart, matchEnd)
// 命中前留 1/4 的窗口做上下文:命中点偏左比偏右好读,但也别把命中顶到第一列。
val lead = maxChars / 4
val start = (matchStart - lead).coerceIn(0, (plain.length - maxChars).coerceAtLeast(0))
val end = (start + maxChars).coerceAtMost(plain.length)
val prefix = if (start > 0) ELLIPSIS else ""
val suffix = if (end < plain.length) ELLIPSIS else ""
val text = prefix + plain.substring(start, end) + suffix
return SearchSnippet(
text = text,
matchStart = (matchStart - start + prefix.length).coerceIn(0, text.length),
matchEnd = (matchEnd - start + prefix.length).coerceIn(0, text.length),
)
}
/**
* 结果条目上的角色标签。
*
* `role` 在后端是**开放集合**(`05` 的「易忘的实现约定」):`user` / `assistant` 之外的取值
* **原样显示**,不丢弃、不占位——将来多一个 `system` 也不会被显示成空白。
*/
fun searchRoleLabel(role: String): String = when (role) {
"user" -> "我"
"assistant" -> "昔涟"
else -> role
}
/**
* 结果条目上的时间:Unix 毫秒(服务端时间)→ 本地 `MM-dd HH:mm`。
*
* 刻意**不复用** `domain/admin` 的 `formatEpochMillis`:那是给「一行放得下一整串」的详情页用的
* `yyyy-MM-dd HH:mm:ss`,而搜索结果一行里还要放角色与命中片段,秒级时间会把左列挤没。
* 已知取舍:跨年时看不出年份——搜索是「在这个会话里找刚说过的话」,这个精度够用。
* `zone` 可注入,因此这条映射是纯函数(单测钉住时区)。
*/
fun searchHitTime(millis: Long, zone: ZoneId = ZoneId.systemDefault()): String =
Instant.ofEpochMilli(millis).atZone(zone).format(SearchHitTimeFormat)
private val SearchHitTimeFormat = DateTimeFormatter.ofPattern("MM-dd HH:mm")
/**
* 搜索结果排序:`createdAt` **倒序**(新 → 旧),同毫秒用 `id` 倒序兜底。
*
* 与聊天页的 [top.yeij.cyrene.data.repository.MessageOrder] 方向**刻意相反**:搜索结果是
* 「按时间倒序的一条条命中」(契约 §1 行 6),翻页也是往更早翻,UI 因此可以直接顺序渲染。
*/
internal val SearchHitOrder: Comparator<HistoryMessageDto> =
compareByDescending<HistoryMessageDto> { it.createdAt }.thenByDescending { it.id }
/** 一页搜索结果并进已有结果后的结果集与「还能不能往下翻」。 */
data class SearchPageMerge(
val hits: List<HistoryMessageDto>,
val hasMore: Boolean,
)
/**
* 合并一页搜索结果:按 `id` 去重(重复到达的以**新到的**为准)、按 [SearchHitOrder] 重排。
*
* [hasMore] 是 `pageHasMore && 真的变多了`——这一条不是洁癖,而是**防死循环**:`before` 是
* 「上一页最旧那条的时间戳」,若服务端把同毫秒的一批消息又回了一遍,游标不会前进,
* 于是「还能翻 → 再请求 → 又全是重复」会永远转下去。只有结果集真的长大才允许继续翻。
*/
fun mergeSearchPage(
existing: List<HistoryMessageDto>,
incoming: List<HistoryMessageDto>,
pageHasMore: Boolean,
): SearchPageMerge {
if (incoming.isEmpty()) return SearchPageMerge(existing, false)
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
existing.forEach { byId[it.id] = it }
incoming.forEach { byId[it.id] = it }
val merged = byId.values.sortedWith(SearchHitOrder)
return SearchPageMerge(merged, pageHasMore && merged.size > existing.size)
}
/**
* 下一页的 `before` 游标 = **已加载结果里最旧那条**的 `createdAt`。
*
* 取整个结果集的极小值(而不是「这一页的最小值」):翻页轮次变多时,两页之间若出现乱序
* (服务端同毫秒并列、或某条被别的端删了),极小值仍然单调不增,不会跳回去重拉一段。
*/
fun nextSearchCursor(hits: List<HistoryMessageDto>): Long? = hits.minOfOrNull { it.createdAt }
/** 点一条搜索结果之后要做的事。 */
sealed interface SearchJump {
/**
* 命中就在**当前已加载的窗口**里:[lazyIndex] 是聊天列表(`reverseLayout`)里的项下标,
* [messageId] 用于把「滚的是哪一条」说清楚。
*/
data class ScrollTo(val lazyIndex: Int, val messageId: String) : SearchJump
/**
* 命中**不在**窗口里。这里刻意不给「滚过去」的假动作,[notice] 是给用户看的一句人话。
*/
data class NotLoaded(val notice: String) : SearchJump
}
/**
* 判定一条命中能不能定位,并给出定位下标。
*
* ## 为什么不在窗口里时**不**把它插进来显示
* 「插一条临时消息」看似更讨好,代价却是真的:聊天列表是
* `ChatUiState.items`(已落库 + 待发 + 流式中)按 `createdAt` 升序倒过来喂给
* `reverseLayout` 的,凭空插一条几千条之前的历史消息进去,它会出现在**列表最底**、
* 也就是「刚刚发出的位置」,与它自己的时间戳矛盾;跟随底部的逻辑(`followBottom`)也会被
* 这条永远不该在底部的东西带偏。而本模块**不许改 `ChatViewModel`**(它归另一个并行模块),
* 因此没有「把这条真的并进消息窗」的合法路径。
* ⇒ 如实说一句「这条不在当前已加载的窗口里」并告诉用户下一步(上滑加载更早的消息),
* 比一个假的跳转诚实得多,也正是本任务书要求的那条底线。
*
* @param loadedAscending 聊天页**已加载**的落库消息(升序,即 `ChatUiState.messages`)
* @param displayItemCount `ChatUiState.items` 的条数:待发与流式气泡排在已落库消息之后,
* 所以「下拉列表下标 = displayItemCount - 1 - 升序下标」对已落库消息恒成立
*/
fun planSearchJump(
hit: HistoryMessageDto,
loadedAscending: List<HistoryMessageDto>,
displayItemCount: Int,
): SearchJump {
val index = loadedAscending.indexOfFirst { it.id == hit.id }
if (index >= 0) {
val lazyIndex = (displayItemCount - 1 - index).coerceAtLeast(0)
return SearchJump.ScrollTo(lazyIndex = lazyIndex, messageId = hit.id)
}
val oldest = loadedAscending.firstOrNull()?.createdAt
val newest = loadedAscending.lastOrNull()?.createdAt
val notice = when {
loadedAscending.isEmpty() -> "这条消息还没加载到本地,先等会话加载完再来"
oldest != null && hit.createdAt < oldest -> "这条比已加载的最早一条还早,先上滑加载更早的消息再来"
newest != null && hit.createdAt > newest -> "这条比已加载的最新一条还新,等它出现在会话里再来"
else -> "这条不在当前已加载的窗口里(可能已在别处删除),没有跳过去"
}
return SearchJump.NotLoaded(notice)
}
/**
* 搜索失败 → 一句人话。
*
* 与「没有找到」**必须分开**:这两种在界面上长得像(都是「列表是空的」),但用户该做的事完全
* 不同——一个是换关键词,一个是修网络/权限。所以失败一律走这里,绝不落进空结果分支。
*/
fun searchFailureMessage(throwable: Throwable): String {
val error = (throwable as? CyreneException)?.error
return when {
error is CyreneError.Unauthorized -> "登录状态已失效,请重新登录"
error is CyreneError.Server && error.httpStatus == 403 -> "当前账号没有权限搜索这个会话"
error is CyreneError.Server && error.code == "not_implemented" ->
"服务端还没有开启消息存储,搜索暂时不可用"
error != null -> "搜索失败:${error.userMessage}"
else -> "搜索失败,请稍后重试"
}
}
@@ -0,0 +1,355 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.distinctUntilChanged
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 结果列表末尾还剩几项时就开始拉下一页(与聊天页的「接近末尾就加载更早」同一手感)。 */
private const val LOAD_MORE_THRESHOLD = 3
/**
* 会话内搜索面板:**盖住整个聊天页**(含顶栏与输入栏)的一层。
*
* 为什么不换掉聊天页的 Scaffold 而是叠一层:搜索期间底下的消息列表必须**保持挂载**——
* 用户点一条命中要 `animateScrollToItem` 定位,而 `LazyListState` 一旦对应的 LazyColumn 被
* 移出组合就不再 attach,滚动会落空。叠一层则底下的列表一直在,定位随时可用。
*
* 自己的顶栏只做一件事:关闭(返回键走 ChatScreen 里那条 [androidx.activity.compose.BackHandler],
* 两条路径都只关搜索,不动抽屉/设置/管理的层级)。
*/
@Composable
fun SessionSearchPanel(
state: SessionSearchUiState,
onQueryChange: (String) -> Unit,
onSubmit: () -> Unit,
onLoadMore: () -> Unit,
onRetryMore: () -> Unit,
onHitClick: (HistoryMessageDto) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
// 不透明底色:面板是叠在聊天页上的,漏底色就会看到两层文字叠在一起。
Box(modifier = modifier.fillMaxSize().background(MiuixTheme.colorScheme.surface)) {
Scaffold(
topBar = {
SmallTopAppBar(
title = "搜索会话",
// subtitle 是非空 String(与聊天页顶栏同一口径:没有副标题就传空串)。
subtitle = if (state.hits.isEmpty()) "" else "${state.hits.size} 条命中",
navigationIcon = {
IconButton(onClick = onClose) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = "关闭搜索")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
SearchField(
value = state.query,
onChange = onQueryChange,
onSubmit = onSubmit,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
Box(modifier = Modifier.fillMaxSize()) {
when {
state.searching -> SearchHint("搜索中…")
// 失败与「没找到」在这里分开:两者都是空列表,但用户要做的事完全不同。
state.failedWithoutResults -> SearchFailure(state.failure.orEmpty(), onSubmit)
state.emptyResult -> SearchHint("没有找到包含「${state.submitted}」的消息")
state.hits.isEmpty() -> SearchHint("输入关键词,搜索这个会话里的消息")
else -> SearchResultList(
state = state,
onLoadMore = onLoadMore,
onRetryMore = onRetryMore,
onHitClick = onHitClick,
)
}
}
}
}
}
}
/**
* 关键词输入框:**自动聚焦**(进搜索态就能直接打字是这类界面唯一的意义),
* IME 的搜索键与右侧的放大镜**都能提交**——模拟器/外接键盘上按 IME 搜索键不一定方便,
* 留一个能点的入口才不至于「打了字没反应」。
*/
@Composable
private fun SearchField(
value: String,
onChange: (String) -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
// 等第一帧:刚 composition 出来时输入框还没 attach,直接 requestFocus 会抛 IllegalState。
withFrameNanos { }
runCatching { focusRequester.requestFocus() }
keyboard?.show()
}
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
TextField(
value = value,
onValueChange = onChange,
label = "搜索这个会话里的消息",
useLabelAsPlaceholder = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
modifier = Modifier.weight(1f).focusRequester(focusRequester),
)
Spacer(Modifier.width(4.dp))
IconButton(onClick = onSubmit) {
Icon(imageVector = MiuixIcons.Basic.Search, contentDescription = "开始搜索")
}
}
}
@Composable
private fun SearchResultList(
state: SessionSearchUiState,
onLoadMore: () -> Unit,
onRetryMore: () -> Unit,
onHitClick: (HistoryMessageDto) -> Unit,
) {
val listState = rememberLazyListState()
LaunchedEffect(listState) {
snapshotFlow {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - LOAD_MORE_THRESHOLD
}
.distinctUntilChanged()
.collect { nearEnd -> if (nearEnd) onLoadMore() }
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = state.hits, key = { it.id }) { hit ->
SearchResultRow(
hit = hit,
query = state.submitted,
onClick = { onHitClick(hit) },
)
}
item(key = "__search_more__") {
SearchFooter(state = state, onLoadMore = onLoadMore, onRetryMore = onRetryMore)
}
}
}
/**
* 列表尾部:翻页进度 / 翻页失败(带重试)/ 「还有更早的命中」。
*
* 翻页失败**不清空**已经搜到的结果,只在尾部说明——前几页是可信的,丢掉它们等于惩罚用户,
* 而「重试」按钮用的是同一份游标,不会重复拉已经拿到的那些。
*/
@Composable
private fun SearchFooter(
state: SessionSearchUiState,
onLoadMore: () -> Unit,
onRetryMore: () -> Unit,
) {
val hint = when {
state.loadingMore -> "加载更早的命中…"
state.failure != null -> state.failure
state.hasMore -> "上滑加载更早的命中"
else -> null
}
if (hint == null) return
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = hint,
style = MiuixTheme.textStyles.footnote2,
color = if (state.failure != null) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
if (state.failure != null && !state.loadingMore) {
TextButton(text = "重试", onClick = onRetryMore)
}
}
}
/**
* 一条命中:角色 + 命中片段(高亮)+ 时间。
*
* 片段是**纯文本**投影([searchSnippet],先过 `domain/parse` 的解析器),高亮只是在这一段纯文本
* 上加一个 `SpanStyle` 底色——本项目**从不**把 `<p>` / `<action>` 当富文本渲染,
* 这里也不开这个口子。
*
* 列表项用 `BasicComponent` 作底座(Miuix 的列表项约定),它自己没有背景与圆角,
* 因此必须包一层 [PreferenceCard]。
*/
@Composable
private fun SearchResultRow(
hit: HistoryMessageDto,
query: String,
onClick: () -> Unit,
) {
val scheme = MiuixTheme.colorScheme
val snippet = remember(hit.id, hit.content, query) { searchSnippet(hit.content, query) }
// 恒定是 AnnotatedString:Miuix 的 Text 只有 String 与 AnnotatedString 两个重载,
// 让分支类型收敛成 CharSequence 会两个都匹配不上。
val highlighted = remember(snippet, scheme.primaryContainer, scheme.onPrimaryContainer) {
buildAnnotatedString {
if (!snippet.highlighted) {
append(snippet.text)
} else {
append(snippet.text.substring(0, snippet.matchStart))
withStyle(
SpanStyle(
background = scheme.primaryContainer,
color = scheme.onPrimaryContainer,
),
) {
append(snippet.text.substring(snippet.matchStart, snippet.matchEnd))
}
append(snippet.text.substring(snippet.matchEnd))
}
}
}
PreferenceCard {
BasicComponent(
onClick = onClick,
endActions = {
Text(
text = searchHitTime(hit.createdAt),
style = MiuixTheme.textStyles.footnote2,
color = scheme.onSurfaceVariantSummary,
maxLines = 1,
)
},
) {
Column {
Text(
text = searchRoleLabel(hit.role),
style = MiuixTheme.textStyles.footnote2,
color = scheme.primary,
maxLines = 1,
)
// 纯图片 / 纯附件的回合没有正文,如实说明而不是显示一行空白。
if (snippet.text.isEmpty()) {
Text(
text = "(这条消息没有文字内容)",
style = MiuixTheme.textStyles.footnote1,
color = scheme.onSurfaceVariantSummary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
text = highlighted,
style = MiuixTheme.textStyles.footnote1,
color = scheme.onSurfaceContainer,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
/** 首批失败:整页显示原因 + 重试(**不**渲染成「没找到」)。 */
@Composable
private fun SearchFailure(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = message.ifBlank { "搜索失败,请稍后重试" },
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
)
Spacer(Modifier.height(12.dp))
TextButton(text = "重试", onClick = onRetry)
}
}
/** 居中一句提示(初始态 / 搜索中 / 没找到)。 */
@Composable
private fun SearchHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = 24.dp),
)
}
}
@@ -0,0 +1,166 @@
package top.yeij.cyrene.ui.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.normalizeSearchQuery
/**
* 会话内搜索(D2)的状态。
*
* @param query 输入框里的原文
* @param submitted **已经发给服务端**的那个关键词。与 [query] 分开是必须的:结果属于
* `submitted`,而用户可能正在打下一个词——空结果的文案要说的是「没有找到包含 X 的消息」,
* 那个 X 只能是 `submitted`,否则会声称「没找到 abc」而其实搜的是 ab。
* @param started 是否**至少提交过一次**。初始态与「搜过但没命中」都是空列表,
* 只有它能区分「还没搜」与「没找到」([emptyResult])。
* @param failure 最近一次失败的人话。[hits] 为空时整页显示它,非空时挂在列表尾部——
* 两种都不是「没找到」。
*/
data class SessionSearchUiState(
val query: String = "",
val submitted: String = "",
val started: Boolean = false,
val searching: Boolean = false,
val loadingMore: Boolean = false,
val hits: List<HistoryMessageDto> = emptyList(),
val hasMore: Boolean = false,
val nextCursor: Long? = null,
val failure: String? = null,
) {
/** 真的搜过、请求成功、结果为空 —— 这才是「没有找到」。 */
val emptyResult: Boolean
get() = started && failure == null && !searching && hits.isEmpty()
/** 首批就失败(列表里什么都没有):整页的失败态。翻页失败走 [failure] + [hits] 非空那条路。 */
val failedWithoutResults: Boolean
get() = failure != null && hits.isEmpty()
}
/**
* 会话内搜索的小 ViewModel:**只属于聊天页的搜索态**,与 [ChatViewModel] 完全不相干——
* 搜索不改变消息窗、不碰 WS、不发消息,所以它不该住进那个正在被另一个模块改动的 VM 里。
*
* 作用域由调用方决定(`viewModel(key = "session-search:<id>")`),因此每个会话各有一份,
* 切回来时上一次的查询与结果还在。取数一律走既有的 `MessageRepository`。
*
* 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方,
* 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。
* 每次 [submit] 都换代并取消上一个 job,旧代的结果回来直接丢。
*/
class SessionSearchViewModel(
private val container: AppContainer,
private val sessionId: String,
) : ViewModel() {
private val _state = MutableStateFlow(SessionSearchUiState())
val state: StateFlow<SessionSearchUiState> = _state.asStateFlow()
private var job: Job? = null
private var generation = 0
/** 输入框变化:**不发请求**。让用户把词打完,提交由 IME 的搜索键或搜索按钮触发。 */
fun onQueryChange(value: String) {
_state.update { it.copy(query = value) }
}
/** 提交当前关键词:从第一页(最新的命中)开始搜。空白关键词按「不发请求」处理。 */
fun submit() {
val normalized = normalizeSearchQuery(_state.value.query) ?: return
val generation = ++this.generation
job?.cancel()
_state.update {
it.copy(
submitted = normalized,
started = true,
searching = true,
loadingMore = false,
hits = emptyList(),
hasMore = false,
nextCursor = null,
failure = null,
)
}
job = viewModelScope.launch {
val result = container.messageRepository.search(sessionId, normalized)
if (generation != this@SessionSearchViewModel.generation) return@launch
result.fold(
onSuccess = { page ->
val merge = mergeSearchPage(emptyList(), page.messages, page.hasMore)
_state.update {
it.copy(
searching = false,
hits = merge.hits,
hasMore = merge.hasMore,
nextCursor = nextSearchCursor(merge.hits),
)
}
},
onFailure = { throwable ->
_state.update {
it.copy(
searching = false,
hits = emptyList(),
hasMore = false,
nextCursor = null,
failure = searchFailureMessage(throwable),
)
}
},
)
}
}
/**
* 往下翻:加载**更早**的命中(`before` + `limit`)。自动触发(滚到列表末尾)与手动重试共用。
*
* @param force 手动重试时为 true:翻页失败后 [SessionSearchUiState.hasMore] 已被置 false
* (那正是「不许自动重试」的实现),只有用户明确点重试才允许再打一次。
*/
fun loadMore(force: Boolean = false) {
val current = _state.value
if (current.searching || current.loadingMore) return
if (!current.hasMore && !force) return
val cursor = current.nextCursor ?: return
val generation = this.generation
_state.update { it.copy(loadingMore = true, failure = null) }
job = viewModelScope.launch {
val result = container.messageRepository.search(
sessionId = sessionId,
query = current.submitted,
before = cursor,
)
if (generation != this@SessionSearchViewModel.generation) return@launch
result.fold(
onSuccess = { page ->
val merge = mergeSearchPage(current.hits, page.messages, page.hasMore)
_state.update {
it.copy(
loadingMore = false,
hits = merge.hits,
hasMore = merge.hasMore,
nextCursor = nextSearchCursor(merge.hits),
)
}
},
onFailure = { throwable ->
// 失败**不动**已有结果:搜到的东西是可信的,只是更早的那一页没取回来。
_state.update {
it.copy(
loadingMore = false,
hasMore = false,
failure = searchFailureMessage(throwable),
)
}
},
)
}
}
}
@@ -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")))
}
}