feat(sessions): 接上 D4 未读 —— 抽屉角标 + 已读游标(进入即读、看着就点已读)

后端 v0.37 起 `GET /sessions` 每条会话恒下发 `unreadCount`、`POST /sessions/{id}/read`
可推进**账号级**已读游标(只前进,`MAX(read_at, before)`)、成功后广播 `session_read`,
客户端一直没接。本次补齐:列表能看见未读、看完真的会消、别的端读了本端也消。

- data/api/dto/SessionDto.kt:`SessionSummaryDto.unreadCount`(**默认 0**——
  `SessionPreferences` 把整份列表原样持久化,非默认值会让升级后第一次启动整份快照
  解码失败、列表先空一下;默认 0 只会让角标少显示一会儿);
  新增 `ReadSessionRequest`(`before` 缺席 = 现在,`explicitNulls=false` 保证序列化成
  `{}`,正是服务端接受的形状)与 `ReadSessionResponse`
- data/api/SessionsApi.kt + data/repository/SessionRepository.kt:`markRead(sessionId, before)`
  —— 不传 0(服务端对 0/负数回 400),null 即「现在」
- data/ws/WsEvent.kt:新增 `SessionRead`(`{type:"session_read", sessionId, data:{readAt}}`)。
  会话归属**只在帧壳**上(data 里没有 sessionId);`readAt` 缺席回落 0,而 0 在上层是
  「无从判断」——坏帧因此天然退化成什么都不改
- ui/sessions/SessionsViewModel.kt:`SessionItem` 多出 `unreadCount` 与 `lastMessageAt`
  (后者只用于游标比对);两条调和规则方向相反且都写成纯函数——
  `withReadCursor(readAt)` **有依据**地清(游标只前进,本地最新消息不比游标新才算真已读;
  若有更新的消息则保留旧计数,不能把真实未读藏起来),`markRead(id)` 无条件清(用户就在这一行)
- 已读策略:① `select()`(点开 = 进入)以「现在」推进——聊天页紧接着会加载并显示最新一页,
  比推本地可能陈旧的 `lastMessage.createdAt` 更诚实,且先就地清零,角标不等网络往返;
  ② `message_persisted` 且会话是**当前打开**的那一个时,以该消息 `createdAt` 推进
  (只标它及其之前,同时落库的更晚消息仍算未读),否则「自己正看着的会话」会攒出角标。
  不该标的绝不乱标:别的会话攒未读是正确行为,本端不做「替服务端数未读」(增量会漂移,
  抽屉每次打开都会静默重拉,以服务端为准)。失败**静默**——服务端游标是唯一真源
- `session_read` 帧按帧里的会话 id **逐行**定位(刻意不用 `isForSession`:那道闸是给
  「一个 VM 只关心一个会话」的 ChatViewModel 用的,本 VM 持有整个列表,按 activeSessionId
  过滤会把「别的会话被读了」这一帧丢掉,那正是最该处理的一半)
- 另记一份本地已知游标(只取服务端口径:read 响应的 readAt / 帧的 readAt / 落库消息的
  createdAt),在整表替换时把「本地已知没有更新消息」的行压回 0——否则一次竞态中的
  GET(快照早于本端刚发起的 read)会把角标闪回来;登出时随快照一起清(游标是账号级的)
- ui/sessions/SessionDrawerContent.kt:角标用 Miuix 自带 `Badge`(错误色药丸 + onError 文字),
  且**放在标题行里**而不是 `endActions`:抽屉固定 300dp,卡片与内边距之后只剩约 244dp,
  `endActions` 已有三个 40dp 图标(120dp)且被库内 60% 硬上限卡着,再塞角标会把图标挤到裁剪。
  改成标题行的 `[标题][角标]` 后角标不占额外列宽;标题/摘要同时收紧为 `maxLines = 1` +
  省略号(长标题不再在约 116dp 里折成两三行把卡片撑高);`99+` 收敛、`0` 不画

测试:305 全绿(272 → +33):角标文本(0/负数不画、99 精确、100 与 1234 收敛 `99+`)、
两条调和规则的各种边界(游标等于/新于/旧于最新消息、无消息会话、坏帧 readAt=0、
按会话逐行定位、遮罩只压记过游标的行)、`toItem` 映射、`session_read` 帧解码
(生产者逐字取自后端 `gateway/session_read.go:broadcastSessionRead`)、DTO 真载荷
(`GET /sessions` 抓包里主会话 unreadCount=6、次级=0)与**旧快照无该键仍解得开**、
read 请求体序列化(`{}` / `{"before":…}`)。

未验证(无模拟器,也不许碰 adb,只有构建 + 单测):
- 角标与标题的**实际观感**没截图。宽度是按 Miuix 0.9.3 源码算的(抽屉 300dp − 卡片边距 24
  − 组件内边距 32 = 244dp;endActions 120dp < 60% 上限,标题列约 116dp,角标约 16~32dp)。
  LazyColumn 里角标与标题的具体落位(紧贴标题还是行尾)取决于 Row 的权重分配细节,两种都成立、
  都不遮蔽文字,但真机上可能想微调间距
