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)