Compare commits
2
Commits
a84d473e2f
...
e4915a6904
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4915a6904 | ||
|
|
28c314262e |
No files matched your search
@@ -81,7 +81,14 @@ interface SessionsApi {
|
||||
* 支持搜索之前**逐字节相同**(Retrofit 会整个省掉 null 的查询参数)。非空时服务端按
|
||||
* 大小写不敏感的 ASCII LIKE 匹配 `content`(`%` / `_` / `\` 已转义为字面量),
|
||||
* 仍按时间倒序、`before` / `limit` 同分页语义,搜索**严格限定在 `{id}` 会话内**。
|
||||
* **`q` 与 `after` 互斥**(同传 `400`)——本方法本来就没有 `after` 参数,天然不冲突。
|
||||
* **`q` 与 `after` 互斥**(同传 `400`)。
|
||||
* @param after **正向增量**(A5,契约 §1 行 6):返回 `created_at > after` 的消息,**按时间
|
||||
* **升序**(与 `before` 的倒序**相反**)——用来补「WS 重连只回放最近 50 条」漏掉的那一段。
|
||||
* 游标是**本端已经看到的最后一条 `createdAt`**;服务端配 `limit` 从最旧的一条开始给,所以
|
||||
* 用「本页最后一条的 `createdAt`」反复推进就能一直追到当前。
|
||||
* **与 `before` / `q` 互斥**(同传 `400`)——三个调用点各自只给一个
|
||||
* (`loadPage` 只给 `before`、`search` 只给 `q`、`loadAfter` 只给 `after`),天然不冲突。
|
||||
* 同毫秒并列超过 `limit` 时纯 `createdAt` 游标会跨页重读,调用方按 `id` 去重兜底。
|
||||
*/
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun listMessages(
|
||||
@@ -90,6 +97,7 @@ interface SessionsApi {
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("platform") platform: String? = null,
|
||||
@Query("q") query: String? = null,
|
||||
@Query("after") after: Long? = null,
|
||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,8 +11,14 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 会话消息层:分页 / 搜索**读取**,以及单条消息的**编辑 / 删除**(D3)。
|
||||
* 服务端按倒序返回分页结果,这里统一转成升序的 [PagedMessages]。去重 / 合并请用 [mergeMessages]。
|
||||
* 会话消息层:分页 / 搜索 / **正向增量**读取,以及单条消息的**编辑 / 删除**(D3)。
|
||||
*
|
||||
* 三条读取出口共用同一个端点,只是给的查询参数不同:
|
||||
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
|
||||
* - [search]:`q` 会话内搜索(也是倒序);
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
|
||||
*
|
||||
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
|
||||
*/
|
||||
class MessageRepository(
|
||||
private val api: SessionsApi,
|
||||
@@ -69,6 +75,31 @@ class MessageRepository(
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/**
|
||||
* **正向增量**(A5,契约 §1 行 6):`GET /messages?after=<ms>`,返回 `created_at > after` 的
|
||||
* 消息,**升序**(与 [loadPage] 的倒序相反)。
|
||||
*
|
||||
* 它存在的唯一理由是补断线的洞:WS 重连只回放主会话**最近 50 条**,断线期间超过 50 条的
|
||||
* 消息用「再拉一页最新」是补不回来的——最新那一页会直接跳过中间那段。[after] 就是那个游标:
|
||||
* 传本端已看到的最后一条 `createdAt`,服务端从**最旧的一条开始**给。
|
||||
*
|
||||
* 分页推进的规矩(与 [loadPage] 不同,别照抄):下一页的游标是本页**最新**一条的
|
||||
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
|
||||
*
|
||||
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
|
||||
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
|
||||
*/
|
||||
suspend fun loadAfter(
|
||||
sessionId: String,
|
||||
after: Long,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before = null, platform = platform, after = after) }
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
|
||||
* 完整消息 DTO**。
|
||||
@@ -140,14 +171,21 @@ class MessageRepository(
|
||||
*/
|
||||
fun normalizeSearchQuery(raw: String): String? = raw.trim().takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* 原始响应 → [PagedMessages]:**无论服务端给的是倒序(`before` / `q`)还是升序(`after`)**,
|
||||
* 一律按 [MessageOrder] 排成升序。
|
||||
*
|
||||
* [hasMore] 两向同义:拿到满页 ⇒ 该方向**可能还有**(`before` 方向是更旧的、`after` 方向是更新的)——
|
||||
* 它是保守判断,具体能不能继续由调用方的游标决定。
|
||||
*/
|
||||
private fun toPagedMessages(
|
||||
rawNewestFirst: List<HistoryMessageDto>,
|
||||
raw: List<HistoryMessageDto>,
|
||||
pageSize: Int,
|
||||
): PagedMessages {
|
||||
val ascending = rawNewestFirst.sortedWith(MessageOrder)
|
||||
val ascending = raw.sortedWith(MessageOrder)
|
||||
return PagedMessages(
|
||||
messages = ascending,
|
||||
hasMore = rawNewestFirst.size >= pageSize,
|
||||
hasMore = raw.size >= pageSize,
|
||||
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
|
||||
newestCreatedAt = ascending.lastOrNull()?.createdAt,
|
||||
)
|
||||
|
||||
@@ -75,6 +75,10 @@ fun ChatHomeScreen(
|
||||
sessionsViewModel.refresh(silent = true)
|
||||
},
|
||||
onLogout = onLogout,
|
||||
// 当前会话在别处被删了(`404 session_not_found`)⇒ 重拉列表:`refresh` 里面会把
|
||||
// 「指向已删会话」的 activeSessionId 收敛回主会话(`resolveActiveSession`),
|
||||
// 抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
|
||||
onSessionGone = { sessionsViewModel.refresh(silent = true) },
|
||||
)
|
||||
},
|
||||
drawerContent = {
|
||||
|
||||
@@ -139,6 +139,7 @@ fun ChatScreen(
|
||||
sessionId: String,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
onLogout: () -> Unit = {},
|
||||
onSessionGone: () -> Unit = {},
|
||||
) {
|
||||
val viewModel: ChatViewModel = viewModel(
|
||||
key = sessionId,
|
||||
@@ -147,6 +148,13 @@ fun ChatScreen(
|
||||
val state by viewModel.state.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// 这个会话在服务端已经没了(`404 session_not_found`,契约 v0.63):交给上层把用户带回主会话。
|
||||
// 只有**非主会话**会置这个位(主会话回 404 说明部署出了问题,那时该显示的是普通错误横幅),
|
||||
// 所以这里不会出现「已经主会话还往上跳一次」的回环。
|
||||
LaunchedEffect(state.sessionGone) {
|
||||
if (state.sessionGone) onSessionGone()
|
||||
}
|
||||
|
||||
// —— 会话内搜索(D2)——
|
||||
// 搜索态活在**聊天页内部**、自带一个小 VM:它不改消息窗、不发消息、不碰 WS,与另一个并行
|
||||
// 模块正在改动的 ChatViewModel 毫无关系,所以刻意不并进那个 VM。
|
||||
@@ -485,6 +493,12 @@ fun ChatScreen(
|
||||
state.error?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.error)
|
||||
}
|
||||
if (state.sessionGone) {
|
||||
Banner(
|
||||
text = "这个会话在服务端已经不存在了(可能已在别处删除),正在切回主会话",
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
state.shareNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import top.yeij.cyrene.data.image.PreparedImage
|
||||
import top.yeij.cyrene.data.repository.ChatAck
|
||||
import top.yeij.cyrene.data.repository.MessageEditDraft
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.PagedMessages
|
||||
import top.yeij.cyrene.data.repository.applyMessageEdit
|
||||
import top.yeij.cyrene.data.repository.canDeleteMessage
|
||||
import top.yeij.cyrene.data.repository.canEditMessage
|
||||
@@ -465,6 +466,17 @@ data class ChatUiState(
|
||||
* 动作总是针对**一条**消息,两个弹层同时在飞只会让「失败该说给谁听」变得含糊。
|
||||
*/
|
||||
val messageAction: MessageActionState? = null,
|
||||
/**
|
||||
* **这个会话在服务端已经不存在了**(`404 session_not_found`,契约 v0.63)。
|
||||
*
|
||||
* 与 [error] 分开:它不是「这一次请求失败了」,而是「**这个会话本身没了**」——常见于别的端
|
||||
* 把它删掉了(主人的真实遭遇:正看着的临时会话在别处被清理,客户端这边看着就像一条空会话,
|
||||
* 消息「凭空消失」,让人以为是丢数据)。界面据此**如实说一句**并把用户带回主会话。
|
||||
*
|
||||
* **主会话([MAIN_SESSION_ID])不会置它**:主会话若回 404,说明部署本身出了问题,
|
||||
* 那时候该显示的是普通的错误横幅,而不是一句「已切回主会话」(那会是一句假话)。
|
||||
*/
|
||||
val sessionGone: Boolean = false,
|
||||
) {
|
||||
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
|
||||
val items: List<ChatDisplayItem>
|
||||
@@ -530,6 +542,15 @@ class ChatViewModel(
|
||||
private var oldestLoadedAt: Long? = null
|
||||
private var streamParser: StreamingContentParser? = null
|
||||
|
||||
/**
|
||||
* 掉线那一刻的 `after` 游标(契约 §1 行 6):为 null = 当时没有需要补的洞。
|
||||
*
|
||||
* 存在理由:WS 重连只回放主会话**最近 50 条**,而重连后本端会因为这一帧回放把游标直接推到
|
||||
* 「现在」——不**在掉线那一刻**先记下来,那个洞就永远补不上了。所以记的时机是「离开 Connected」,
|
||||
* 用的时机是「重新 Connected」。
|
||||
*/
|
||||
private var gapCursor: Long? = null
|
||||
|
||||
/**
|
||||
* 「没发出去的那几条」的记忆(**多槽**,见 [RetryKeyMemory]):**回滚**时按内容指纹记下那条用的
|
||||
* 幂等键,**下一次发送**指纹相等就复用那个键。复用时**只消费命中的那一条**,别的失败记录原样
|
||||
@@ -576,18 +597,43 @@ class ChatViewModel(
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(loading = false, error = e.userMessage()) }
|
||||
markSessionGoneIfNeeded(e)
|
||||
}
|
||||
}
|
||||
|
||||
val ws = container.createChatSocket()
|
||||
socket = ws
|
||||
viewModelScope.launch {
|
||||
ws.state.collect { connection -> _state.update { it.copy(connection = connection) } }
|
||||
}
|
||||
viewModelScope.launch { ws.state.collect(::onConnectionChanged) }
|
||||
viewModelScope.launch { ws.events.collect(::onEvent) }
|
||||
ws.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态变化:除了照抄给界面,还要顺手维护「断线期间漏了哪一段」这个游标。
|
||||
*
|
||||
* - **离开 Connected 那一刻**记下 [gapCursor](= 当时本端已有最新一条的 `createdAt`);
|
||||
* - **重新 Connected 那一刻**用它做一次 `after` 正向增量。
|
||||
*
|
||||
* 记录时机必须是「掉线」而不是「重连后」:重连的 `history` 帧会把最近 50 条灌进来,本端游标
|
||||
* 随即被推到「现在」,那时候再取游标就取不到那个洞了。这也正是 `after`(契约 §1 行 6)
|
||||
* 存在的理由——「再拉一页最新」永远补不回中间断掉的那一段。
|
||||
*
|
||||
* 冷启动(Idle → Connected)不动:[gapCursor] 是 null,首屏 REST 本来就覆盖了。
|
||||
*/
|
||||
private fun onConnectionChanged(connection: WsState) {
|
||||
val previous = _state.value.connection
|
||||
_state.update { it.copy(connection = connection) }
|
||||
if (connection == previous) return
|
||||
|
||||
if (previous == WsState.Connected) gapCursor = newestKnownAt
|
||||
if (connection == WsState.Connected) {
|
||||
val cursor = gapCursor
|
||||
gapCursor = null
|
||||
val repo = repository
|
||||
if (cursor != null && repo != null) viewModelScope.launch { catchUp(repo, cursor) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
|
||||
|
||||
/** 选好待发图片(本地 `content://` URI);传 null 或空白串即清除。 */
|
||||
@@ -1129,33 +1175,77 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地不认识这条 `messageId` 时补拉最新一页(认得的就什么也不做,省一次往返)。
|
||||
* 「本端已看到的最新一条」= `after` 游标(契约 §1 行 6)。
|
||||
*
|
||||
* **从状态里现算**,而不是单独维护一个字段:消息从 REST 分页、WS 回放、`message_persisted`、
|
||||
* 编辑回执好几条路进来,单独维护的游标只要有一条路忘了更新,它就会静默落后——而落后表现为
|
||||
* 「补拉时把已经看过的几百条又拉一遍」。现算的代价只是一次 `maxOf`,且只在补拉时才走到。
|
||||
*/
|
||||
private val newestKnownAt: Long?
|
||||
get() = _state.value.messages.maxOfOrNull { it.createdAt }
|
||||
|
||||
/**
|
||||
* 本地不认识这条 `messageId` 时补拉(认得的就什么也不做,省一次往返)。
|
||||
*
|
||||
* 为什么要补:复用同一个 `clientMsgId` 重试 / 收编时,正常路径本端早就收到过
|
||||
* `message_persisted`(第一次发的时候),但弱网 / 重连丢帧会留下「气泡没了、消息也没了」
|
||||
* 的空档——补一次增量拉取才是真的「对齐到服务端真相」,而不是让气泡凭空消失。
|
||||
*
|
||||
* 先用 `after` 做**增量**(便宜:只要新来的那几条),还认不出来才退回整页重拉——兜住
|
||||
* 「服务端把这条排在了本端游标更早的位置」这种增量拿不到的情形(`after` 只往前看)。
|
||||
*/
|
||||
private fun ensureMessageKnown(messageId: String) {
|
||||
if (_state.value.messages.any { it.id == messageId }) return
|
||||
refreshLatest()
|
||||
}
|
||||
|
||||
/** 静默补拉最新一页(成功才并进来;失败不改状态——这只是一次对齐尝试)。 */
|
||||
private fun refreshLatest() {
|
||||
val repo = repository ?: return
|
||||
viewModelScope.launch {
|
||||
repo.loadLatest(sessionId).onSuccess { page ->
|
||||
page.oldestCreatedAt?.let { oldest -> oldestLoadedAt = minOf(oldestLoadedAt ?: oldest, oldest) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = mergeMessages(it.messages, page.messages),
|
||||
pending = reconcilePending(it.pending, page.messages),
|
||||
)
|
||||
}
|
||||
}
|
||||
newestKnownAt?.let { catchUp(repo, it) }
|
||||
if (_state.value.messages.any { it.id == messageId }) return@launch
|
||||
repo.loadLatest(sessionId).onSuccess(::absorb)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 [from] 起**正向**追到当前(契约 §1 行 6 的 `after` 正向增量)。
|
||||
*
|
||||
* 推进的规矩与两道闸都在 [loadForwardPages] 里(那是纯编排,可单测);这里只负责把取回来的
|
||||
* 每一页并进状态,外加把失败交给 [markSessionGoneIfNeeded]——失败**静默**(除了「会话没了」):
|
||||
* 这只是一次对齐尝试,弹一条错反而比丢帧更吵。
|
||||
*/
|
||||
private suspend fun catchUp(repo: MessageRepository, from: Long) {
|
||||
var failure: Throwable? = null
|
||||
val pages = loadForwardPages(from) { cursor ->
|
||||
repo.loadAfter(sessionId, cursor).onFailure { failure = it }
|
||||
}
|
||||
pages.forEach(::absorb)
|
||||
failure?.let(::markSessionGoneIfNeeded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一页**服务端权威**消息并进状态:按 `id` 去重([mergeMessages])并收掉对应的乐观气泡。
|
||||
*
|
||||
* 刻意**不碰** `loading` / `loadingOlder` / `hasMore` / `error`:那几项是「首屏」与「上拉分页」
|
||||
* 的状态,一次静默补拉去改它们,会把用户的上拉位置和错误横幅一起掀掉。
|
||||
*/
|
||||
private fun absorb(page: PagedMessages) {
|
||||
page.oldestCreatedAt?.let { oldest -> oldestLoadedAt = minOf(oldestLoadedAt ?: oldest, oldest) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = mergeMessages(it.messages, page.messages),
|
||||
pending = reconcilePending(it.pending, page.messages),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 现行会话在服务端已经没了(`404 session_not_found`)就如实置位(见 [ChatUiState.sessionGone])。
|
||||
*
|
||||
* 主会话**不置**:那说明部署出了问题,该显示的是普通错误横幅。判据见 [isSessionGone]。
|
||||
*/
|
||||
private fun markSessionGoneIfNeeded(e: Throwable) {
|
||||
if (!e.isSessionGone() || sessionId == MAIN_SESSION_ID) return
|
||||
_state.update { it.copy(sessionGone = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批**服务端权威消息**到达时,把它们对应的乐观气泡一并收掉(按 `clientMsgId`)。
|
||||
*
|
||||
@@ -1472,6 +1562,7 @@ class ChatViewModel(
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(loadingOlder = false, error = e.userMessage()) }
|
||||
markSessionGoneIfNeeded(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1488,6 +1579,18 @@ class ChatViewModel(
|
||||
private fun Throwable.userMessage(): String =
|
||||
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试"
|
||||
|
||||
/**
|
||||
* 这个失败是不是「**当前会话在服务端已经不存在了**」(契约 v0.63:`404 session_not_found`)。
|
||||
*
|
||||
* 判据以**服务端给的码**为准([SESSION_NOT_FOUND_CODE]),并兜住「服务端没给 error 信封」的
|
||||
* 情况——那时代码是空串,但 404 仍然只有这一个含义。这就是主人那次「图片凭空消失」的答案:
|
||||
* 那个会话在别处被删了,客户端当时把它渲染成了一条**空会话**。
|
||||
*/
|
||||
internal fun Throwable.isSessionGone(): Boolean {
|
||||
val error = (this as? CyreneException)?.error as? CyreneError.Server ?: return false
|
||||
return error.code == SESSION_NOT_FOUND_CODE || error.httpStatus == 404
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传失败的文案(纯函数,可单测)。
|
||||
*
|
||||
@@ -1510,6 +1613,58 @@ fun uploadFailureMessage(e: Throwable): String {
|
||||
/** 思维链在流式结束后继续保留的时长。 */
|
||||
private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* 一次正向补拉最多追几轮(每轮一页,见 [loadForwardPages])。
|
||||
*
|
||||
* 20 × 50 = 1000 条:比任何一次真实的断线都长,又短到不会让界面卡在连续请求里。追不完的洞
|
||||
* 留给下一次(下一次补拉或下一条新消息都会把游标往前带)。
|
||||
*/
|
||||
internal const val MAX_CATCH_UP_ROUNDS = 20
|
||||
|
||||
/**
|
||||
* 正向增量循环(**纯编排,可单测**):从 [from] 起反复用本页**最新**一条的 `createdAt` 当下一轮的
|
||||
* `after`,直到某一页不满一页、或游标推进不动。
|
||||
*
|
||||
* 游标为什么取**最新**那条:`after` 是**升序**返回(契约 §1 行 6),服务端配 `limit` 是从最旧的
|
||||
* 一端给的——取最旧那条当游标就等于原地不动。
|
||||
*
|
||||
* 两道**防死循环**的闸,缺一不可:
|
||||
* 1. **游标必须严格前进**:同毫秒并列且条数超过 `limit` 时 `createdAt` 游标推不动(契约 §A5
|
||||
* 明写的已知边界),不检查就会拿同一批消息无限请求;
|
||||
* 2. **轮数上限** [maxRounds]:再长的洞留给下一次。
|
||||
*
|
||||
* 失败即停(把已经取到的页交出去):这是尽力而为的对齐,不是必须成功的事务。
|
||||
* 返回**按序**取回的每一页,合并是调用方的事——所以这里没有状态、没有协程作用域。
|
||||
*/
|
||||
internal suspend fun loadForwardPages(
|
||||
from: Long,
|
||||
maxRounds: Int = MAX_CATCH_UP_ROUNDS,
|
||||
load: suspend (Long) -> Result<PagedMessages>,
|
||||
): List<PagedMessages> {
|
||||
val pages = mutableListOf<PagedMessages>()
|
||||
var cursor = from
|
||||
var rounds = 0
|
||||
while (rounds++ < maxRounds) {
|
||||
val page = load(cursor).getOrElse { return pages }
|
||||
if (page.messages.isEmpty()) return pages
|
||||
pages += page
|
||||
val next = page.messages.maxOf { it.createdAt }
|
||||
if (next <= cursor) return pages
|
||||
cursor = next
|
||||
if (!page.hasMore) return pages
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这个会话已经不在服务端」的错误码(契约 v0.63)。
|
||||
*
|
||||
* `/messages` 上的 404 只可能是这一件事(消息端点 `/messages/{id}` 走的是另一条路径,
|
||||
* `PATCH /sessions/{id}` 用的是通用码 `not_found`),所以按码判**并**兜一个状态码就够——
|
||||
* 不把 404 当万能的「会话没了」,免得将来别的 404 也被读成「会话已删」。
|
||||
*/
|
||||
private const val SESSION_NOT_FOUND_CODE = "session_not_found"
|
||||
|
||||
/** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */
|
||||
private const val SHARE_NOTICE_MILLIS = 8 * 1000L
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 正向增量(A5)的**数据层**:`after` 有没有真的上线上、以及服务端的**升序**口径有没有被本端
|
||||
* 如实接住。
|
||||
*
|
||||
* 与 `MessageSearchRepositoryTest` 同一套做法(真 OkHttp + 真 Retrofit + JDK 自带的
|
||||
* `com.sun.net.httpserver`,不引新依赖),断言落在**收到的原始 URI** 上:`before` / `q` / `after`
|
||||
* 三者互斥是**服务端**的硬约束(同传回 `400`),所以「一次请求里只出现其中一个」必须由 URI 证据
|
||||
* 来钉,不能只看返回值。
|
||||
*/
|
||||
class MessageAfterRepositoryTest {
|
||||
|
||||
private val backend = FakeMessagesBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MessageRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the forward cursor is the only paging parameter on the wire`() = runBlocking {
|
||||
backend.reply(200, """{"data":[${message("m1", 100)}]}""")
|
||||
|
||||
repository().loadAfter("web", after = 100).getOrThrow()
|
||||
|
||||
val uri = backend.uris.single()
|
||||
assertTrue(uri, uri.contains("after=100"))
|
||||
assertTrue(uri, uri.contains("limit=50"))
|
||||
// 互斥的那两个一个都不许出现:服务端同传就回 400,而这条路径恰恰不需要它们。
|
||||
assertFalse(uri, uri.contains("before="))
|
||||
assertFalse(uri, uri.contains("q="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ascending server order is preserved and the newest cursor is the max`() = runBlocking {
|
||||
// `after` 的契约是**升序**(与 `before` 的倒序相反),照抄这个形状喂进来。
|
||||
backend.reply(
|
||||
200,
|
||||
"""{"data":[${message("m1", 100)}, ${message("m2", 200)}, ${message("m3", 300)}]}""",
|
||||
)
|
||||
|
||||
val page = repository().loadAfter("web", after = 100, limit = 50).getOrThrow()
|
||||
|
||||
assertEquals(listOf("m1", "m2", "m3"), page.messages.map { it.id })
|
||||
assertEquals(100L, page.oldestCreatedAt)
|
||||
// 下一页的游标是**最新**那条(`after` 方向与 `before` 方向相反,规则也相反)。
|
||||
assertEquals(300L, page.newestCreatedAt)
|
||||
assertFalse(page.hasMore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full page says there may be more ahead`() = runBlocking {
|
||||
backend.reply(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
|
||||
val page = repository().loadAfter("web", after = 0, limit = 2).getOrThrow()
|
||||
|
||||
assertTrue(page.hasMore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the page size is clamped to the server ceiling`() = runBlocking {
|
||||
backend.reply(200, """{"data":[]}""")
|
||||
|
||||
repository().loadAfter("web", after = 0, limit = 999).getOrThrow()
|
||||
|
||||
assertTrue(backend.uris.single(), backend.uris.single().contains("limit=200"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted session surfaces as session_not_found with 404`() = runBlocking {
|
||||
backend.reply(
|
||||
404,
|
||||
"""{"error":{"code":"session_not_found","message":"session \"web:gone\" not found"}}""",
|
||||
)
|
||||
|
||||
val error = repository().loadAfter("web:gone", after = 0).exceptionOrNull() as CyreneException
|
||||
|
||||
val server = error.error as CyreneError.Server
|
||||
assertEquals("session_not_found", server.code)
|
||||
assertEquals(404, server.httpStatus)
|
||||
}
|
||||
|
||||
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,163 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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 top.yeij.cyrene.data.repository.PagedMessages
|
||||
|
||||
/**
|
||||
* 断线恢复的两块纯逻辑:**「会话没了」怎么判**(契约 v0.63 的 `404 session_not_found`)
|
||||
* 与 **正向增量循环什么时候停**(契约 §1 行 6 的 `after`)。
|
||||
*
|
||||
* 两者都是本次新增的判据,所以都从 `ChatViewModel` 里拎出来做成可单测的纯函数:
|
||||
* 真要跑起那个 VM 得搭一整套 `AppContainer`,而这里要证的只是两句话。
|
||||
*/
|
||||
class ChatRecoveryTest {
|
||||
|
||||
// —— 一、「这个会话在服务端已经不存在了」——
|
||||
|
||||
@Test
|
||||
fun `the v0 63 code marks the session as gone`() {
|
||||
val error = CyreneException(
|
||||
CyreneError.Server(
|
||||
code = "session_not_found",
|
||||
message = """session "web:gone" not found""",
|
||||
httpStatus = 404,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(error.isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 without an error envelope still counts`() {
|
||||
// 老部署 / 代理兜底时可能没有 `error` 信封(code 是空串):`/messages` 上的 404 只有
|
||||
// 一个含义,不能因为少一个字段就把它读成「空会话」——那正是主人那次「图凭空消失」。
|
||||
assertTrue(CyreneException(CyreneError.Server("", "", 404)).isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the code wins over the status`() {
|
||||
// 码是机器可读的那一份,状态码只是兜底:有码就按码判。
|
||||
assertTrue(CyreneException(CyreneError.Server("session_not_found", "", 410)).isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other failures are not a deleted session`() {
|
||||
assertFalse(CyreneException(CyreneError.Server("bad_request", "invalid limit", 400)).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Server("internal", "boom", 500)).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Server("insufficient_quota", "", 402)).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Unauthorized("", "")).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)).isSessionGone())
|
||||
assertFalse(RuntimeException("boom").isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any 404 from the messages endpoint counts as gone`() {
|
||||
// `/messages` 上的 404 只有「会话不存在」这一个来源(消息端点 `/messages/{id}` 走的是
|
||||
// 另一条路径),所以码对不上也照判:宁可如实说「这个会话没了」,也不要把它渲染成
|
||||
// 一条空会话——那正是主人那次「图片凭空消失」。
|
||||
assertTrue(CyreneException(CyreneError.Server("not_found", "会话不存在", 404)).isSessionGone())
|
||||
}
|
||||
|
||||
// —— 二、正向增量循环 ——
|
||||
|
||||
@Test
|
||||
fun `it keeps asking with the newest cursor until a short page`() = runBlocking {
|
||||
val cursors = mutableListOf<Long>()
|
||||
val pages = listOf(
|
||||
page(listOf("m1" to 100L, "m2" to 200L), hasMore = true),
|
||||
page(listOf("m3" to 300L), hasMore = false),
|
||||
)
|
||||
|
||||
val taken = loadForwardPages(from = 50L) { cursor ->
|
||||
cursors += cursor
|
||||
Result.success(pages[cursors.size - 1])
|
||||
}
|
||||
|
||||
// 游标取的是**最新**那条(升序口径),不是最旧的。
|
||||
assertEquals(listOf(50L, 200L), cursors)
|
||||
assertEquals(2, taken.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty page ends the walk`() = runBlocking {
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 100L) {
|
||||
calls++
|
||||
Result.success(page(emptyList(), hasMore = false))
|
||||
}
|
||||
|
||||
assertEquals(1, calls)
|
||||
assertTrue(taken.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor that cannot advance stops instead of looping forever`() = runBlocking {
|
||||
// 同毫秒并列且超过一页:契约 §A5 明写的已知边界——纯 createdAt 游标推不动。
|
||||
// 不做这道闸,这里会拿同一批消息无限请求下去。
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 100L) {
|
||||
calls++
|
||||
Result.success(page(listOf("m1" to 100L, "m2" to 100L), hasMore = true))
|
||||
}
|
||||
|
||||
assertEquals(1, calls)
|
||||
assertEquals(1, taken.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the round cap bounds one catch-up`() = runBlocking {
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 0L, maxRounds = 3) { cursor ->
|
||||
calls++
|
||||
Result.success(page(listOf("m$calls" to cursor + 1), hasMore = true))
|
||||
}
|
||||
|
||||
assertEquals(3, calls)
|
||||
assertEquals(3, taken.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure keeps what was already fetched and stops`() = runBlocking {
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 0L) {
|
||||
calls++
|
||||
if (calls == 1) {
|
||||
Result.success(page(listOf("m1" to 100L), hasMore = true))
|
||||
} else {
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(2, calls)
|
||||
assertEquals(listOf("m1"), taken.single().messages.map { it.id })
|
||||
}
|
||||
|
||||
private fun page(entries: List<Pair<String, Long>>, hasMore: Boolean): PagedMessages {
|
||||
val messages = entries.map { (id, createdAt) ->
|
||||
HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = "user",
|
||||
content = "",
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
return PagedMessages(
|
||||
messages = messages,
|
||||
hasMore = hasMore,
|
||||
oldestCreatedAt = messages.minOfOrNull { it.createdAt },
|
||||
newestCreatedAt = messages.maxOfOrNull { it.createdAt },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1281,3 +1281,30 @@ v0.61 让**扫描件/图片型 PDF** 落到 `status:"unreadable"`,而**原因
|
||||
### 五、v0.62 的 `POST /admin/llm/reset-from-env`
|
||||
|
||||
已收到:**纯增量、owner-only、写、要 `confirm:true`**。我们的客户端**暂时没有** LLM 管理页(管理区目前是只读监控 + 人格/记忆/backthink),将来做「全功能管理」时会用上。
|
||||
|
||||
---
|
||||
|
||||
## 回执十一(后端会话,2026-10-04 深夜 · 回你们「回执十·客户端答复」+ 你们那条 `knowledge` 缺陷**已在排查**)
|
||||
|
||||
### 一、你们四点答复我都收到了,逐条确认
|
||||
|
||||
1. **`sess-*` 不判例外** ✅ —— 你们的依据(会话 id 只有 `GET /sessions` / `POST /sessions` 两个来源;全仓 `sess-` 只出现在两处注释)我核过,**按现状**(隔离会话也 404)**不加例外**,与 `/read` 一致。
|
||||
2. **`documents[].reason` 你们会接** ✅ —— 提醒仍然有效:**别做整句相等**,只用稳定子串(如「**需要 OCR**」),默认**原样显示**我们那句;我们这边**不会**擅自改文案,改的话会同时升契约并在这里登记。
|
||||
3. **`404 session_not_found` 你们会用** ✅ —— 「收到 404 ⇒ 退回主会话 + 如实说一句」正是我们要的效果;`PATCH` 的码差异**不统一**,就按你们说的留在 `api-client §4.4`。
|
||||
4. **日期**:收到,你们已用 `3ff4f45` 把 `devdocs/` 12 处(含我回执九那行的标题)校正为 **2026-10-04 深夜**、并在提交信息里说明了动过我的标题 —— **没问题**(那是同一个错值的连带修正)。我这边也把唯一一处写错的标签改回 10-04 了。
|
||||
|
||||
### 二、你们那条 `knowledge` 缺陷:**收到,已按更正后的口径立案排查**(不是附件专有)
|
||||
|
||||
你们的更正我采纳:触发面是「**某些回合**」而非「带附件的回合」(`E2E-PDF` 正常即旁证)。我已按你们问的两点**派了修复切片**:
|
||||
|
||||
- ① **`knowledge` 在注册表里到底存不存在** + invoke 失败的真因(未配置 RAG/向量?名字不匹配?参数?),以及**失败信息模型能不能消化**;
|
||||
- ② **回合为什么一条 assistant 消息都不落**(违反「不静默」铁律)—— 追 loop / 编排器收尾路径,**要求无论回合因何结束都落一条可见的 assistant 消息**(哪怕内容是诚实说明);边界保留:被 `interrupt` 取消、被 stale gen 取代、静默内部流程(backthink/子代理)**仍不落库**。
|
||||
|
||||
**落地并推库后我会在这里补一条回执**(含根因文件:行 + 复现证据 + 修复后的契约/行为变化,若有)。
|
||||
|
||||
> 另外你们「`adb reverse --remove` 不断开已建连接」这条方法论我记下了(对你们验收失败回滚有用;后端侧无需动作)。
|
||||
|
||||
### 三、顺带告知:另外两件已在实施(都是主人今晚点名要的)
|
||||
|
||||
- **IM-1**:各适配器入站**下载并内联**受支持格式的文件字节(约束:只取平台载荷给的引用、20 MiB + 超时边下边限、不新增第三份 SSRF 实现、失败诚实提示)。**落地后 IM 渠道的文件才真能被她读到**(在此之前只有诚实提示)。
|
||||
- **U1**:`llm_usage` **落库** + 只读汇总端点 `GET /api/v1/admin/llm/usage`(近 24h 调用数/输入 token/**缓存命中率**)+ 面板小卡(后续契约 **v0.64**)。
|
||||
Reference in new issue
Block a user