- 「进入即读」在 UI 上没端到端跑过:POST /sessions/{id}/read 是否 200、`session_read` 帧
  是否真的清掉另一端的角标,只有单测与契约(后端源码)支撑,没有真机往返
- 已知近似:判断「正在看这个会话」只用 `activeSessionId`,而它是**活动页**的会话 id——
  用户切到设置 / 管理页时 ChatScreen 已卸载,此时落库的消息也会被标记已读(他会回来看到,
  但角标已经消了)。信息不足,如实记在这里

架构代价(需主人裁决):`session_read` / `message_persisted` 只在 WS 上,而 `AppContainer`
只提供 `createChatSocket()`(每次新建,**没有进程级共享连接**),`ChatSocket` / `ChatViewModel`
/ `AppContainer` 都在别人的改动范围里 —— 本 VM 因此自持一条连接(登录后连、登出断开、
销毁断开)。这是既有遗留问题「每 VM 一条 socket、旧 socket 不关」之上再加一条;服务端支持
同账号多连接(多端本来如此),收不到帧也只是退回「下次 GET /sessions 收敛」。共享一条
app 级连接是更好的终局,但那要动 AppContainer / ChatSocket。
This commit is contained in:
AskaEth committed 2026-10-04 17:42:31 +08:00
1 parent feeb49ebeb
commit f413f8db55
9 files changed
+754 -11

No files matched your search

