merge: W16 — 会话内消息搜索(顶栏入口 + 搜索面板 + 点结果定位)
This commit is contained in:
commit
46ac15a538
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")))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user