merge: W16 — 会话内消息搜索(顶栏入口 + 搜索面板 + 点结果定位)

This commit is contained in:
AskaEth committed 2026-10-04 20:45:09 +08:00
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),
)
}
},
)
}
}
}