6 Commits
Author SHA1 Message Date
AskaEth 664fd8dcef merge: W3 — B1 /upload(大图走上传,历史只留 URL) 2026-10-04 17:44:23 +08:00
AskaEth b44e8d96b9 merge: W2 — A2 interrupt scope(传输层 + 设置项) 2026-10-04 17:44:21 +08:00
AskaEth 51ea890684 merge: W1 — D4 未读(抽屉角标 + 已读游标) 2026-10-04 17:44:21 +08:00
AskaEth f413f8db55 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。
2026-10-04 17:42:31 +08:00
AskaEth cc2f266564 feat(chat): 大图改走 /upload,历史里只留 URL
服务端把 `chat.image` **原样落库**,于是内联的 base64 会在**每一页历史**与**每一次
WS `history` 回放**里重传一遍(协同 G3)。B1 的 `POST /api/v1/upload` 早就有了,客户端
一直没用——本次把它接进发送路径。

- `data/api/UploadApi.kt` / `data/api/dto/UploadDto.kt` / `data/repository/UploadRepository.kt`:
  upload 三件套。multipart 字段名 `file`(Go 只认这个名,且只把带 `filename` 的部件当文件,
  已对着在线后端实测:字段名不对回 `400 {"code":"bad_request","message":"file is required"}`);
  走 `apiCall`(这台端点的状态码不承载额外语义,`413`/`415` 的区分在 `error.code` 里)。
  仓库另拦两种「服务端帮不了」的情况:`url` 为空**或不是绝对 http(s) 地址**就判解析失败
  ——视觉层只把 `http(s)://` 当 URL,裸路径会被当成 base64,照发等于把这一轮扔进黑洞。
- `app/AppContainer.kt`:按 baseUrl 缓存接线,与 chatApi 同源(上传产物 URL 必须交给同一个后端)。
- `domain/chat/ChatImageDelivery.kt`:内联 vs 上传的**唯一判据**(纯函数)。
  阈值为重编码后的 **64 KiB(含等于内联)**:内联的代价是每次历史取数再多 4/3 × B,
  64 KiB 即 +85 KB/次(20 张这种图的会话每次冷启动/重连回放就多传 ≥1.7 MB),而上传只是
  多一次往返(局域网毫秒级)——从第二次取数起就稳赚;≤64 KiB 的缩略图省下的量与一次往返同量级,
  不值得多一个失败面。阈值必须严格小于本端 1 MiB 预算,否则上传分支是死代码(有测试钉住)。
- `ui/chat/ChatViewModel.kt`:图片发送路径分流(小图内联、大图上传换 URL 再发 REST)。
  **上传失败不回退内联**:回退会把「省历史重量」当场作废、还是在用户看不见时换了一条语义不同的路;
  这里按上一步骤给可重试文案,并把图与文本还给输入框(`uploadFailureMessage`,纯函数可单测)。
- `data/image/ChatImagePreparer.kt`:产物改为同时给出 `data:` URI 与**同一份**字节,
  上传因此不必把刚编出来的东西再 base64 解一遍。

阈值依据、以及「URL 形态到底受不受 5 MiB 上限约束」的核查结论写在 `ChatImageDelivery`
与 `UploadRepository` 的注释里(结论:URL 形态**不受**解码后 5 MiB 约束——网关
`checkChatImageSize` 量的是 `image` 字符串长度,URL 才几十字符;URL 到视觉层才被取回,
上限是 `vision.maxFetchedBody` = 50 MiB。所以走上传**不是**为了绕开上限,本端 1 MiB 预算离
两个 5 MiB 上限都还差两个数量级)。

测试:309 全绿(272 → +37):上传响应/错误体的真实形状(错误体是在线抓包原文)、
multipart 线级形状(部件名、filename、字节原样)、内联阈值与两条不变量、上传失败文案。
`assembleDebug` 通过。**未实测**(本端无模拟器、且不给在线后端留测试文件):
真机上的上传成功链路与 Coil 渲染远程 URL——已静态确认 `coil-network-okhttp` 带
`META-INF/services/coil3.util.FetcherServiceLoaderTarget`(默认 ImageLoader 会自动注册网络
fetcher)、`network_security_config` 放行明文(同源 http URL 可取),但没跑过真机。
2026-10-04 17:40:12 +08:00
AskaEth b7806951eb feat(ws): interrupt 支持 scope,并按偏好选「追加队列 / 打断」(协同 A2)
契约 v0.56/A2(api-client §3.1):interrupt{sessionId, scope?},取值域 {"", "all", "current"}——省略 / 空串 / "all" 同义(取消在跑回合 + 清空待处理队列,逐字不变的旧行为),"current" = 只取消在跑回合、保留待处理队列(队列消息按编排器续跑规则继续执行,一条不丢);其余取值服务端定向回 error{bad_request}、不打断任何会话。

- WsOutbound.Interrupt 增可选 scope:null = 整键省略(服务端视同 all),默认帧因此与今天逐字节相同;传输层不校验取值、不补默认、不改写别名(开放取值域由服务端裁决,客户端替它纠正只会换来一次没解释的打断)。
- ChatSocket.sendInterrupt(sessionId, scope = null):把 scope 交给调用方从偏好取值;今天仍无调用方——「打断」触发点在聊天区(属另一轮),本 API 先把传输面备好,KDoc 如实标注。
- 新偏好 InterruptMode(DataStore cyrene_app/interrupt_mode):AppendQueue(默认,省略 scope = all)与 Interrupt(scope:"current");未知/缺省值一律解回默认档,绝不悄悄换成更激进的一档。偏好经 AppPrefs → LocalAppPrefs 下发,聊天区按既有方式读取。
- 设置页新增「她忙时发消息」分组:沿用主题那套 Miuix PreferenceCard + BasicComponent + ✓;两行文案说清唯一差别——忙时那条排队消息还在不在(看不懂效果的偏好比没有更糟)。
- 测试新增 11 条(WsOutboundInterruptTest 6 / InterruptModeTest 5):钉死默认档与旧帧逐字节相同、current 原样透传、"all"/""/未知取值都原样发出、两档不在线上打成同一种形状。工作树 assembleDebug 绿,testDebugUnitTest 283 条 0 失败(基线 272 + 新增 11)。
2026-10-04 17:36:22 +08:00
26 changed files with 1856 additions and 21 deletions

