diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt index e68617a..5ccae3a 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt @@ -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>> /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt index 30fa48f..8879f85 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt @@ -41,12 +41,57 @@ class MessageRepository( platform: String? = null, ): Result = 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 = 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, pageSize: Int, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 1b03fd0..ecf36bf 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -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(null) } + // 待执行的定位请求。命中在窗口里时先关面板再滚,交给这个 effect 在组合完成之后做。 + var pendingScroll by remember { mutableStateOf(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 -> diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchModel.kt new file mode 100644 index 0000000..c1de70c --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchModel.kt @@ -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 端标记解析器)再拼回纯文本:服务端存的是 + * `

` / `` / `` / `*…*` 这套标记,直接渲染出来就是一堆尖括号。 + * 「拼成纯文本再上色」与「把标记当富文本渲染」是两件事——后者本端从来没做过(见 `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 = + compareByDescending { it.createdAt }.thenByDescending { it.id } + +/** 一页搜索结果并进已有结果后的结果集与「还能不能往下翻」。 */ +data class SearchPageMerge( + val hits: List, + val hasMore: Boolean, +) + +/** + * 合并一页搜索结果:按 `id` 去重(重复到达的以**新到的**为准)、按 [SearchHitOrder] 重排。 + * + * [hasMore] 是 `pageHasMore && 真的变多了`——这一条不是洁癖,而是**防死循环**:`before` 是 + * 「上一页最旧那条的时间戳」,若服务端把同毫秒的一批消息又回了一遍,游标不会前进, + * 于是「还能翻 → 再请求 → 又全是重复」会永远转下去。只有结果集真的长大才允许继续翻。 + */ +fun mergeSearchPage( + existing: List, + incoming: List, + pageHasMore: Boolean, +): SearchPageMerge { + if (incoming.isEmpty()) return SearchPageMerge(existing, false) + + val byId = LinkedHashMap(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): 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, + 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 -> "搜索失败,请稍后重试" + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchPanel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchPanel.kt new file mode 100644 index 0000000..92696e1 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchPanel.kt @@ -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` 底色——本项目**从不**把 `

` / `` 当富文本渲染, + * 这里也不开这个口子。 + * + * 列表项用 `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), + ) + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchViewModel.kt new file mode 100644 index 0000000..54a050c --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SessionSearchViewModel.kt @@ -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 = 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:")`),因此每个会话各有一份, + * 切回来时上一次的查询与结果还在。取数一律走既有的 `MessageRepository`。 + * + * 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方, + * 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。 + * 每次 [submit] 都换代并取消上一个 job,旧代的结果回来直接丢。 + */ +class SessionSearchViewModel( + private val container: AppContainer, + private val sessionId: String, +) : ViewModel() { + + private val _state = MutableStateFlow(SessionSearchUiState()) + val state: StateFlow = _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), + ) + } + }, + ) + } + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageSearchRepositoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageSearchRepositoryTest.kt new file mode 100644 index 0000000..7e226ef --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageSearchRepositoryTest.kt @@ -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() + + @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) + } + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SessionSearchModelTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SessionSearchModelTest.kt new file mode 100644 index 0000000..76743f7 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SessionSearchModelTest.kt @@ -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 = "她凑近了一点

今天下午要下雨

" + + 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("她凑近了一点

正文

", "凑近") + + 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") + + 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("

第一行\n\n第二行 要下雨

", "下雨") + + 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("

", "下雨").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"))) + } +}