@@ -3,6 +3,8 @@ package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
@@ -60,4 +62,17 @@ interface SessionsApi {
@Query("before") before: Long? = null,
@Query("platform") platform: String? = null,
): Response<ApiEnvelope<List<HistoryMessageDto>>>
/**
* 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。
*
* `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。
* 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、
* 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。
*/
@POST("api/v1/sessions/{id}/read")
suspend fun markRead(
@Path("id") sessionId: String,
@Body body: ReadSessionRequest,
): Response<ApiEnvelope<ReadSessionResponse>>
}
@@ -21,6 +21,16 @@ data class SessionSummaryDto(
val pinned: Boolean = false,
/** 会话还没有消息时**整个键缺席**。 */
val lastMessage: SessionLastMessageDto? = null,
/**
* 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数,
* `0` = 无未读。
*
* 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences]
* 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败,
* 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
*/
val unreadCount: Int = 0,
) {
val isMain: Boolean
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
@@ -82,3 +92,26 @@ data class UpdateSessionResponse(
data class DeleteSessionResponse(
val deleted: String,
)
/**
* `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。
*
* - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false`
* 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象
* (`api-reference §3.6b`:**空 body / `{}` = 现在**)。
* - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到
* 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。
* - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。
*/
@Serializable
data class ReadSessionRequest(
val before: Long? = null,
)
/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"<id>","readAt":<毫秒>}}`。 */
@Serializable
data class ReadSessionResponse(
val sessionId: String = "",
/** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */
val readAt: Long = 0L,
)
@@ -2,6 +2,8 @@ package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
@@ -49,4 +51,18 @@ class SessionRepository(
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
}
/**
* `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。
*
* @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数
* 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。
*
* 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用——
* 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。
*/
suspend fun markRead(sessionId: String, before: Long? = null): Result<ReadSessionResponse> =
withContext(dispatcher) {
apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) }
}
}
@@ -309,6 +309,26 @@ sealed class WsEvent {
override val type: String get() = "chat_duplicate"
}
/**
* 已读游标推进(D4,契约 v0.37,api-client §3.9):
* `{type:"session_read", sessionId, data:{readAt}}`——**载荷里没有会话 id**,
* 会话归属**只**来自帧壳(服务端 `POST /sessions/{id}/read` 成功后广播)。
*
* 它是那 7 个「不含内容的状态机信号」之一(发给**所有**已认证连接,含 `viewer`),
* 与 `message_persisted` 同一条全连接广播路径,故同样要按 [isForSession] 收敛。
* [readAt] 是**推进后**的游标(毫秒,与该会话消息的 `createdAt` 同单位);
* data 缺席时回落 `0`——游标实际不可能为 0,于是「坏帧」天然退化成**什么也不改**
* (见 `SessionItem.withReadCursor`:只有 `lastMessageAt <= readAt` 才清零)。
*/
data class SessionRead(
override val sessionId: String,
val readAt: Long,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_read"
}
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
data class MessagePersisted(
val message: HistoryMessageDto?,
@@ -459,6 +479,9 @@ sealed class WsEvent {
seq,
)
}
// 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。
"session_read" -> decode<SessionReadData>(data)
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
"message_persisted" -> decode<PersistedData>(data)
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"channel_persisted" -> decode<PersistedData>(data)
@@ -567,6 +590,10 @@ private data class ChatDuplicateData(
val messageId: String? = null,
)
/** `session_read` 载荷(api-client §3.9):`{readAt}`——毫秒游标,**不带**会话 id。 */
@Serializable
private data class SessionReadData(val readAt: Long = 0L)
@Serializable
private data class PersistedData(val message: HistoryMessageDto? = null)
@@ -21,10 +21,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Badge
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
@@ -157,6 +163,19 @@ fun ColumnScope.SessionDrawerContent(
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
* 子级会先吃掉按下事件,长按就永远不会触发)。
*
* ## 为什么不用 `BasicComponent` 的 `title` / `summary` 参数
* 那两个参数只收 `String`,塞不下未读角标;而角标**不能**放进 `endActions`——
* 抽屉固定 300dp,卡片 + 内边距之后只剩约 244dp,而 `endActions` 里已经有三个 40dp 的
* 图标按钮(120dp),`BasicComponent` 又把它内部可用宽度的 60% 作为 `endActions` 的硬上限:
* 再塞一个角标会把图标挤到裁剪(Row 不换行),标题列也被压到约 95dp。
* 所以这里改用「内容重载」自己搭标题行:`[标题][角标]` 同行,角标紧挨标题、不占额外列宽。
*
* 代价是标题 / 摘要的字号、字重、颜色要自己写,取值与库内实现(`Component.kt`)逐字一致
* (`headline1.fontSize` + `Medium` + `BasicComponentDefaults.titleColor()`;
* `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**:
* 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的
* 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。
*/
@Composable
private fun SessionRow(
@@ -167,11 +186,13 @@ private fun SessionRow(
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val titleColor = BasicComponentDefaults.titleColor().color
val summaryColor = BasicComponentDefaults.summaryColor().color
val badge = unreadBadgeText(session.unreadCount)
PreferenceCard {
BasicComponent(
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
title = session.title,
summary = session.lastMessage,
endActions = {
if (active) {
Text(
@@ -202,10 +223,53 @@ private fun SessionRow(
}
}
},
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = session.title,
// 标题是这一行唯一的权重子项,`fill = false` 让短标题只占自己需要的宽度。
// 真正保证角标不被挤掉的是 Row 的测量顺序:**非权重子项(角标与它前面的间距)
// 先量**,权重只分配剩下的空间,标题再在这块空间里省略——
// 所以 `99+` 永远进得来,标题也不会被压到完全看不见。
modifier = Modifier.weight(1f, fill = false),
fontSize = MiuixTheme.textStyles.headline1.fontSize,
fontWeight = FontWeight.Medium,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (badge != null) {
Spacer(Modifier.width(UNREAD_BADGE_GAP))
// Miuix 自带角标:错误色药丸 + `onError` 文字 + 16dp 下限高度,
// 在 `surfaceContainer` 卡片上对比度足够;`99+` 会自然撑成药丸而不会溢出。
Badge(
modifier = Modifier.clearAndSetSemantics {
contentDescription = "未读 $badge"
},
) {
Text(badge)
}
}
}
session.lastMessage?.let { preview ->
Text(
text = preview,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = summaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** 标题与角标之间的间距:太小会粘成一个词,太大又脱离标题。 */
private val UNREAD_BADGE_GAP = 6.dp
/** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */
@Composable
private fun RenameDialog(
@@ -12,6 +12,8 @@ import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
data class SessionItem(
val id: String,
@@ -20,6 +22,13 @@ data class SessionItem(
val pinned: Boolean,
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
val lastMessage: String?,
/**
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
*/
val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
val unreadCount: Int = 0,
)
data class SessionsUiState(
@@ -37,6 +46,21 @@ data class SessionsUiState(
* 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
* 客户端不重排。
*
* ## 未读(D4)
* 未读条数来自服务端(`GET /sessions` 的 `unreadCount`,恒下发),本类只在三处改它:
* 列表整表替换(以服务端为准)、**进入会话**、以及**正在看的会话有新消息落库**——后两处
* 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。
* 反向同步(别的端读了)靠下行 `session_read` 帧。
*
* ## 为什么本类自己开一条 WS 连接
* `session_read` / `message_persisted` 都在 WS 上,而 `AppContainer` 只提供
* `createChatSocket()`(**每次调用新建**,没有进程级共享连接),`ChatSocket` / `ChatViewModel`
* 又都在别的改动范围里。要在这里收帧,唯一不改别人的办法就是自持一条连接。
* 代价如实说:这是本应用**已知遗留问题**(「每 VM 一条 socket、旧 socket 不关」,见
* `devdocs/05`)之上再加一条——服务端对同一账号的多连接是支持的(多端场景本来如此),
* 而且本类只关心两个帧类型,收不到也不会让功能失效(`GET /sessions` 会收敛)。
* 生命周期:登录后连接、登出断开、VM 销毁断开([onCleared])。
*/
class SessionsViewModel(private val container: AppContainer) : ViewModel() {
@@ -45,6 +69,21 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
private val prefs = container.sessionPreferences
/**
* 本端**已知的已读游标**(会话 id → 毫秒),写入值一律取自**服务端口径**:
* - `POST /sessions/{id}/read` 响应里的 `readAt`(权威);
* - 下行 `session_read` 帧的 `readAt`(可能是别的端推的);
* - 本端对某条落库消息发起的推进,先用该消息的 `createdAt` 占位——它也是服务端时间,
* 所以不存在设备时钟偏差。
*
* 唯一用途是 [withReadCursors]:整表替换(REST 快照)时,把「本地已知没有比游标更新的
* 消息」的那些行的未读压回 0。不做这件事的话,刚在别处读完、或本端刚标记过的会话,
* 会因为一次竞态中的 GET 把角标又闪回来。
*/
private val readCursors = mutableMapOf<String, Long>()
private val socket: ChatSocket = container.createChatSocket()
init {
viewModelScope.launch {
val fingerprint = fingerprint()
@@ -52,7 +91,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
val snapshot = cached.snapshotFor(fingerprint)
_state.update {
it.copy(
sessions = snapshot.map { dto -> dto.toItem() },
sessions = snapshot.map { dto -> dto.toItem() }.withReadCursors(readCursors),
activeSessionId = if (snapshot.isEmpty()) {
SessionSummaryDto.MAIN_SESSION_ID
} else {
@@ -65,15 +104,34 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
refresh()
}
// 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)——
// 否则换账号后第一帧会闪一份上个账号的会话列表。
// 否则换账号后第一帧会闪一份上个账号的会话列表。已读游标同理:它是**账号级**的
// (服务端单一游标),留着会让新账号的会话被旧账号的游标错误地清成已读。
viewModelScope.launch {
container.authManager.state.collect { auth ->
if (auth == SessionState.LoggedOut) {
prefs.clear()
_state.value = SessionsUiState()
when (auth) {
SessionState.LoggedOut -> {
socket.close()
readCursors.clear()
prefs.clear()
_state.value = SessionsUiState()
}
// 连接只在登录后建立:Socket 的令牌 / 地址都是每次重连现读的,
// connect() 幂等(已连接时直接返回)。本 VM 存活期跨越登出 → 重登,
// 所以「登出断开、重登接上」必须在这里显式表达。
is SessionState.LoggedIn -> socket.connect()
SessionState.Unknown -> Unit
}
}
}
// 未读的两个下行来源都在这里收敛;见 [onEvent]。
viewModelScope.launch { socket.events.collect(::onEvent) }
}
override fun onCleared() {
socket.close()
super.onCleared()
}
/**
@@ -95,7 +153,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
if (active != _state.value.activeSessionId) prefs.setActive(active)
_state.update {
it.copy(
sessions = list.map { dto -> dto.toItem() },
// 未读以服务端为准,但先过一遍本地已知游标:一次竞态中的 GET
// (快照早于本端刚发起的 read)不该把角标闪回来。
sessions = list.map { dto -> dto.toItem() }.withReadCursors(readCursors),
activeSessionId = active,
loading = false,
fromCache = false,
@@ -116,9 +176,23 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
}
/**
* 切换 / 进入会话。
*
* **进入即已读**(D4):用户点开这个会话,聊天页紧接着会加载并显示它的最新一页,
* 所以立刻把游标推到「现在」(`before` 缺席 = 服务端取现在)——这比推一个本地
* `lastMessage.createdAt` 更诚实:本地的列表可能已经陈旧,而聊天页看到的一定是最新的。
* 先就地清零(不等网络往返,角标立刻消失),再用响应里的权威游标收敛。
*
* `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后,
* 屏幕上(即将)显示的就是这个会话。
*/
fun select(sessionId: String) {
_state.update { it.copy(activeSessionId = sessionId) }
_state.update {
it.copy(activeSessionId = sessionId, sessions = it.sessions.markRead(sessionId))
}
viewModelScope.launch { prefs.setActive(sessionId) }
pushReadCursor(sessionId)
}
fun createSession() {
@@ -184,6 +258,76 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
fun dismissError() = _state.update { it.copy(error = null) }
/**
* 下行帧收敛(未读相关只有两类)。
*
* **这里刻意不用 [top.yeij.cyrene.data.ws.isForSession] 过滤**:那道闸是给「一个 VM 只关心
* 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId
* 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id
* **逐行定位**([applyReadCursor],影响面严格限制在那一行)。
*/
private fun onEvent(event: WsEvent) {
when (event) {
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
is WsEvent.SessionRead -> onSessionRead(event)
// 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标,
// 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。
is WsEvent.MessagePersisted -> onMessagePersisted(event)
else -> Unit
}
}
private fun onSessionRead(event: WsEvent.SessionRead) {
if (event.sessionId.isBlank()) return
recordReadCursor(event.sessionId, event.readAt)
_state.update { it.copy(sessions = it.sessions.applyReadCursor(event.sessionId, event.readAt)) }
}
/**
* `message_persisted`:只有**当前打开的那个会话**才推进游标。
*
* 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数——
* 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移,
* 而抽屉每次打开都会静默重拉,以服务端为准)。
*/
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
if (sessionId != _state.value.activeSessionId) return
val message = event.message ?: return
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成
// 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。
val before = message.createdAt.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
}
/**
* 推进已读游标(D4)。**失败静默**:这是一次后台的「顺手记一下」,不是用户发起的操作——
* 弹错误横幅会为一件用户没要求、也不影响使用的失败打扰他;而且服务端游标才是唯一真源,
* 漏标一次会在下一次 `GET /sessions` 暴露出来、再被 [select] / [onMessagePersisted] 纠正。
* 请求本身幂等(服务端 `MAX(read_at, before)`),重复发不会把已读退回未读。
*
* @param before null = 现在。**绝不能传 0**:服务端把 `0` / 负数当非法参数回 400。
*/
private fun pushReadCursor(sessionId: String, before: Long? = null) {
viewModelScope.launch {
container.sessionRepository.markRead(sessionId, before)
.onSuccess { response ->
if (response.readAt > 0) recordReadCursor(sessionId, response.readAt)
_state.update {
it.copy(sessions = it.sessions.applyReadCursor(sessionId, response.readAt))
}
}
}
}
/** 记下已知游标:只接受正数(坏帧的 `readAt = 0` 不能污染成「读到纪元」)。 */
private fun recordReadCursor(sessionId: String, readAt: Long) {
if (readAt <= 0) return
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
}
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
@@ -194,8 +338,57 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
isMain = isMain,
pinned = pinned,
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 },
unreadCount = unreadCount,
)
/**
* 未读角标的文本(纯函数)。`null` = **不显示**角标。
*
* `0` 与负数都不显示(服务端只会给非负整数,负数当数据错误处理,不画一个「-1」)。
* 超过 [UNREAD_BADGE_MAX] 收敛为 `99+`:三位数的角标在 300dp 的抽屉里会把标题挤没,
* 而三位数以上的精确值对「有多少条没看」几乎没有信息增量。
*/
internal fun unreadBadgeText(count: Int): String? = when {
count <= 0 -> null
count > UNREAD_BADGE_MAX -> "$UNREAD_BADGE_MAX+"
else -> count.toString()
}
/** 角标显示的未读上限;超过就显示 `99+`。 */
internal const val UNREAD_BADGE_MAX = 99
/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */
internal fun List<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
/** 把某一行的未读清零(`session_read` 帧、或本端刚标记过)。 */
internal fun List<SessionItem>.applyReadCursor(sessionId: String, readAt: Long): List<SessionItem> =
map { if (it.id == sessionId) it.withReadCursor(readAt) else it }
/**
* 用户**正在看**这个会话(或本端刚为它推进过游标)时,无条件清掉它的未读。
* 与 [withReadCursor] 的区别:这里不需要游标依据——调用方已经知道「这一行就是屏幕上的那一行」
* (进入会话、正在看的会话落了新消息)。
*/
internal fun List<SessionItem>.markRead(sessionId: String): List<SessionItem> =
map { if (it.id == sessionId) it.copy(unreadCount = 0) else it }
/**
* 按已读游标清未读(纯函数):**只在「本地已知的最新一条消息不比游标新」时清零**。
*
* 为什么不能一律清零(api-client §3.9 说「直接清零也不会错」):那边的假设是「本地没有更完整的
* 数据」。本端有——`lastMessage.createdAt` 就是本地已知的最新消息时间。若它**晚于**游标,
* 说明确实还有未读(游标只前进,这只能是读完以后新到的消息),此时清零就是**把真实未读藏起来**。
* 保留旧计数更接近真相:精确条数本地算不出来(没有逐条时间线),等下次 `GET /sessions` 收敛。
*
* 会话没有消息([SessionItem.lastMessageAt] 为 null)时一定没有未读,清零。
*/
internal fun SessionItem.withReadCursor(readAt: Long): SessionItem {
val newest = lastMessageAt
return if (newest != null && newest > readAt) this else copy(unreadCount = 0)
}
/**
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
*
@@ -22,7 +22,7 @@ import top.yeij.cyrene.data.api.ApiEnvelope
* **置顶的次级会话合法地排在主会话前面**(不能假设下标 0 是主会话);
* - 还没有用户消息的会话 `title` 键**整个缺席**(自动命名只在首条用户消息时写入);
* - 没有任何消息的会话 `lastMessage` 键整个缺席;
* - `pinned` 恒下发(主会话上是显式 `false`),`unreadCount` 无关键照收。
* - `pinned` 恒下发(主会话上是显式 `false`)、`unreadCount`(D4)恒下发——两者都必须读进模型。
*/
class SessionDtoRealPayloadTest {
@@ -164,4 +164,69 @@ class SessionDtoRealPayloadTest {
// 服务端对 `{}` 回 400 `title or pinned is required`;调用方必须保证至少带一个字段。
assertEquals("{}", body)
}
// —— D4 未读 / 已读游标(契约 v0.37) ——
@Test
fun `the unread count of the live payload is read off the wire`() {
val sessions = decode(realSessionList)
// 这份线上载荷里两个值都有:已读过的次级会话是显式 0,主会话有 6 条没看。
assertEquals(0, sessions[0].unreadCount)
assertEquals(6, sessions[1].unreadCount)
}
@Test
fun `a session row without the unread key decodes to zero`() {
// 旧快照:本版之前写下的一份(那时 DTO 里还没有这个字段),升级后必须照样解得开——
// 否则第一次启动整份快照解码失败、列表先空一下,正是最不该出现的表现。
val legacy = """{"id":"web","type":"main","createdAt":1788724031196,"pinned":false}"""
assertEquals(0, json.decodeFromString(SessionSummaryDto.serializer(), legacy).unreadCount)
}
@Test
fun `a snapshot round trip keeps the unread count`() {
// `SessionPreferences` 存快照用的就是这份 Json + 这个 ListSerializer。
val before = decode(realSessionList)
val restored = json.decodeFromString(sessionList, json.encodeToString(sessionList, before))
assertEquals(before, restored)
}
@Test
fun `a read request without a timestamp omits the key entirely`() {
val body = json.encodeToString(ReadSessionRequest.serializer(), ReadSessionRequest())
// 「缺省 = 现在」:服务端接受的正是空对象 `{}`;而 `before` 为 0 / 负数 / 非数字一律 400,
// 所以「没有时间戳」只能表达成**整键缺席**,绝不能是 `{"before":null}` 或 `{"before":0}`。
assertEquals("{}", body)
assertFalse(body, body.contains("before"))
}
@Test
fun `a read request with a timestamp sends it as millis`() {
val body = json.encodeToString(
ReadSessionRequest.serializer(),
ReadSessionRequest(before = 1_753_000_000_000L),
)
assertEquals("""{"before":1753000000000}""", body)
}
@Test
fun `the documented read response decodes to the advanced cursor`() {
// 形状取自 api-reference §3.6b 的 curl 示例,与后端 `handleMarkSessionRead` 的
// `writeJSON`(`{"data": …}`)一致。**这是文档示例,不是线上抓取**(不调线上端点)。
val response = json.decodeFromString(
ApiEnvelope.serializer(ReadSessionResponse.serializer()),
"""{"data":{"sessionId":"web","readAt":1753000000000}}""",
)
val data = requireNotNull(response.data)
assertEquals("web", data.sessionId)
assertEquals(1_753_000_000_000L, data.readAt)
}
}
@@ -0,0 +1,113 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* D4 下行帧 `session_read` 的解码(契约 v0.37,api-client §3.9)。
*
* 帧形状不是手写猜的,取自**后端生产者逐字**:
* `internal/services/gateway/session_read.go:broadcastSessionRead` ——
*
* ```go
* h.broadcast(wsEvent{Type: "session_read", SessionID: sessionID, Data: map[string]any{"readAt": readAt}})
* ```
*
* 三个要点都在这里钉死:
* 1. 会话归属**只在帧壳**上(`data` 里没有 sessionId),游标是 `data.readAt`(毫秒整数);
* 2. 它是那 7 个「状态机信号」之一,发给所有已认证连接,因此同样要按 [isForSession] 收敛;
* 3. 坏帧(缺 data)要安全降级:`readAt` 解成 0,而上层把 0 当「无从判断」——正好什么都不改。
*/
class WsEventSessionReadTest {
private fun parse(raw: String): WsEvent =
requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" }
@Test
fun `the produced frame decodes with its session and cursor`() {
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1753000000000}}""")
as WsEvent.SessionRead
assertEquals("session_read", ev.type)
assertEquals("web", ev.sessionId)
assertEquals(1_753_000_000_000L, ev.readAt)
// 状态机信号不带轮次 / 序号:hub 直接广播,不经过回合。
assertNull(ev.turnId)
assertNull(ev.seq)
}
@Test
fun `the frame carries the session of a sub session too`() {
// 读的是哪个会话就广播哪个——不是「总是主会话」。
val ev = parse(
"""{"type":"session_read","sessionId":"web:299bbf0b","data":{"readAt":1791090628688}}""",
) as WsEvent.SessionRead
assertEquals("web:299bbf0b", ev.sessionId)
assertEquals(1_791_090_628_688L, ev.readAt)
}
@Test
fun `a frame without data degrades to a zero cursor instead of failing`() {
// 上层把 readAt = 0 当「无从判断」:有消息的行原样保留计数,绝不会误清成已读。
val ev = parse("""{"type":"session_read","sessionId":"web"}""") as WsEvent.SessionRead
assertEquals(0L, ev.readAt)
}
@Test
fun `a frame whose shell omits the session gets a blank session, never a null`() {
// 没有会话 id 的这一帧无法归属任何一行;上层据此直接跳过(而不是当成「属于当前会话」)。
val ev = parse("""{"type":"session_read","data":{"readAt":1753000000000}}""")
as WsEvent.SessionRead
assertEquals("", ev.sessionId)
}
@Test
fun `an explicit null session on the shell also decodes to a blank session`() {
val ev = parse("""{"type":"session_read","sessionId":null,"data":{"readAt":1}}""")
as WsEvent.SessionRead
assertEquals("", ev.sessionId)
}
@Test
fun `unknown keys inside data are ignored`() {
// 后端将来在 data 里加字段不能被这一层挡掉(`ignoreUnknownKeys`)。
val ev = parse(
"""{"type":"session_read","sessionId":"web","data":{"readAt":42,"unread":0}}""",
) as WsEvent.SessionRead
assertEquals(42L, ev.readAt)
}
@Test
fun `a read frame follows the same per session rule as the other frames`() {
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1}}""")
assertTrue(ev.isForSession("web"))
assertFalse(ev.isForSession("web:other"))
}
@Test
fun `a blank session on a read frame is unknown, so every session accepts it`() {
val ev = parse("""{"type":"session_read","data":{"readAt":1}}""")
// 空 = 「无法判定」而不是「不属于本会话」——与其它帧同一口径。
assertTrue(ev.isForSession("web"))
assertTrue(ev.isForSession("web:other"))
}
@Test
fun `a sub session id on the shell is kept verbatim`() {
// 帧壳上的 sessionId 只是字符串,本层不做格式裁决(`web:<uuid>` / `sess-xxx` 都合法)。
val ev = parse("""{"type":"session_read","sessionId":"sess-9c3","data":{"readAt":7}}""")
as WsEvent.SessionRead
assertEquals("sess-9c3", ev.sessionId)
}
}
@@ -0,0 +1,217 @@
package top.yeij.cyrene.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.data.api.dto.SessionLastMessageDto
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
/**
* D4 未读的两块纯逻辑:角标文本,以及两条**方向相反**的本地调和规则。
*
* 调和规则为什么要分两条:
* - [withReadCursor](`session_read` 帧 / 本地已知游标)是**有依据**的清零——游标只前进,
* 所以「本地最新一条消息不比游标新」就等于没有未读;反过来若本地明明有更新的消息
* (读完以后新到的),清成 0 就是把真实未读藏起来,必须保留旧计数。
* - [markRead] 是**无条件**清零——调用方(`select()` / 正在看的会话落库)已经知道
* 这一行就是屏幕上的那一行,不需要游标依据。
*/
class SessionUnreadTest {
private fun item(
id: String = "web",
lastMessageAt: Long? = null,
unreadCount: Int = 0,
) = SessionItem(
id = id,
title = "标题",
isMain = id == "web",
pinned = false,
lastMessage = if (lastMessageAt == null) null else "预览",
lastMessageAt = lastMessageAt,
unreadCount = unreadCount,
)
// —— 角标文本 ——
@Test
fun `no unread means no badge at all`() {
assertNull(unreadBadgeText(0))
}
@Test
fun `a negative count is a data error, not a badge`() {
// 服务端恒下发非负整数;真出现负数说明数据坏了,绝不画一个「-1」出来。
assertNull(unreadBadgeText(-1))
}
@Test
fun `small counts are shown verbatim`() {
assertEquals("1", unreadBadgeText(1))
assertEquals("9", unreadBadgeText(9))
assertEquals("10", unreadBadgeText(10))
}
@Test
fun `the boundary at 99 is still exact and 100 overflows`() {
assertEquals("99", unreadBadgeText(99))
// 三位数会把 300dp 抽屉里的标题挤没,而精确值几乎没有信息增量。
assertEquals("99+", unreadBadgeText(100))
assertEquals("99+", unreadBadgeText(1234))
}
// —— 按游标清零(有依据) ——
@Test
fun `a cursor newer than the last message clears the badge`() {
val cleared = item(lastMessageAt = 1_000L, unreadCount = 3).withReadCursor(2_000L)
assertEquals(0, cleared.unreadCount)
}
@Test
fun `a cursor exactly on the last message clears the badge`() {
// 服务端口径是 `createdAt <= readAt` 算已读、只数 `createdAt > readAt` 的,所以相等即已读。
val cleared = item(lastMessageAt = 1_000L, unreadCount = 1).withReadCursor(1_000L)
assertEquals(0, cleared.unreadCount)
}
@Test
fun `a message newer than the cursor keeps the badge`() {
// 本地已知有比游标更新的消息 ⇒ 真的还有未读(游标只前进,这只能是读完以后新到的)。
// 精确条数本地算不出来(没有逐条时间线),保留旧计数比清零更接近真相。
val kept = item(lastMessageAt = 3_000L, unreadCount = 2).withReadCursor(1_000L)
assertEquals(2, kept.unreadCount)
}
@Test
fun `a session without any message has nothing to be unread`() {
val cleared = item(lastMessageAt = null, unreadCount = 1).withReadCursor(0L)
assertEquals(0, cleared.unreadCount)
}
@Test
fun `a bad frame with readAt zero changes nothing it cannot judge`() {
// 帧里没有 readAt 时解出来是 0(真实游标不可能是 0):有消息的行**原样保留**。
assertEquals(5, item(lastMessageAt = 10L, unreadCount = 5).withReadCursor(0L).unreadCount)
}
@Test
fun `a zero count stays zero`() {
assertEquals(0, item(lastMessageAt = 10L, unreadCount = 0).withReadCursor(1L).unreadCount)
}
// —— 整表替换时的游标遮罩 ——
@Test
fun `only the rows with a known cursor are masked`() {
val sessions = listOf(
item(id = "web", lastMessageAt = 1_000L, unreadCount = 4),
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 7),
)
val masked = sessions.withReadCursors(mapOf("web" to 2_000L))
assertEquals(0, masked[0].unreadCount)
// 没记过游标的会话保持服务端的值——遮罩只压「本地已知已读」的行。
assertEquals(7, masked[1].unreadCount)
}
@Test
fun `a cursor older than the row keeps the server count`() {
// 服务端说 4 条未读,而本地游标只推到「上一条消息」:这不是矛盾,是真的还有未读。
val masked = listOf(item(id = "web", lastMessageAt = 5_000L, unreadCount = 4))
.withReadCursors(mapOf("web" to 1_000L))
assertEquals(4, masked[0].unreadCount)
}
// —— 按会话定位(帧里带的是**那个**会话的 id,不是当前会话) ——
@Test
fun `a read frame only touches its own row`() {
val sessions = listOf(
item(id = "web", lastMessageAt = 1_000L, unreadCount = 2),
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 5),
)
val updated = sessions.applyReadCursor("web:other", 2_000L)
// 别的端读的是 web:other——这正是本端要处理的一半,不能按 activeSessionId 过滤掉。
assertEquals(2, updated[0].unreadCount)
assertEquals(0, updated[1].unreadCount)
}
@Test
fun `a read frame for an unknown session is a no-op`() {
val sessions = listOf(item(id = "web", lastMessageAt = 1_000L, unreadCount = 2))
assertEquals(sessions, sessions.applyReadCursor("web:gone", 9_999L))
}
// —— 无条件清零(本端刚看到) ——
@Test
fun `marking the active session read clears it regardless of the cursor`() {
// 用户就在这个会话里:即使本地已知有更新的消息,也必须清(那正是他屏幕上那条)。
val sessions = listOf(
item(id = "web", lastMessageAt = 9_999L, unreadCount = 3),
item(id = "web:other", lastMessageAt = 9_999L, unreadCount = 3),
)
val updated = sessions.markRead("web")
assertEquals(0, updated[0].unreadCount)
assertEquals(3, updated[1].unreadCount)
}
// —— DTO → 行模型的映射 ——
@Test
fun `toItem carries the unread count and the last message time`() {
val dto = SessionSummaryDto(
id = "web:1",
type = SessionSummaryDto.TYPE_SUB,
title = "临时小事",
createdAt = 1L,
unreadCount = 6,
lastMessage = SessionLastMessageDto(role = "assistant", content = "在呢", createdAt = 1_791_090_628_688L),
)
val item = dto.toItem()
assertEquals(6, item.unreadCount)
assertEquals(1_791_090_628_688L, item.lastMessageAt)
}
@Test
fun `toItem without a last message has no time and no unread`() {
val dto = SessionSummaryDto(
id = "web:1",
type = SessionSummaryDto.TYPE_SUB,
createdAt = 1L,
)
val item = dto.toItem()
assertNull(item.lastMessageAt)
assertEquals(0, item.unreadCount)
}
@Test
fun `an unread count without a last message still reaches the row`() {
// 理论上不会发生(有未读就一定有消息),但服务端字段是独立的:不能因为没预览就丢掉计数,
// 否则渲染层拿不到它的唯一依据。
val dto = SessionSummaryDto(
id = "web:1",
type = SessionSummaryDto.TYPE_SUB,
createdAt = 1L,
unreadCount = 3,
)
assertEquals(3, dto.toItem().unreadCount)
}
}