No files matched your search

@@ -11,11 +11,13 @@ import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.repository.UploadRepository
import top.yeij.cyrene.data.ws.ChatSocket
/** 手写 DI 容器(D:不引 Hilt)。 */
@@ -76,6 +78,31 @@ class AppContainer(context: Context) {
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
private var cachedUploadApi: UploadApi? = null
private var cachedUploadBaseUrl: String? = null
/**
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
*/
val uploadApi: UploadApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedUploadApi
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = UploadApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedUploadBaseUrl = baseUrl
cachedUploadApi = created
return created
}
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
private var cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = null
@@ -15,17 +15,42 @@ private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore
enum class ThemeMode { System, Light, Dark }
/**
* 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。
*
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
*/
enum class InterruptMode(val wireScope: String?) {
/**
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
*
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。
*/
AppendQueue(null),
/**
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
*/
Interrupt("current"),
}
/**
* App 级偏好(非机密)。
*
* @param themeMode 主题深浅
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
* @param thinkingExpanded 思维链卡片默认是否展开
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
val showNarration: Boolean = true,
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
@@ -40,6 +65,7 @@ class AppSettings(context: Context) {
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
)
}
@@ -55,15 +81,26 @@ class AppSettings(context: Context) {
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
}
suspend fun setInterruptMode(mode: InterruptMode) {
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
}
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
else -> ThemeMode.System
}
/** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */
private fun String?.toInterruptMode(): InterruptMode = when (this) {
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
else -> InterruptMode.AppendQueue
}
private companion object {
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
}
}
@@ -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>>
}
@@ -0,0 +1,33 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.UploadResponse
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
/**
* 图片上传端点(`POST /api/v1/upload`,api-client §1 行 13)。权限 `owner|admin`。
*
* **上传本身不是目的**:服务端把 `chat.image` 字符串**原样落库**,于是每一页历史与每一次
* WS `history` 回放都要把整段 base64 再传一遍(协同 G3)。上传一次、消息里只留 URL,
* 是这条链路上唯一能把「历史重量」永久压下去的手段。
*
* 与 [ChatApi] 分开:它的请求体是 multipart(不是 JSON)、错误面也完全不同
* (`413 payload_too_large` / `415 unsupported_media_type` 是它的日常回执)。
*
* 返回原始 [Response] 而不是直接折成 `Result`:与其它端点一样由仓库层统一走
* [ApiErrorMapper],状态码在这里**不承载额外语义**(`413`/`415` 的机器可读区分在
* `error.code` 里,所以仓库能用 [apiCall])。
*/
interface UploadApi {
/**
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`):字段名错了会回
* `400 {error:{code:"bad_request",message:"file is required"}}`(已对着在线后端实测)。
*/
@Multipart
@POST("api/v1/upload")
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<UploadResponse>>
}
@@ -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,
)
@@ -0,0 +1,31 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `POST /api/v1/upload` 的成功载荷(api-client §1 行 13,v0.24 起的 B1 端点)。
*
* 线上形状(`internal/services/gateway/upload.go` 的 `uploadResponse`,字段名逐字如此):
* ```
* 201 {"data":{"url":"http://192.168.1.10:10942/api/v1/files/<32hex>.png","size":1234,"mime":"image/png","name":"<32hex>.png"}}
* ```
*
* 语义要点:
* - [url] 是**绝对同源地址**(`scheme://host/api/v1/files/<name>`),**可直接作 `chat.image`**:
* 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 —— 所以这个字段必须按 URL 校验,
* 不能原样塞回去(见 `UploadRepository`);
* - [name] 是服务端生成的 **32 位小写 hex + 白名单扩展名**,客户端原始文件名**被丢弃**(不可信输入);
* - [size] 字节数与 [mime] 都是**服务端嗅探/统计的结果**,不是客户端声明的——客户端传的
* `Content-Type` 一律不被采信(`sniffImageMIME`)。
*
* 全部字段带默认值:解码器是生产同一份 `Json`(`ignoreUnknownKeys` / `coerceInputValues`),
* 将来服务端加键不会让整次上传变成解析失败。**缺 `url`(或 `url` 不是绝对 http(s))仍要判失败**——
* 那是「上传成功但拿不到可用地址」,由仓库层拦,绝不把空串/路径当 URL 发出去。
*/
@Serializable
data class UploadResponse(
val url: String = "",
val size: Long = 0,
val mime: String = "",
val name: String = "",
)
@@ -8,6 +8,19 @@ import android.util.Base64
import top.yeij.cyrene.domain.chat.ChatImageBudget
import java.io.ByteArrayOutputStream
/**
* 一次重编码的产物。
*
* [dataUri] 与 [bytes] 是**同一张图**的两种形态:内联(base64 `data:` URI)用前者,
* 上传(`POST /api/v1/upload`)用后者。上传时**不能**从 [dataUri] 反解一遍——那既多一次
* 解码与分配,也让「上传的字节」和「内联的字节」有漂移的余地。
*/
class PreparedImage(
val dataUri: String,
val bytes: ByteArray,
val mime: String,
)
/**
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
*
@@ -23,6 +36,10 @@ import java.io.ByteArrayOutputStream
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
*
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
* 编完之后的真实字节数才谈得上。
*
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
*/
object ChatImagePreparer {
@@ -33,10 +50,10 @@ object ChatImagePreparer {
private const val MAX_ATTEMPTS = 4
/**
* @return 可直接作为 `image` 字段发送的 `data:` URI;失败返回 `null`。
* @return 可内联发送的 `data:` URI 与可上传的同一份字节;失败返回 `null`。
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
*/
fun prepare(context: Context, uri: Uri): String? {
fun prepare(context: Context, uri: Uri): PreparedImage? {
val raw = readBytes(context, uri) ?: return null
val decoded = decodeScaled(raw) ?: return null
return try {
@@ -80,7 +97,7 @@ object ChatImagePreparer {
}
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
private fun encodeWithinBudget(source: Bitmap): String? {
private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
var width = source.width
var height = source.height
repeat(MAX_ATTEMPTS) { attempt ->
@@ -92,7 +109,8 @@ object ChatImagePreparer {
if (ChatImageBudget.withinTarget(bytes.size)) {
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
return dataUri.takeIf { ChatImageBudget.withinServerLimit(it.length) }
if (!ChatImageBudget.withinServerLimit(dataUri.length)) return null
return PreparedImage(dataUri = dataUri, bytes = bytes, mime = mime)
}
width = (width / 2).coerceAtLeast(1)
height = (height / 2).coerceAtLeast(1)
@@ -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)) }
}
}
@@ -0,0 +1,112 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.api.apiCall
/**
* 一次成功上传的可用形态。
*
* 调用方(`ChatViewModel`)只需要 [url],其余三个字段由服务端生成、留着排查用:
* [size] 是服务端统计的字节数、[mime] 是**服务端嗅探**出的类型(不是客户端声明的)、
* [name] 是服务端存储名(客户端原始文件名已被丢弃)。
*/
data class UploadedImage(
val url: String,
val size: Long,
val mime: String,
val name: String,
)
/**
* 图片上传取数层(`POST /api/v1/upload`)。
*
* ## 为什么这台机器存在
* 服务端把 `chat.image` 字符串**原样落库**(api-client §1 行 3 + 协同 G3):内联的 base64
* 会在**每一页历史**与**每一次 WS `history` 回放**里重传一遍。上传一次、消息里只留一个
* URL,是唯一能让历史不再随图片长大的路。
*
* ## 错误语义(这里是本仓库与只读仓库的差别)
* 用 [apiCall]:它的状态码**不承载额外语义**——`413 payload_too_large` 与
* `415 unsupported_media_type` 的机器可读区分都在 `error.code` 里(已对着在线后端实测,
* 见 `UploadDtoRealPayloadTest`),不像 `POST /api/v1/chat` 那样必须靠状态码分 `202` / `409`。
* 于是网络 / 超时 / `401` / 服务端拒绝一律折进 `Result.failure`,调用方按 `code` 或
* [CyreneError] 的类型分支。
*
* ## 两处**客户端**必须自己把关的地方(服务端帮不了)
* 1. **`url` 必须是绝对 http(s) 地址**:`chat.image` 的视觉层只把 `http(s)://` 当 URL
* (`vision.ParseImageRef` 的判据就是 `strings.HasPrefix(src, "http://")`),裸路径
* (如 `/api/v1/files/x.png`)会被当成 **base64** 载荷。拿到这种 url 还照发,就是把这一轮
* 丢进「图片解析失败」的黑洞——所以这里直接判解析失败,宁可当场重试。
* 2. **`url` 不能为空**:`201` 却没有可用地址 = 上传了但拿不到东西,同样判失败,**绝不**
* 退回内联 base64(那正是本任务要消灭的成本)。
*/
class UploadRepository(
private val api: UploadApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 上传一张**已重编码**的图片,返回可作 `chat.image` 的绝对 URL。
*
* [bytes] 与 [mime] 必须来自同一次编码(`ChatImagePreparer` 的产物):
* 服务端按**字节嗅探**判类型,声明的 `Content-Type` 只作参考,两者不一致时以字节为准。
*/
suspend fun uploadImage(bytes: ByteArray, mime: String): Result<UploadedImage> =
withContext(dispatcher) {
apiCall { api.upload(partOf(bytes, mime)) }.fold(
onSuccess = { data ->
val url = absoluteHttpUrl(data.url)
if (url == null) {
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
} else {
Result.success(
UploadedImage(url = url, size = data.size, mime = data.mime, name = data.name),
)
}
},
onFailure = { Result.failure(it) },
)
}
/**
* multipart 部件。
*
* **部件名固定 `file`**(服务端 `FormFile("file")`)。文件名也**必须给**:Go 的
* `mime/multipart` 只把带 `filename` 参数的部件当**文件**,不带的会落进普通表单值、
* 于是 `FormFile` 找不到它。扩展名与服务端白名单一致(服务端丢弃原始名,这里只是让
* 线上的部件看起来像一张真图,中间件不会因为怪名字做多余判断)。
*/
private fun partOf(bytes: ByteArray, mime: String): MultipartBody.Part =
MultipartBody.Part.createFormData(
name = UPLOAD_FIELD_NAME,
filename = fileNameFor(mime),
body = bytes.toRequestBody(mime.toMediaTypeOrNull()),
)
private fun fileNameFor(mime: String): String = "upload." + when (mime) {
MIME_PNG -> "png"
MIME_GIF -> "gif"
MIME_WEBP -> "webp"
MIME_BMP -> "bmp"
else -> "jpg"
}
/** 只认绝对 http(s) 地址,且**大小写敏感**(与服务端 `ParseImageRef` 的判据同一口径:放宽只会放行服务端不认的东西)。 */
private fun absoluteHttpUrl(raw: String): String? = raw.trim()
.takeIf { it.startsWith("http://") || it.startsWith("https://") }
}
private const val UPLOAD_FIELD_NAME = "file"
private const val MIME_PNG = "image/png"
private const val MIME_GIF = "image/gif"
private const val MIME_WEBP = "image/webp"
private const val MIME_BMP = "image/bmp"
@@ -155,8 +155,18 @@ class ChatSocket(
),
)
/** 发送 `interrupt`(取消在跑 turn + 清空 pending)。 */
fun sendInterrupt(sessionId: String): Boolean = send(WsOutbound.Interrupt(sessionId))
/**
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
*
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
*
* **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好——
* 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。
*/
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
@@ -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)
@@ -56,14 +56,25 @@ sealed interface WsOutbound {
}
}
/** `interrupt`:`{sessionId}`。命中空闲会话为 no-op。 */
/** `interrupt`:`{sessionId, scope?}`。命中空闲会话为 no-op。 */
data class Interrupt(
val sessionId: String,
/**
* 打断范围(契约 v0.56/A2,api-client §3.1):取值域 `{"", "all", "current"}`。
* `null` = **整键省略**——服务端视同 `"all"`(取消在跑回合 + 清空待处理队列),
* 也就是加入该字段之前那条帧**逐字节相同**;`"current"` = 只取消在跑回合、**保留**待处理队列。
* 其它取值服务端定向回 `error{bad_request}`(不打断任何会话)——故本层**原样透传**,
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
*/
val scope: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "interrupt"
override fun data(): JsonElement = buildJsonObject { put("sessionId", sessionId) }
override fun data(): JsonElement = buildJsonObject {
put("sessionId", sessionId)
scope?.let { put("scope", it) }
}
}
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
@@ -0,0 +1,51 @@
package top.yeij.cyrene.domain.chat
/** 一张**已重编码**的图片该怎么送上去。 */
enum class ImageDelivery {
/** 直接把 `data:` URI 塞进 `chat.image`:一次请求,但字节永久留在历史里。 */
Inline,
/** 先 `POST /api/v1/upload` 换 URL,再把 URL 当 `chat.image`:多一次往返,历史里只留几十字节。 */
Upload,
}
/**
* 内联(base64)与上传(URL)的分界(纯函数,VM 与测试共用)。
*
* ## 这是一笔什么账
* 服务端把 `chat.image` 字符串**原样落库**,所以两边的成本不是「发一次」而是「发多少次」:
*
* | | 发出时 | 之后每一次历史取数 |
* |---|---|---|
* | 内联 | 请求体里带 base64(4/3 × B 字符) | **再带一遍**(REST 每页、WS `history` 每次回放、每次上拉) |
* | 上传 | 多**一次** HTTP 往返;字节同样只传一遍 | 只剩那个 URL(几十字节) |
*
* 也就是说:上传不是「省一次流量」,是**用一次往返买断此后所有的历史重量**。
*
* ## 阈值:64 KiB([INLINE_MAX_BYTES],含等于)
* 重编码后的字节数 `B`,内联的代价是每次历史取数再多 `4/3 × B`:
* - `B = 64 KiB` → 每次取数 +85 KB。一个会话里 20 张这种图,每次冷启动 / 重连回放就要多传
* ≥1.7 MB(1 Mbps 下约 14 秒纯图片流量),而那次上传往返在局域网是毫秒、在移动网也就是
* 一两百毫秒——**从第二次取数起就稳赚**。
* - `B ≈ 32 KiB`(缩略图、小截图)→ 每次取数 +43 KB,省下的量与一次往返本身的量级相当,
* 而多出来的一次请求**必然**多一个失败面(上传失败就得让用户重发,内联不会)。
* 这种量级下「一次请求发完」更划算,所以留在内联。
*
* 阈值取整到 64 KiB 而不是更细的数,是因为它本来就是个成本量级的判断,不是服务端限制:
* 服务端的两个 5 MiB 上限(`chat.image` 与 `/upload`)离这里都还差两个数量级,
* **本决策与「装不装得下」无关**(重编码出口已保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES])。
*/
object ChatImageDelivery {
/**
* 内联与上传的分界:**重编码后**的字节数(含等于 → 内联)。
*
* 必须严格小于 [ChatImageBudget.MAX_DECODED_BYTES],否则上传分支永远走不到(死代码);
* 这一条由 `ChatImageDeliveryTest` 钉住。
*/
const val INLINE_MAX_BYTES = 64 * 1024
/** @param preparedBytes [top.yeij.cyrene.data.image.ChatImagePreparer] 产出的字节数。 */
fun forPreparedBytes(preparedBytes: Int): ImageDelivery =
if (preparedBytes <= INLINE_MAX_BYTES) ImageDelivery.Inline else ImageDelivery.Upload
}
@@ -11,10 +11,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.historyForSession
@@ -23,7 +25,9 @@ import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.data.ws.isForSession
import top.yeij.cyrene.domain.chat.ChatImageDelivery
import top.yeij.cyrene.domain.chat.ChatSendRouting
import top.yeij.cyrene.domain.chat.ImageDelivery
import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser
@@ -219,6 +223,9 @@ data class ChatUiState(
*
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
* 图片在 REST 之前还多一层选择:小图内联成 `data:` URI,大图先 `POST /api/v1/upload`
* 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页
* 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。
*
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
@@ -287,7 +294,9 @@ class ChatViewModel(
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
*
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
* 也不把几 MB base64 永久留在每一页历史里。
*/
fun send() {
val snapshot = _state.value
@@ -324,7 +333,30 @@ class ChatViewModel(
if (!sent) rollback(localId, "发送失败,请重试")
}
SendChannel.Rest -> sendViaRest(localId, text, image)
SendChannel.Rest -> if (image == null) {
sendViaRest(localId, text, image = null)
} else {
deliverImage(localId, text, image)
}
}
}
/**
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
*
* **上传失败不回退内联**:回退等于把「省历史重量」这件事当场作废——用户以为发的是 URL、
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
*/
private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) {
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri)
ImageDelivery.Upload -> container.uploadRepository
.uploadImage(image.bytes, image.mime)
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url) }
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
}
}
@@ -667,5 +699,24 @@ class ChatViewModel(
private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试"
/**
* 上传失败的文案(纯函数,可单测)。
*
* 前两条是服务端的**机器可读**拒绝码(对着在线后端实测过,见 `UploadDtoRealPayloadTest`):
* 在本端预算下它们不该出现(重编码出口 ≤ 1 MiB,类型非 png 即 jpeg,都在白名单里),
* 但真出现时正确的动作是「换一张图」而不是「重试」,文案必须把这两者分开。
*
* 其余(网络 / 超时 / 5xx)用既有错误口径,并**点明是上传这一步**失败——否则用户看到
* 「无法连接到服务器」会以为整条消息没发出去,而实际上文字也是跟着一起回滚的。
*/
fun uploadFailureMessage(e: Throwable): String {
val error = (e as? CyreneException)?.error
return when ((error as? CyreneError.Server)?.code) {
"payload_too_large" -> "图片超过服务端 5 MB 上限,请换一张更小的"
"unsupported_media_type" -> "服务端不接受这张图片的格式,请换一张"
else -> "图片上传失败," + (error?.userMessage ?: "请重试")
}
}
/** 思维链在流式结束后继续保留的时长。 */
private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
@@ -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)
}
/**
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
*
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
@@ -102,6 +103,27 @@ fun SettingsScreen(
)
}
SectionTitle("她忙时发消息")
InterruptMode.entries.forEach { mode ->
PreferenceCard {
BasicComponent(
title = mode.label(),
summary = mode.summary(),
onClick = { scope.launch { settings.setInterruptMode(mode) } },
endActions = {
if (prefs.interruptMode == mode) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
)
}
},
)
}
Spacer(Modifier.height(8.dp))
}
SectionTitle("关于")
PreferenceCard {
BasicComponent(
@@ -155,3 +177,19 @@ private fun ThemeMode.label(): String = when (this) {
ThemeMode.Light -> "浅色"
ThemeMode.Dark -> "深色"
}
private fun InterruptMode.label(): String = when (this) {
InterruptMode.AppendQueue -> "追加队列"
InterruptMode.Interrupt -> "打断"
}
/**
* 两档的差别只有一处:**忙时那条排队消息还在不在**。文案必须说清这一点——
* 一个用户看不懂效果的偏好,比没有这个偏好更糟。
*/
private fun InterruptMode.summary(): String = when (this) {
InterruptMode.AppendQueue ->
"她正忙时只把消息排进队列,等她做完当前回复再依次回;不打断(默认)"
InterruptMode.Interrupt ->
"她正忙时先打断当前这一句;已排队的消息保留,继续依次回答"
}
@@ -0,0 +1,58 @@
package top.yeij.cyrene.core.prefs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsOutbound
/**
* A2 偏好 → 线上取值 的映射(契约 v0.56 §3.1)。
*
* 这一层只负责一件事:把用户选的档位翻成 `interrupt` 帧的 `scope`。默认档必须翻成
* **整键省略**——服务端视同 `"all"`,帧因此与加入 `scope` 之前**逐字节相同**(兼容保证)。
*/
class InterruptModeTest {
private fun frameOf(mode: InterruptMode): String =
WsOutbound.Interrupt(sessionId = "web", scope = mode.wireScope).toJson()
@Test
fun `the default mode serialises to today's interrupt frame byte for byte`() {
val mode = AppPrefs().interruptMode
assertEquals(InterruptMode.AppendQueue, mode)
assertNull("默认档必须是「省略 scope」(服务端 = all)", mode.wireScope)
assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(mode))
}
@Test
fun `the interrupt mode maps to scope current so the queue survives`() {
assertEquals("current", InterruptMode.Interrupt.wireScope)
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
frameOf(InterruptMode.Interrupt),
)
}
@Test
fun `the preference only ever produces the two documented wire values`() {
// 服务端取值域是 {"", "all", "current"};客户端只发「省略」与 "current" 两种,
// ""/"all" 已由省略表达——不自己补一串等价别名,少一种线上形状就少一类歧义。
assertEquals(listOf(null, "current"), InterruptMode.entries.map { it.wireScope })
assertTrue(InterruptMode.entries.none { it.wireScope == "all" })
assertTrue(InterruptMode.entries.none { it.wireScope == "" })
}
@Test
fun `both modes stay distinguishable on the wire`() {
// 打成同一个形状就等于「两种模式被当成一种」——正是 A2 要修掉的那类缺陷。
assertTrue(frameOf(InterruptMode.AppendQueue) != frameOf(InterruptMode.Interrupt))
}
@Test
fun `the persisted enum names are stable`() {
// AppSettings 按 enum 名落盘:改名 = 把所有已存偏好静默重置回默认档。
assertEquals(listOf("AppendQueue", "Interrupt"), InterruptMode.entries.map { it.name })
}
}
@@ -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,143 @@
package top.yeij.cyrene.data.api.dto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
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.ApiEnvelope
import top.yeij.cyrene.data.api.ApiErrorMapper
/**
* 契约钉死测试:`POST /api/v1/upload` 的**成功载荷**用的是服务端
* `internal/services/gateway/upload.go` 的 `uploadResponse` 结构体形状(字段名逐字:
* `url` / `size` / `mime` / `name`),**错误载荷**用的是 2026-10-04 对着在线后端
* (`http://127.0.0.1:10942`)**实测抓下来**的原文——只发错误请求(缺部件 / 非图片 / 超限),
* 服务端一律在落盘之前拒绝,所以这些抓包**没有在服务端留下任何文件**。
*
* 解码器用 [ApiClientFactory.json]——与生产同一份 `Json`,这里过不了的形状线上也过不了。
*/
class UploadDtoRealPayloadTest {
private val json = ApiClientFactory.json
/** 成功载荷:32 位小写 hex 名 + 白名单扩展名 + 同源绝对 URL,全是服务端生成的。 */
private val realSuccess = """
{"data":{"url":"http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg","size":184320,"mime":"image/jpeg","name":"9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg"}}
""".trimIndent()
private fun decode(body: String): UploadResponse =
json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), body).data!!
@Test
fun `the real success payload decodes into a directly usable url`() {
val data = decode(realSuccess)
assertEquals(
"http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg",
data.url,
)
assertEquals(184320L, data.size)
assertEquals("image/jpeg", data.mime)
assertEquals("9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg", data.name)
}
@Test
fun `the url is absolute same-origin because chat image only accepts http s urls`() {
val url = decode(realSuccess).url
// 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 载荷 —— 所以这个形状是硬要求。
assertTrue(url, url.startsWith("http://") || url.startsWith("https://"))
assertTrue(url, url.contains("/api/v1/files/"))
}
@Test
fun `the name is 32 lowercase hex plus a whitelisted raster extension`() {
val name = decode(realSuccess).name
val stem = name.substringBeforeLast('.')
assertEquals(32, stem.length)
assertEquals(stem, stem.lowercase())
assertTrue(stem, stem.all { it in "0123456789abcdef" })
// 白名单只有光栅:SVG / HTML 会被拒(`uploadExtForMIME`)。
assertTrue(name, name.endsWith(".jpg"))
}
// —— 以下四条是**在线抓包原文**(逐字照抄) ——
@Test
fun `the captured 415 body maps to unsupported_media_type`() {
val body = """{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}"""
val error = ApiErrorMapper.fromHttp(415, body)
assertEquals(CyreneError.Server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415), error)
// 客户端按 code 分支(改文案不改判定),所以这两个字符串必须钉住。
assertEquals("unsupported_media_type", (error as CyreneError.Server).code)
}
@Test
fun `the captured 413 body maps to payload_too_large`() {
val body = """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}"""
val error = ApiErrorMapper.fromHttp(413, body) as CyreneError.Server
assertEquals("payload_too_large", error.code)
assertEquals(413, error.httpStatus)
}
@Test
fun `a wrong part name is a 400 that names the field`() {
// 实测:部件名不是 `file`(例如 `image`)→ 这条。它是「字段名写错」的唯一征兆。
val body = """{"error":{"code":"bad_request","message":"file is required"}}"""
val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server
assertEquals("bad_request", error.code)
assertEquals("file is required", error.message)
}
@Test
fun `a body that is not multipart at all is a 400 with a different message`() {
val body = """{"error":{"code":"bad_request","message":"invalid multipart body"}}"""
val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server
assertEquals("bad_request", error.code)
// 与「部件名错了」同码不同文案:排障时靠 message 区分,靠 code 分支。
assertEquals("invalid multipart body", error.message)
}
@Test
fun `the captured 401 is unauthorized and not a server rejection`() {
val error = ApiErrorMapper.fromHttp(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
assertEquals(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"), error)
}
// —— 形状的松紧 ——
@Test
fun `a success envelope without data is not an upload`() {
assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), "{}").data)
assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), """{"data":null}""").data)
}
@Test
fun `unknown extra keys are ignored so a future field cannot break decoding`() {
val data = decode("""{"data":{"url":"http://h/api/v1/files/aa.jpg","size":1,"mime":"image/jpeg","name":"aa.jpg","sha256":"deadbeef"}}""")
assertEquals("http://h/api/v1/files/aa.jpg", data.url)
}
@Test
fun `explicit nulls fall back to the defaults instead of failing the decode`() {
val data = decode("""{"data":{"url":null,"size":null,"mime":null,"name":null}}""")
assertEquals("", data.url)
assertEquals(0L, data.size)
assertEquals("", data.mime)
assertEquals("", data.name)
}
}
@@ -0,0 +1,227 @@
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.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.UploadApi
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 上传端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端(不引新依赖,
* 与 [ChatRepositoryTest] 同一手法)。
*
* 这里要证明的不是「能解析一个 JSON」,而是三件线上真会出事的事:
* 1. 请求**真的是 multipart**、部件名真的是 `file`、载荷字节**一个不改**地发出去
* (服务端 `r.FormFile("file")` 认不到就回 `400 file is required`);
* 2. `413 payload_too_large` / `415 unsupported_media_type` 按 `code` 分成可辨的两类;
* 3. 服务端给的 url **不是绝对 http(s) 就判失败**——`chat.image` 会把裸路径当成 base64,
* 照发等于把这一轮扔进解析错误里。
*/
class UploadRepositoryTest {
private val backend = FakeUploadBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = UploadRepository(
ApiClientFactory.create(baseUrl = backend.baseUrl, service = UploadApi::class.java),
)
/** 一段带 JPEG 魔数的假图:足够让「字节原样发出」这件事可断言。 */
private val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) +
"B1-UPLOAD-PAYLOAD".toByteArray()
@Test
fun `a successful upload returns the server built url`() = runBlocking {
backend.reply(201, backend.successBody())
val uploaded = repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
assertEquals(backend.url, uploaded.url)
// size / mime / name 是**服务端**的数,客户端只传递、不自己算一遍。
assertEquals(jpeg.size.toLong(), uploaded.size)
assertEquals("image/jpeg", uploaded.mime)
assertEquals(backend.name, uploaded.name)
}
@Test
fun `the request really is multipart with the file part name the server looks for`() = runBlocking {
backend.reply(201, backend.successBody())
repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
val request = backend.requests.single()
assertEquals("POST", request.method)
assertEquals("/api/v1/upload", request.path)
assertTrue(request.contentType, request.contentType.startsWith("multipart/form-data"))
// 部件名 / 文件名都要在:Go 只把带 filename 的部件当文件,字段名不对则 FormFile 找不到。
assertTrue(request.body, request.body.contains("""name="file""""))
assertTrue(request.body, request.body.contains("""filename="upload.jpg""""))
assertTrue(request.body, request.body.contains("Content-Type: image/jpeg"))
}
@Test
fun `the bytes leave untouched, jpeg magic included`() = runBlocking {
backend.reply(201, backend.successBody())
repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
// ISO-8859-1 一一对应字节:这里的断言就是「发出去的就是编出来的那些字节」。
val body = backend.requests.single().body
assertTrue(body, body.contains("\u00FF\u00D8\u00FF\u00E0"))
assertTrue(body, body.contains("B1-UPLOAD-PAYLOAD"))
}
@Test
fun `a png is sent as a png part`() = runBlocking {
backend.reply(201, backend.successBody(mime = "image/png"))
repository().uploadImage(byteArrayOf(0x89.toByte(), 0x50), "image/png").getOrThrow()
val body = backend.requests.single().body
assertTrue(body, body.contains("Content-Type: image/png"))
assertTrue(body, body.contains("""filename="upload.png""""))
}
@Test
fun `413 payload_too_large is surfaced with its code`() = runBlocking {
backend.reply(413, """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals(
CyreneError.Server("payload_too_large", "file exceeds the 5MB limit", 413),
error.error,
)
}
@Test
fun `415 unsupported_media_type is surfaced with its code`() = runBlocking {
backend.reply(
415,
"""{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}""",
)
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals("unsupported_media_type", (error.error as CyreneError.Server).code)
}
@Test
fun `401 stays a failure because the session itself is no longer valid`() = runBlocking {
backend.reply(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertTrue("实际 ${error.error}", error.error is CyreneError.Unauthorized)
}
@Test
fun `a relative url is a parse failure not something we would send as image`() = runBlocking {
// `chat.image` 只把 http(s):// 当 URL:裸路径会被当成 base64 载荷。
backend.reply(201, """{"data":{"url":"/api/v1/files/aa.jpg","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `a blank url is a parse failure instead of a silent empty image`() = runBlocking {
backend.reply(201, """{"data":{"url":"","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `a 201 without data is a parse failure`() = runBlocking {
backend.reply(201, "{}")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `an unreachable server is a local failure`() = runBlocking {
backend.close()
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
}
/** 记录每次请求的原始字节与头,按 `reply(...)` 回话的假后端。 */
private inner class FakeUploadBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/api/v1/upload", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
/** 服务端生成的存储名:32 位小写 hex + 白名单扩展名。 */
val name = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg"
val url: String get() = baseUrl + "api/v1/files/" + name
val requests = CopyOnWriteArrayList<RecordedRequest>()
@Volatile
private var status: Int = 201
@Volatile
private var body: String = ""
fun reply(status: Int, body: String) {
this.status = status
this.body = body
}
/** 线上的成功形状(`uploadResponse`):`size` 报的就是本次真正发出去的那段字节数。 */
fun successBody(mime: String = "image/jpeg"): String =
"""{"data":{"url":"$url","size":${jpeg.size},"mime":"$mime","name":"$name"}}"""
private fun handle(exchange: HttpExchange) {
// ISO-8859-1:字节 ↔ 字符一一对应,断言里比对的就是线上真实字节。
val raw = exchange.requestBody.readBytes()
requests += RecordedRequest(
method = exchange.requestMethod,
path = exchange.requestURI.path,
contentType = exchange.requestHeaders.getFirst("Content-Type").orEmpty(),
body = raw.toString(Charsets.ISO_8859_1),
)
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() {
runCatching { server.stop(0) }
}
}
private data class RecordedRequest(
val method: String,
val path: String,
val contentType: String,
val body: String,
)
}
@@ -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,75 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/**
* A2:`interrupt` 的可选 `scope`(契约 v0.56 §3.1,取值域 `{"", "all", "current"}`)。
*
* 两条要点:
* - **省略 `scope` 的帧必须与今天逐字节相同**——这是与老服务端 / 老行为的兼容保证;
* - 本层**不校验**取值(服务端开放取值域由服务端裁决):除了省略,其余一律原样透传,
* 既不自作主张补默认、也不静默改写成别名——服务端对非法值是**定向回 `bad_request`**。
*/
class WsOutboundInterruptTest {
/** 加入 `scope` 之前那条帧的逐字形状。 */
private val legacyFrame = """{"type":"interrupt","data":{"sessionId":"web"}}"""
@Test
fun `an interrupt without a scope is today's frame byte for byte`() {
assertEquals(legacyFrame, WsOutbound.Interrupt(sessionId = "web").toJson())
assertEquals(legacyFrame, WsOutbound.Interrupt(sessionId = "web", scope = null).toJson())
}
@Test
fun `omitting the scope omits the key itself rather than sending null`() {
val json = WsOutbound.Interrupt(sessionId = "web").toJson()
// `"scope":null` 与「整键省略」在服务端不是一回事:后者才是「未声明」。
assertFalse("省略时不该出现 scope 键:$json", json.contains("scope"))
}
@Test
fun `scope current is carried verbatim next to the session`() {
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
WsOutbound.Interrupt(sessionId = "web", scope = "current").toJson(),
)
}
@Test
fun `scope all and an empty scope are transmitted as-is`() {
// 三者(省略 / "" / "all")在服务端同义,但**同义不等于客户端该改写**:
// 调用方给了什么就发什么,服务端才是解释者。
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","scope":"all"}}""",
WsOutbound.Interrupt(sessionId = "web", scope = "all").toJson(),
)
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","scope":""}}""",
WsOutbound.Interrupt(sessionId = "web", scope = "").toJson(),
)
}
@Test
fun `an unknown scope is sent verbatim and never silently downgraded to the default`() {
// 服务端大小写敏感:`"Current"` 是非法值,会定向回 bad_request 且**不打断任何会话**。
// 客户端若在此处「帮它纠正」成 "current",用户看到的就是一次没解释的打断。
val json = WsOutbound.Interrupt(sessionId = "web", scope = "Current").toJson()
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","scope":"Current"}}""",
json,
)
}
@Test
fun `the session id stays first so older servers see the same key order`() {
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
WsOutbound.Interrupt("web", "current").toJson(),
)
}
}
@@ -0,0 +1,81 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 内联 vs 上传的阈值(纯函数)。
*
* 判据是**重编码后的字节数**:它同时决定「这次多走一次往返值不值」与「历史会背多重的 base64」。
* 阈值本身不是服务端限制——服务端的两个 5 MiB 上限离这里还有两个数量级,所以下面的不变量
* 才是重点:**上传分支必须够得着(不是死代码)、内联分支必须永远送得出去**。
*/
class ChatImageDeliveryTest {
@Test
fun `small images stay inline`() {
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(0))
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(1))
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(32 * 1024))
}
@Test
fun `the threshold is inclusive on the inline side`() {
assertEquals(
ImageDelivery.Inline,
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES),
)
assertEquals(
ImageDelivery.Upload,
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES + 1),
)
}
@Test
fun `the threshold is 64 KiB`() {
// 一个成本量级上的取整值:每次历史取数多 4/3 × B,64 KiB 时约 +85 KB/次(见下一条)。
assertEquals(64 * 1024, ChatImageDelivery.INLINE_MAX_BYTES)
}
@Test
fun `at the threshold one history fetch pays at least the number the docs claim`() {
// 类注释里「+85 KB」这个数的出处:base64 膨胀 4/3 后是 87384 字符。
assertEquals(87384, ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES))
assertTrue(ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES) > 85 * 1000)
}
@Test
fun `the upload branch is reachable but not mandatory for everything`() {
// 阈值得低于重编码出口的上限,否则「上传」是死代码;但也不能低到把一切都推去上传。
assertTrue(
"阈值得小于本端预算 ${ChatImageBudget.MAX_DECODED_BYTES},否则上传分支永远走不到",
ChatImageDelivery.INLINE_MAX_BYTES < ChatImageBudget.MAX_DECODED_BYTES,
)
assertTrue(
"阈值也不能小到把缩略图也推去上传",
ChatImageDelivery.INLINE_MAX_BYTES * 4 <= ChatImageBudget.MAX_DECODED_BYTES,
)
}
@Test
fun `the inline branch always fits the server cap by construction`() {
// 内联的字节上限就是本端预算:它的 base64 + 前缀余量必须仍在服务端字符上限内。
val worstChars = ChatImageBudget.base64Chars(ChatImageBudget.MAX_DECODED_BYTES) +
ChatImageBudget.PREFIX_ALLOWANCE_CHARS
assertTrue(ChatImageBudget.withinServerLimit(worstChars))
assertTrue(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES))
}
@Test
fun `the choice is about history weight, not about fitting under a cap`() {
// 重编码出口比服务端硬顶低 5 倍:所以「走上传」从来不是为了装得下,
// 而是为了让每一页历史 / 每一次 WS history 回放不再重传这段 base64。
assertTrue(ChatImageBudget.MAX_DECODED_BYTES * 5 <= ChatImageBudget.SERVER_MAX_DECODED_BYTES)
assertEquals(
ImageDelivery.Upload,
ChatImageDelivery.forPreparedBytes(ChatImageBudget.MAX_DECODED_BYTES),
)
}
}
@@ -0,0 +1,89 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 上传失败的文案(纯函数)。
*
* 这里锚的是**行为承诺**,不是措辞本身:上传失败时这条消息**没有发出去**(文本与图一起
* 回到输入框),而且**绝不**退回内联 base64——退回就等于把「省下历史重量」当场作废,
* 还是在用户看不见的情况下换了一条语义不同的路。
*
* 两个服务端拒绝码对应的是「换一张图」而不是「重试」,必须与网络失败分开说。
*/
class ImageUploadFailureTest {
private fun server(code: String, message: String, status: Int = 400) =
CyreneException(CyreneError.Server(code, message, status))
@Test
fun `payload_too_large asks for a smaller image, not a retry`() {
val message = uploadFailureMessage(server("payload_too_large", "file exceeds the 5MB limit", 413))
assertEquals("图片超过服务端 5 MB 上限,请换一张更小的", message)
}
@Test
fun `unsupported_media_type says the format is refused`() {
val message = uploadFailureMessage(
server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415),
)
assertEquals("服务端不接受这张图片的格式,请换一张", message)
}
@Test
fun `a network failure names the step that failed and keeps the reason`() {
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
assertEquals("图片上传失败,网络不可用,请检查连接", message)
}
@Test
fun `a timeout keeps its own wording`() {
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))
assertEquals("图片上传失败,连接超时,服务器没有响应", message)
}
@Test
fun `a server error keeps the server message instead of inventing one`() {
val message = uploadFailureMessage(server("upload_failed", "failed to store upload", 500))
assertEquals("图片上传失败,failed to store upload", message)
}
@Test
fun `an expired session is still an upload failure, never a silent success`() {
val message = uploadFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")))
assertTrue(message, message.startsWith("图片上传失败"))
}
@Test
fun `a non Cyrene throwable still produces an actionable message`() {
assertEquals("图片上传失败,请重试", uploadFailureMessage(IllegalStateException("boom")))
assertEquals("图片上传失败,请重试", uploadFailureMessage(RuntimeException()))
}
@Test
fun `no failure path ever falls back to inlining the image`() {
// 文案里绝不能出现「改用内联 / 直接发送」这类暗示:回退内联正是要消灭的成本。
val messages = listOf(
uploadFailureMessage(server("payload_too_large", "x", 413)),
uploadFailureMessage(server("unsupported_media_type", "x", 415)),
uploadFailureMessage(server("upload_failed", "x", 500)),
uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.ConnectFailed))),
uploadFailureMessage(IllegalStateException()),
)
assertTrue(
messages.toString(),
messages.all { it.contains("换一张") || it.contains("重试") || it.contains("上传失败") },
)
}
}
@@ -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)
}
}