feat(chat): 加 REST 发送路径 —— 长文与图片(含 clientMsgId 幂等与归一)

客户端此前**只有 WS** 发送,而 WS 帧硬上限 4096 字节、**超限是直接断连**(不是报个错),
所以长文发不出去、图完全发不了。v0.56 给 REST 流式补齐了明确响应面与 clientMsgId,
本次照它接入。

- data/api:ChatApi + ChatDto(窄请求体,不回环整条会话)+ ChatRepository
  —— **刻意不走 `apiCall`**:它把 4xx/5xx 一律折进 `Result.failure` 并丢掉状态码,
  那样 `202`(已排队)与 `409`(队满)的差别会在那一刻消失;改为映射成 ChatAck 密封结果
  (Accepted / Queued / QueueFull / Duplicate / Unavailable / Rejected)
- domain/chat/ChatSendRouting:纯函数路由 —— **UTF-8 字节数 ≤ 3584 且无图 → WS,否则 REST**。
  阈值按**字节**不按字符(中文 1 字 3 字节);预留 512 字节覆盖帧壳。短文本仍走 WS 是刻意的:
  那条路**绕开有损总线**,而超 WS 上限的代价是**断连**而非报错,两边风险不对称
- domain/chat/ChatImageBudget + data/image/ChatImagePreparer:解码 → 等比缩放 → 重编码为
  JPEG/PNG data URI;超预算自动降尺寸重来;**失败返回 null,绝不退回原图 base64**
- clientMsgId:每次发送 `"local-" + UUID`(**随机不自增**——服务端判重窗口是**整个会话历史
  且无过期清理**,进程重启后复用旧值会让那条消息永远发不出去);REST 与 WS 两路都带
- 归一:带 clientMsgId 的消息**只按 id 匹配**(文本不同也命中;匹配不到就什么都不吃,
  **绝不回落到文本**——那会吃掉一条无关气泡);不带键的老行才回落「第一条同文本」
- ack 分支:200 已受理(等 message_persisted)/ 202 已排队(出「已排队 N 条」)/
  **409 既未受理也未落库 → 回滚气泡并把文本与图片还回输入框**(不能当「已排队」,
  那会让气泡永远挂着「发送中…」)/ duplicate 不是错误(收气泡;本地没有该 messageId 就补拉一页)/
  500、503 回滚 / 400 image_too_large 换短文案
- 接下行帧 `chat_duplicate`;图片一律走 REST(`WsOutbound.Chat.image` 仍无生产者)

测试:269 全绿(218 → +51)。含真实抓包的四段响应(200 / 202 / duplicate / 400 image_too_large,
且服务端文案里的 6990572 与本端 SERVER_MAX_CHARS 做成对断言)、路由阈值边界
(1194 汉字 = 3582 字节走 WS、1195 = 3585 走 REST,并直接断言阈值处真实
`WsOutbound.Chat(...)` 序列化后 ≤ 4096 字节)、归一规则、六种 ack 映射。

真机(真后端 127.0.0.1:10942):**图片全链路通过** —— 选图 → 重编码(源 7.8 KB PNG →
1 KB JPEG,magic ffd8ffe0)→ `POST /api/v1/chat`(1558 字节体)→
`200 {"data":{"stream":true}}` → 气泡由「发送中…」归一为「来自 安卓」→ 昔涟回复**准确描述了那张图**。
另 WS 短文本路径未回归(无 POST,且 clientMsgId 被 v0.56 原样回填)。

未验证:`409 chat_busy` 与 `500` 非真实抓包(要填满主人后端的待处理队列、等于真跑 5 个回合,
判断不值得;形状与文案逐字取自后端源码,测试注释里已写明);UI 上的 202/409 未端到端跑过;
降尺寸「减半重来」那步未在设备上跑过(测试图 64×64 只走到重编码)。
This commit is contained in:
AskaEth committed 2026-10-04 16:57:22 +08:00
1 parent 4c14028c70
commit e642b0c18c
19 files changed
+1615 -35

No files matched your search

@@ -8,9 +8,11 @@ import top.yeij.cyrene.core.prefs.EnumPreferences
import top.yeij.cyrene.core.prefs.SessionPreferences import top.yeij.cyrene.core.prefs.SessionPreferences
import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory 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.MetaApi
import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.repository.AdminRepository 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.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.SessionRepository import top.yeij.cyrene.data.repository.SessionRepository
@@ -19,6 +21,9 @@ import top.yeij.cyrene.data.ws.ChatSocket
/** 手写 DI 容器(D:不引 Hilt)。 */ /** 手写 DI 容器(D:不引 Hilt)。 */
class AppContainer(context: Context) { class AppContainer(context: Context) {
/** 应用上下文:给需要在 ViewModel 里读本地资源(图片解码等)的组件用。 */
val appContext: Context = context.applicationContext
val sessionStore: SessionStore = SessionStore(context.applicationContext) val sessionStore: SessionStore = SessionStore(context.applicationContext)
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext) val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext) val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
@@ -49,6 +54,28 @@ class AppContainer(context: Context) {
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi) val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
private var cachedChatApi: ChatApi? = null
private var cachedChatBaseUrl: String? = null
/** 发送端点(`POST /api/v1/chat`);与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val chatApi: ChatApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedChatApi
if (cached != null && cachedChatBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = ChatApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedChatBaseUrl = baseUrl
cachedChatApi = created
return created
}
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
private var cachedAdminApi: AdminApi? = null private var cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = null private var cachedAdminBaseUrl: String? = null
@@ -0,0 +1,23 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.ChatAckDto
import top.yeij.cyrene.data.api.dto.ChatRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
/**
* 发送一条消息(`POST /api/v1/chat`,api-client §1 行 3)。权限 `owner|admin`。
*
* 与会话只读的 [SessionsApi]、管理面 [AdminApi] 分开:这是**写**路径,且它的失败面比只读端点
* 复杂得多(`202` / `409` / `500` / `503` 都是编排器的真实回执,不是「请求错了」),
* 放在一起会让只读端点的调用方以为错误处理是同一种。
*
* 返回原始 [Response](而不是直接返回信封):状态码本身是语义的一部分——
* `202` 排队 与 `409` 排队满 必须能被调用方分开,折进 `Result` 前就先丢了。
*/
interface ChatApi {
@POST("api/v1/chat")
suspend fun sendChat(@Body body: ChatRequest): Response<ApiEnvelope<ChatAckDto>>
}
@@ -0,0 +1,59 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `POST /api/v1/chat` 请求体(契约 v0.56,api-client §1 行 3)。
*
* 刻意只带这一个端点要的字段——**不复用整条会话对象**:请求体越小,「WS 帧 4096 字节上限」
* 的反面(REST 请求体没有长度上限)就越不会被无关字段吃掉,抓包时也一眼能看出这一轮发了什么。
*
* 字段语义:
* - [text] / [image] **至少一项非空**,两项都空服务端回 `400 bad_request`;
* - [image] 是 base64 `data:` URI 或 http(s) URL,**解码后上限 5 MiB**
* (超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);
* - [clientMsgId] 是客户端自带的幂等键:同一会话内**同一个非空值只承认第一条**,
* 重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且不落库、不起回合;
* - [stream] 必须为 `true`(本端**不用** `stream:false`:它是同步的,服务端没有写超时,
* 客户端 60s 读超时会先超时、而回合还在跑——重发就是第二次对话)。
*
* ⚠️ 本项目 `ApiClientFactory.json` 用 kotlinx 默认的 `encodeDefaults = false`:
* 取值等于默认值的字段**整键省略**。因此**纯图片消息**(`text == ""`)线上不会带 `text` 键——
* 服务端 Go 侧零值同为空串,语义完全等价(已按此实现,见 [ChatDtoRealPayloadTest])。
*/
@Serializable
data class ChatRequest(
val sessionId: String? = null,
val text: String = "",
val image: String? = null,
val deviceId: String? = null,
val mode: String? = null,
val stream: Boolean? = null,
val clientMsgId: String? = null,
)
/**
* `POST /api/v1/chat` 的**受理回执**(`data` 载荷)。v0.56 起响应面不再是「任何情况都 200」,
* 四种判定各有形状,因此这里**全部字段可空**、按组合判读,绝不假设某字段恒在:
*
* | 判定 | HTTP | 载荷 |
* |---|---|---|
* | 已起回合 | 200 | `{stream:true}` |
* | 正忙已续接排队 | 202 | `{stream:true,queued:true,queueDepth:n}` |
* | 幂等键重复 | 200 | `{duplicate:true,messageId,sessionId}` |
*
* 其余三种(`409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed`)是
* `{error:{code,message}}` 信封,由 [top.yeij.cyrene.data.repository.ChatRepository] 走错误路径分类。
*
* 不开枚举:服务端还有别的判定会在将来加进来,未知组合一律按「已受理但形状不认识」处理,
* 不因为不认识一个布尔而把整轮当失败。
*/
@Serializable
data class ChatAckDto(
val stream: Boolean? = null,
val queued: Boolean? = null,
val queueDepth: Int? = null,
val duplicate: Boolean? = null,
val messageId: String? = null,
val sessionId: String? = null,
)
@@ -20,5 +20,12 @@ data class HistoryMessageDto(
val deviceId: String? = null, val deviceId: String? = null,
val senderName: String? = null, val senderName: String? = null,
val adapterId: String? = null, val adapterId: String? = null,
/**
* 客户端幂等键(契约 v0.56 §3.13,`omitempty`):带 `clientMsgId` 发出的 user 消息会被
* 服务端**原样回填**,`GET /messages` / WS `history` / `message_persisted` 都能看到。
* 客户端据此把本地乐观气泡对齐到服务端真相——取代「靠文本相等」的脆弱匹配。
* 空 = 这条消息没有键(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)。
*/
val clientMsgId: String? = null,
val createdAt: Long, val createdAt: Long,
) )
@@ -0,0 +1,115 @@
package top.yeij.cyrene.data.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import top.yeij.cyrene.domain.chat.ChatImageBudget
import java.io.ByteArrayOutputStream
/**
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
*
* 为什么要重编码而不是直接读原始字节:服务端把 `image` 字符串**原样落库**,
* 每一页历史与每一次 WS `history` 回放都要重传它(协同文档 G3)。所以这里
* 无论用户选了什么,出口都保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES]:
*
* 1. 先按边界解码拿原尺寸 → 等比缩到最长边 [ChatImageBudget.MAX_DIMENSION_PX];
* 2. 编码(有 alpha 用 PNG,否则用 JPEG);
* 3. 仍超预算 → **尺寸减半重来**(最多 [MAX_ATTEMPTS] 档);
* 4. 出口再按服务端的**字符串长度**上限复核一次(4/3 膨胀 + 前缀余量)。
*
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
*
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
*/
object ChatImagePreparer {
private const val JPEG_QUALITY = 80
/** 缩不动就减半重来的次数上限(1 MiB 预算下 1280px 起步一般 1–2 次就够)。 */
private const val MAX_ATTEMPTS = 4
/**
* @return 可直接作为 `image` 字段发送的 `data:` URI;失败返回 `null`。
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
*/
fun prepare(context: Context, uri: Uri): String? {
val raw = readBytes(context, uri) ?: return null
val decoded = decodeScaled(raw) ?: return null
return try {
encodeWithinBudget(decoded)
} finally {
decoded.recycle()
}
}
private fun readBytes(context: Context, uri: Uri): ByteArray? =
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
/** 按**目标尺寸**两步解码:先读边界算 `inSampleSize`(省内存),再精确缩放到目标。 */
private fun decodeScaled(raw: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(raw, 0, raw.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val target = ChatImageBudget.scaledSize(bounds.outWidth, bounds.outHeight)
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, target.width, target.height)
}
val sampled = runCatching { BitmapFactory.decodeByteArray(raw, 0, raw.size, options) }.getOrNull()
?: return null
if (sampled.width == target.width && sampled.height == target.height) return sampled
val scaled = runCatching {
Bitmap.createScaledBitmap(sampled, target.width, target.height, true)
}.getOrNull() ?: return null
if (scaled !== sampled) sampled.recycle()
return scaled
}
/** 在不小于目标尺寸的前提下,取最大的 2 的幂作为采样步长。 */
private fun sampleSizeFor(width: Int, height: Int, targetWidth: Int, targetHeight: Int): Int {
var sample = 1
while (width / (sample * 2) >= targetWidth && height / (sample * 2) >= targetHeight) {
sample *= 2
}
return sample
}
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
private fun encodeWithinBudget(source: Bitmap): String? {
var width = source.width
var height = source.height
repeat(MAX_ATTEMPTS) { attempt ->
val frame = if (attempt == 0) source else scale(source, width, height) ?: return null
// 有 alpha 的图走 JPEG 会把透明区压成黑块,所以这类图保持 PNG(无损、体积更大)。
val mime = if (frame.hasAlpha()) MIME_PNG else MIME_JPEG
val bytes = compress(frame, mime)
if (frame !== source) frame.recycle()
if (ChatImageBudget.withinTarget(bytes.size)) {
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
return dataUri.takeIf { ChatImageBudget.withinServerLimit(it.length) }
}
width = (width / 2).coerceAtLeast(1)
height = (height / 2).coerceAtLeast(1)
}
return null
}
private fun scale(source: Bitmap, width: Int, height: Int): Bitmap? =
runCatching { Bitmap.createScaledBitmap(source, width, height, true) }.getOrNull()
private fun compress(bitmap: Bitmap, mime: String): ByteArray {
val format = if (mime == MIME_PNG) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
val out = ByteArrayOutputStream()
bitmap.compress(format, JPEG_QUALITY, out)
return out.toByteArray()
}
private const val MIME_JPEG = "image/jpeg"
private const val MIME_PNG = "image/png"
}
@@ -0,0 +1,102 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatAckDto
import top.yeij.cyrene.data.api.dto.ChatRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* `POST /api/v1/chat` 的受理判定(契约 v0.56)。
*
* 它是一个**密封结果**而不是 `Result.failure`:v0.56 起「会话正忙」与「排队已满」不再是异常,
* 而是编排器的正常回执(修复前 handler 提前 `return 200`,这两种状态**永远不可能出现**)。
* 把它们和网络错误混在同一条失败流里,调用方就只能靠字符串猜——这正是本类型要消除的东西。
*/
sealed interface ChatAck {
/** `200 {stream:true}`:回合已起,内容帧走**已连接的 WS**。 */
data object Accepted : ChatAck
/** `202 {stream:true,queued:true,queueDepth:n}`:会话正忙,这条已续接排队(**已落库**)。 */
data class Queued(val queueDepth: Int) : ChatAck
/** `200 {duplicate:true,messageId,sessionId}`:幂等键重复,服务端**没有**再落库 / 再起回合。 */
data class Duplicate(val messageId: String?, val sessionId: String?) : ChatAck
/** `409 {error:{code:"chat_busy"}}`:待处理队列已满,这条**没有被受理、没有落库**。 */
data object QueueFull : ChatAck
/**
* `500 chat_unavailable` / `503 chat_dispatch_failed`:服务端接不住这一轮。
* [code] 保留原始错误码——两者的区别对排查有用,对用户文案无差别。
*/
data class Unavailable(val code: String, val message: String) : ChatAck
/** 其余明确的拒绝(`400 bad_request` / `400 image_too_large` / 未知码)。 */
data class Rejected(val code: String, val message: String) : ChatAck
}
/**
* 发送一条消息(REST 通道)。
*
* 与别的仓库不同的一点:**非 2xx 里有一半是业务态而不是失败**(见 [ChatAck]),
* 所以这里刻意**不使用** [top.yeij.cyrene.data.api.apiCall]——它把 4xx/5xx 一律折进
* `Result.failure`、且只回 `data` 丢掉状态码,于是 `202` 与 `409` 的差别在折进去的那一刻
* 就没了。信封解析与错误映射仍走 [ApiErrorMapper],口径与其它端点一致。
*
* `Result.failure` 只留给**真正发不出去**的情况:网络 / 超时 / 解析不出来 / 401。
*/
class ChatRepository(
private val api: ChatApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun send(request: ChatRequest): Result<ChatAck> = withContext(dispatcher) {
try {
val response = api.sendChat(request)
val data = response.body()?.data
when {
response.isSuccessful && data != null -> Result.success(ackOf(data))
// 2xx 却没有 data:与 apiCall 同一口径,判解析失败而不是「已受理」。
response.isSuccessful -> Result.failure(parseFailure())
else -> {
val error = ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())
val ack = serviceAck(error)
if (ack != null) Result.success(ack) else Result.failure(CyreneException(error))
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
private fun ackOf(data: ChatAckDto): ChatAck = when {
data.duplicate == true -> ChatAck.Duplicate(data.messageId, data.sessionId)
data.queued == true -> ChatAck.Queued(data.queueDepth ?: 0)
// `{stream:true}` 以及将来新增的未知组合:受理了、形状不认识 → 按已受理处理。
else -> ChatAck.Accepted
}
/** 非 2xx 里属于**编排器真实回执**的部分;返回 null = 真失败(401 或未知服务端错误)。 */
private fun serviceAck(error: CyreneError): ChatAck? {
if (error is CyreneError.Unauthorized) return null
val code = (error as? CyreneError.Server)?.code.orEmpty()
val message = error.userMessage
return when (code) {
"chat_busy" -> ChatAck.QueueFull
"chat_unavailable", "chat_dispatch_failed" -> ChatAck.Unavailable(code, message)
else -> ChatAck.Rejected(code, message)
}
}
private fun parseFailure() =
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse))
}
@@ -138,10 +138,22 @@ class ChatSocket(
/** /**
* 发送 `chat`(api-reference §6.3)。 * 发送 `chat`(api-reference §6.3)。
* @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。 * @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
*/ */
fun sendChat(sessionId: String?, text: String, deviceId: String?): Boolean = fun sendChat(
send(WsOutbound.Chat(text = text, sessionId = sessionId, deviceId = deviceId)) sessionId: String?,
text: String,
deviceId: String?,
clientMsgId: String? = null,
): Boolean = send(
WsOutbound.Chat(
text = text,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
),
)
/** 发送 `interrupt`(取消在跑 turn + 清空 pending)。 */ /** 发送 `interrupt`(取消在跑 turn + 清空 pending)。 */
fun sendInterrupt(sessionId: String): Boolean = send(WsOutbound.Interrupt(sessionId)) fun sendInterrupt(sessionId: String): Boolean = send(WsOutbound.Interrupt(sessionId))
@@ -288,6 +288,27 @@ sealed class WsEvent {
override val type: String get() = "pending_full" override val type: String get() = "pending_full"
} }
/**
* `clientMsgId` 重复到达的明确回执(契约 v0.56 §3.13,纯新增帧)。
*
* 语义 = **这条消息早已被受理过**:服务端**不落库、不起回合、不发任何内容帧**
* (它由编排器的状态通道下发,与 `session_busy` 同一出口,因此帧壳带 `sessionId`、
* 不带 `turnId` / `seq`)。[messageId] 是**既有那条用户消息的 id**,
* 客户端据此把本地乐观气泡对齐到服务端真相。
*
* 广播是发给**所有** owner/admin 连接的(hub 无按会话订阅),没发过该
* [clientMsgId] 的客户端按值自行忽略即可。
*/
data class ChatDuplicate(
override val sessionId: String,
val clientMsgId: String,
val messageId: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "chat_duplicate"
}
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */ /** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
data class MessagePersisted( data class MessagePersisted(
val message: HistoryMessageDto?, val message: HistoryMessageDto?,
@@ -427,6 +448,17 @@ sealed class WsEvent {
.let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) } .let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) }
"pending_full" -> decode<PendingFullData>(data) "pending_full" -> decode<PendingFullData>(data)
.let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) } .let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) }
// 幂等回执(v0.56 §3.13):帧壳带 sessionId,data 内也带一份(两者取有值者)。
"chat_duplicate" -> decode<ChatDuplicateData>(data)
.let {
ChatDuplicate(
resolveSessionId(sessionId, it.sessionId),
it.clientMsgId,
it.messageId,
turnId,
seq,
)
}
"message_persisted" -> decode<PersistedData>(data) "message_persisted" -> decode<PersistedData>(data)
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) } .let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"channel_persisted" -> decode<PersistedData>(data) "channel_persisted" -> decode<PersistedData>(data)
@@ -527,6 +559,14 @@ private data class InterruptedData(val sessionId: String = "", val mode: String
@Serializable @Serializable
private data class PendingFullData(val sessionId: String = "", val mode: String = "", val max: Int = 0) private data class PendingFullData(val sessionId: String = "", val mode: String = "", val max: Int = 0)
/** `chat_duplicate` 载荷(v0.56 §3.13):`{sessionId, clientMsgId, messageId}`。 */
@Serializable
private data class ChatDuplicateData(
val sessionId: String = "",
val clientMsgId: String = "",
val messageId: String? = null,
)
@Serializable @Serializable
private data class PersistedData(val message: HistoryMessageDto? = null) private data class PersistedData(val message: HistoryMessageDto? = null)
@@ -32,12 +32,17 @@ sealed interface WsOutbound {
data()?.let { put("data", it) } data()?.let { put("data", it) }
}.toString() }.toString()
/** `chat`:`{sessionId?, text, image?, deviceId?}`;text+image 至少一项。 */ /** `chat`:`{sessionId?, text, image?, deviceId?, clientMsgId?}`;text+image 至少一项。 */
data class Chat( data class Chat(
val text: String, val text: String,
val sessionId: String? = null, val sessionId: String? = null,
val deviceId: String? = null, val deviceId: String? = null,
val image: String? = null, val image: String? = null,
/**
* 客户端幂等键(契约 v0.56 §3.13):同一会话内同一个非空值只承认第一条,
* 重复到达服务端回下行帧 `chat_duplicate`、**不落库、不起回合**。省略 = 老行为逐字不变。
*/
val clientMsgId: String? = null,
override val reqId: String? = null, override val reqId: String? = null,
) : WsOutbound { ) : WsOutbound {
override val type: String get() = "chat" override val type: String get() = "chat"
@@ -47,6 +52,7 @@ sealed interface WsOutbound {
put("text", text) put("text", text)
image?.let { put("image", it) } image?.let { put("image", it) }
deviceId?.let { put("deviceId", it) } deviceId?.let { put("deviceId", it) }
clientMsgId?.let { put("clientMsgId", it) }
} }
} }
@@ -0,0 +1,72 @@
package top.yeij.cyrene.domain.chat
/** 等比缩放后的目标尺寸(px)。 */
data class ScaledSize(val width: Int, val height: Int)
/**
* 发送前图片的**体积预算**与尺寸规划(纯函数,编码器与测试共用;不含任何 Android 依赖)。
*
* ## 为什么必须缩放 / 重编码
* 服务端把 `image` 字符串**原样落库**(api-client §1 行 3 + 协同文档 G3),于是
* `GET /sessions/{id}/messages` 的**每一页**、WS 握手 `history` 的**每一次回放**
* 都要把整段 base64 再传一遍。所以「发一张 8 MB 的原图」= 以后每次翻历史都重传 11 MB。
* 客户端预算是这个成本的唯一闸门,**不是优化**。
*
* 两层上限:
* - [MAX_DECODED_BYTES] = 1 MiB:**本端的**目标(缩放 + 重编码后必须 ≤ 它)——
* 服务端 5 MiB 只管「收不收」,管不了「历史有多重」;
* - [SERVER_MAX_DECODED_BYTES] = 5 MiB:服务端硬顶(解码后),超限 `400 image_too_large`。
* 它只作为**最后一道兜底检查**:真要走上传(`POST /api/v1/upload`)时再放宽。
*
* 校验按**字符串长度**([SERVER_MAX_CHARS])而不是解码字节数——与服务端入口层同一口径
* (`chatImageCharsFor`),这样两端不可能漂移。
*/
object ChatImageBudget {
/** 服务端 `image` 解码后上限:5 MiB(与 `POST /api/v1/upload` 同口径)。 */
const val SERVER_MAX_DECODED_BYTES = 5 * 1024 * 1024
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
const val PREFIX_ALLOWANCE_CHARS = 64
/** 服务端 `image` 字符串长度上限(= base64 膨胀 4/3 + 前缀余量)。 */
const val SERVER_MAX_CHARS =
((SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + PREFIX_ALLOWANCE_CHARS
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
/**
* 最长边的目标像素。1280 与 provider 自己入模型前的缩放口径(约 1300×1300)同量级,
* 所以再放大不会有任何可见收益——省的是库与历史的字节,不是 token。
*/
const val MAX_DIMENSION_PX = 1280
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
/** 编码后字节数是否已落到本端目标内。 */
fun withinTarget(byteCount: Int): Boolean = byteCount <= MAX_DECODED_BYTES
/**
* 等比缩放到最长边不超过 [maxDimension];**只缩不放**(小图保持原尺寸),
* 边长至少 1 px(极端细长图缩放后可能算成 0)。
*/
fun scaledSize(width: Int, height: Int, maxDimension: Int = MAX_DIMENSION_PX): ScaledSize {
val w = width.coerceAtLeast(1)
val h = height.coerceAtLeast(1)
val longest = maxOf(w, h)
if (longest <= maxDimension) return ScaledSize(w, h)
val ratio = maxDimension.toDouble() / longest
return ScaledSize(
width = (w * ratio).toInt().coerceAtLeast(1),
height = (h * ratio).toInt().coerceAtLeast(1),
)
}
/** 拼 `data:` URI([mime] 形如 `image/jpeg`,[base64] 为单行 base64)。 */
fun dataUri(mime: String, base64: String): String = "data:$mime;base64,$base64"
}
@@ -0,0 +1,55 @@
package top.yeij.cyrene.domain.chat
/** 一条消息该走哪条上行通道。 */
enum class SendChannel {
/** 短文本走 WS `chat` 帧:直达、绕过有损总线,且今天就能用。 */
Ws,
/** 长文本 / 任何图片走 REST `POST /api/v1/chat`(`stream:true`)。 */
Rest,
}
/**
* 上行路由规则(纯函数,VM 与测试共用)。
*
* ## 为什么必须有这条规则
* WS 帧的读上限是**服务端** `wsReadLimit = 4096` 字节/帧,且**超限由传输层直接断连**——
* 不是回一个错误,而是把这条连接踢掉(api-client §1 行 3 G3 的诚实边界)。所以
* 「长文发不出去」在今天不是「服务端拒绝」,是**发送帧把连接打断**。REST 没有这个上限,
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
*
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
*
* ## 阈值
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
* 333 个汉字时就撞上 4096)。上限取 [WS_TEXT_LIMIT_BYTES] = 4096 − 512 字节预留:
* 预留覆盖 `{"type","data":{"sessionId","text","deviceId","clientMsgId","image"}}` 的
* JSON 外壳(键名 + 值,UTF-8 最坏情况约 150 字节),并留出余量。留得保守是因为
* **超限的代价是断连**,而多走一次 REST 的代价只是几十毫秒——风险不对称,宁可早退。
*
* 边界:`utf8Length(text) <= WS_TEXT_LIMIT_BYTES` → WS;多 1 字节即 REST。
*/
object ChatSendRouting {
/** 服务端 WS 读上限(`wsReadLimit`,契约 v0.56 明确记载)。 */
const val WS_FRAME_LIMIT_BYTES = 4096
/** WS `chat` 帧 JSON 外壳的预留:键名 + `sessionId` / `deviceId` / `clientMsgId` 等值的字节数。 */
const val WS_ENVELOPE_RESERVE_BYTES = 512
/** 允许走 WS 的**文本**字节上限(不含图片:有图片一律走 REST)。 */
const val WS_TEXT_LIMIT_BYTES = WS_FRAME_LIMIT_BYTES - WS_ENVELOPE_RESERVE_BYTES
/** 文本的 UTF-8 字节数;`text` 为空返回 0。 */
fun utf8Length(text: String): Int = text.toByteArray(Charsets.UTF_8).size
/**
* 这一条该走哪条通道。
* @param hasImage 本次是否带图片(带图**永远**走 REST)
*/
fun channelFor(text: String, hasImage: Boolean): SendChannel = when {
hasImage -> SendChannel.Rest
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
else -> SendChannel.Rest
}
}
@@ -1,5 +1,8 @@
package top.yeij.cyrene.ui.chat package top.yeij.cyrene.ui.chat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -12,10 +15,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -28,11 +33,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.data.ws.WsState
@@ -44,7 +51,9 @@ import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.basic.Sidebar import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */ /** 判定「贴着底部」的容差(px)。 */
@@ -67,6 +76,12 @@ fun ChatScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
// 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。 // 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。
// 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。 // 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。
var followBottom by remember { mutableStateOf(true) } var followBottom by remember { mutableStateOf(true) }
@@ -115,9 +130,16 @@ fun ChatScreen(
bottomBar = { bottomBar = {
InputBar( InputBar(
value = state.draft, value = state.draft,
imageUri = state.draftImage,
busyText = busyLabel(state), busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange, onValueChange = viewModel::onDraftChange,
onSend = viewModel::send, onSend = viewModel::send,
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearImage = viewModel::clearDraftImage,
) )
}, },
) { padding -> ) { padding ->
@@ -181,9 +203,12 @@ fun ChatScreen(
@Composable @Composable
private fun InputBar( private fun InputBar(
value: String, value: String,
imageUri: String?,
busyText: String, busyText: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
onPickImage: () -> Unit,
onClearImage: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -191,7 +216,19 @@ private fun InputBar(
.imePadding() .imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) { ) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
TextField( TextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
@@ -205,7 +242,8 @@ private fun InputBar(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Button( Button(
onClick = onSend, onClick = onSend,
enabled = value.isNotBlank(), // 图片可以单独发出(契约:text / image 至少一项非空)。
enabled = value.isNotBlank() || imageUri != null,
) { ) {
Text("发送") Text("发送")
} }
@@ -227,6 +265,37 @@ private fun InputBar(
} }
} }
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
@Composable
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
AsyncImage(
model = uri,
contentDescription = "待发送的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
IconButton(
onClick = onClear,
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
@Composable @Composable
private fun CenterHint(text: String) { private fun CenterHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -247,9 +316,17 @@ private fun connectionLabel(state: ChatUiState): String = when (state.connection
WsState.Idle -> "" WsState.Idle -> ""
} }
/** 输入栏下方的忙碌提示。 */ /**
* 输入栏下方的忙碌提示。
*
* 三个来源共用这一行:WS 的 `session_busy`([ChatUiState.busyMode])、WS 的
* `pending_ack`/`pending_drain` 与 REST `202 {queued:true,queueDepth}`([ChatUiState.queueDepth])。
* REST 的 202 只给得到条数、给不到忙碌通道,所以队列深度**单独也能成句**——否则 202 之后到
* `session_busy` 之前会有一小段什么都看不见的空档。
*/
private fun busyLabel(state: ChatUiState): String = when { private fun busyLabel(state: ChatUiState): String = when {
state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条" state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条"
state.busyMode != null -> "正在思考…" state.busyMode != null -> "正在思考…"
state.queueDepth > 0 -> "已排队 ${state.queueDepth} 条"
else -> "" else -> ""
} }
@@ -1,15 +1,21 @@
package top.yeij.cyrene.ui.chat package top.yeij.cyrene.ui.chat
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.CyreneException 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.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.historyForSession import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages import top.yeij.cyrene.data.repository.mergeMessages
@@ -17,16 +23,58 @@ import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.data.ws.isForSession import top.yeij.cyrene.data.ws.isForSession
import top.yeij.cyrene.domain.chat.ChatSendRouting
import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser import top.yeij.cyrene.domain.parse.StreamingContentParser
import java.util.UUID
const val MAIN_SESSION_ID = "web" const val MAIN_SESSION_ID = "web"
/** 本端标识(design/16 §4.5 允许的可读前缀形态)。 */ /** 本端标识(design/16 §4.5 允许的可读前缀形态)。 */
private const val DEVICE_ID = "voiceassist:cyrene:v1" private const val DEVICE_ID = "voiceassist:cyrene:v1"
/** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */ /**
data class PendingMessage(val localId: String, val text: String) * 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。
*
* [localId] 同时是**上行幂等键** `clientMsgId`(契约 v0.56 §3.13):同一会话内服务端只承认
* 第一个非空值,重复到达回 `chat_duplicate`(WS)或 `{duplicate:true,messageId}`(REST)而
* **不再落库、不再起回合**。所以手动重试可以**复用**同一个 id,弱网重发不会变成第二条消息。
* 它必须足够唯一:服务端的判重窗口是**整个会话历史**(键没有过期清理),进程重启后复用
* 旧值会被当成重复、消息永远发不出去——因此用随机值而不是自增序号。
*/
data class PendingMessage(
val localId: String,
val text: String,
/** 本次带的图片的**本地**预览(`content://` URI);null = 纯文本消息。 */
val imageUri: String? = null,
)
/**
* 落库的 user 消息到达时,该从待发列表里去掉哪一个乐观气泡(纯函数;返回新列表)。
*
* **首选 `clientMsgId`**(契约 v0.56 §3.13:带键发出的消息服务端会原样回填):键相等即同一条
* 消息,**文本不同**也绝不会认错(发送时被 trim、服务端将来会再加工)。这是 v0.56 专门为
* 「乐观气泡归一」加的通道,用来取代此前唯一的「同文本」匹配。
*
* 落库消息**没有键**时(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)才回落到既有规则:
* 去掉**第一条同文本**的气泡——服务端若是旧版本,永远只走这条路,行为与加入 REST 之前一致。
*
* 命中**恰好一条**且不修改入参:同一帧被重放、或两条同文本消息分别落库时,都不会一次吃掉两个气泡。
*/
fun consumePendingFor(
pending: List<PendingMessage>,
message: HistoryMessageDto,
): List<PendingMessage> {
val key = message.clientMsgId?.takeIf { it.isNotBlank() }
val index = if (key != null) {
pending.indexOfFirst { it.localId == key }
} else {
pending.indexOfFirst { it.text == message.content }
}
if (index < 0) return pending
return pending.filterIndexed { i, _ -> i != index }
}
/** /**
* 工具执行的细粒度阶段,与后端流程图 tool 节点 status **同一套枚举**(契约 v0.50 §3.1 / §3.5)。 * 工具执行的细粒度阶段,与后端流程图 tool 节点 status **同一套枚举**(契约 v0.50 §3.1 / §3.5)。
@@ -136,6 +184,8 @@ data class ChatUiState(
val pending: List<PendingMessage> = emptyList(), val pending: List<PendingMessage> = emptyList(),
val streaming: StreamingState? = null, val streaming: StreamingState? = null,
val draft: String = "", val draft: String = "",
/** 已选好、还没发出去的图片(本地 `content://` URI 字符串);null = 没有待发图片。 */
val draftImage: String? = null,
val loading: Boolean = true, val loading: Boolean = true,
val loadingOlder: Boolean = false, val loadingOlder: Boolean = false,
val hasMore: Boolean = true, val hasMore: Boolean = true,
@@ -143,6 +193,10 @@ data class ChatUiState(
val connection: WsState = WsState.Idle, val connection: WsState = WsState.Idle,
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */ /** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null, val busyMode: String? = null,
/**
* 待处理队列深度。两个来源:WS 的 `pending_ack` / `pending_drain`,
* 以及 REST `202 {queued:true,queueDepth:n}` 的受理回执(同一个队列,同一份语义)。
*/
val queueDepth: Int = 0, val queueDepth: Int = 0,
) { ) {
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */ /** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
@@ -160,9 +214,12 @@ data class ChatUiState(
* 数据来源四路,全部按 `id` 去重收敛: * 数据来源四路,全部按 `id` 去重收敛:
* 1. REST 首屏 / 上拉分页(权威,带游标) * 1. REST 首屏 / 上拉分页(权威,带游标)
* 2. WS 首帧 `history` * 2. WS 首帧 `history`
* 3. WS 增量 `message_persisted`(含乐观消息归一) * 3. WS 增量 `message_persisted`(含乐观消息归一:按 `clientMsgId` 首选、同文本兜底)
* 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代) * 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代)
* *
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
*
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅, * 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。 * 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
*/ */
@@ -178,7 +235,6 @@ class ChatViewModel(
private var repository: MessageRepository? = null private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null private var oldestLoadedAt: Long? = null
private var streamParser: StreamingContentParser? = null private var streamParser: StreamingContentParser? = null
private var pendingSeq = 0
init { init {
start() start()
@@ -217,29 +273,165 @@ class ChatViewModel(
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) } fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
/** 发送:先乐观上屏,WS 上行失败则回滚并把文本还给输入框。 */ /** 选好待发图片(本地 `content://` URI);传 null 或空白串即清除。 */
fun send() { fun onImagePicked(uri: String?) =
val text = _state.value.draft.trim() _state.update { it.copy(draftImage = uri?.takeIf { value -> value.isNotBlank() }) }
if (text.isEmpty()) return
val localId = "local-${pendingSeq++}" fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
/**
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片还给输入框。
*
* 路由 [ChatSendRouting]:**短文本且无图 → WS**(直达、绕过有损总线,今天就能用);
* **长文本或任何图片 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上流式
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
*
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。
*/
fun send() {
val snapshot = _state.value
val text = snapshot.draft.trim()
val imageUri = snapshot.draftImage
if (text.isEmpty() && imageUri == null) return
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
val localId = "local-${UUID.randomUUID()}"
_state.update { _state.update {
it.copy( it.copy(
pending = it.pending + PendingMessage(localId, text), pending = it.pending + PendingMessage(localId, text, imageUri),
draft = "", draft = "",
draftImage = null,
error = null, error = null,
) )
} }
viewModelScope.launch { deliver(localId, text, imageUri) }
}
val sent = socket?.sendChat(sessionId, text, DEVICE_ID) ?: false private suspend fun deliver(localId: String, text: String, imageUri: String?) {
if (!sent) { val image = imageUri?.let { uri ->
_state.update { withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
it.copy( }
pending = it.pending.filterNot { p -> p.localId == localId }, // 图片处理失败就**不发**:绝不退回原图 base64(会撞服务端 5 MiB 上限、也会把历史背成几 MB)。
draft = text, if (imageUri != null && image == null) {
error = "发送失败,请重试", rollback(localId, "图片处理失败,请换一张")
) return
}
when (ChatSendRouting.channelFor(text, hasImage = image != null)) {
SendChannel.Ws -> {
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
if (!sent) rollback(localId, "发送失败,请重试")
} }
SendChannel.Rest -> sendViaRest(localId, text, image)
}
}
private suspend fun sendViaRest(localId: String, text: String, image: String?) {
val request = ChatRequest(
sessionId = sessionId,
text = text,
image = image,
deviceId = DEVICE_ID,
// 必须 true:`stream:false` 是同步的,服务端**没有写超时**而客户端读超时 60s——
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
stream = true,
clientMsgId = localId,
)
container.chatRepository
.send(request)
.onSuccess { ack -> onAck(localId, ack) }
.onFailure { e -> rollback(localId, e.userMessage()) }
}
/** REST 受理回执的六个分支(契约 v0.56 的响应面;形状见 [ChatAck])。 */
private fun onAck(localId: String, ack: ChatAck) {
when (ack) {
// 200 {stream:true}:回合已起,气泡等 message_persisted 归一。
ChatAck.Accepted -> Unit
// 202 {queued:true}:已**落库并续接排队**。轻量态交给既有忙碌标签(busyLabel 认
// queueDepth);队列清空时 WS 的 `pending_drain` / `session_idle` 会把它归零。
is ChatAck.Queued -> _state.update { it.copy(queueDepth = ack.queueDepth) }
// 幂等重复:服务端没有重复落库、也没有起第二个回合——对齐既有消息,**不是错误**。
is ChatAck.Duplicate -> alignDuplicate(localId, ack.messageId)
// 409 队列满:这条**没有被受理、没有落库**(服务端在入队判定处直接拒绝),
// 所以不能当成「已排队」——回滚气泡并把文本还回输入框,用的是既有忙碌文案的口径。
ChatAck.QueueFull -> rollback(localId, "会话正忙,待处理消息已满,请稍后再发")
is ChatAck.Unavailable -> rollback(localId, "服务端暂时接不住这一轮,请稍后重试")
is ChatAck.Rejected -> rollback(localId, rejectMessage(ack))
}
}
private fun rejectMessage(rejected: ChatAck.Rejected): String = when (rejected.code) {
"image_too_large" -> "图片过大,请换一张更小的"
else -> rejected.message.ifBlank { "发送失败,请重试" }
}
/**
* 幂等重复的对齐(REST `{duplicate:true}` 与 WS `chat_duplicate` 共用):
* 去掉那条乐观气泡;本地若**没有**这条消息,再补拉最新一页。
*
* 复用同一个 `clientMsgId` 重试时,正常路径本端早就收到过 `message_persisted`(第一次发的
* 时候),但弱网 / 重连丢帧会留下「气泡没了、消息也没了」的空档——用 `messageId` 补一次
* 增量拉取才是真的「对齐到服务端真相」,而不是让气泡凭空消失。
*/
private fun alignDuplicate(clientMsgId: String, messageId: String?) {
_state.update { state ->
state.copy(pending = state.pending.filterNot { it.localId == clientMsgId })
}
val known = messageId != null && _state.value.messages.any { it.id == messageId }
if (messageId != null && !known) refreshLatest()
}
/** 静默补拉最新一页(成功才并进来;失败不改状态——这只是一次对齐尝试)。 */
private fun refreshLatest() {
val repo = repository ?: return
viewModelScope.launch {
repo.loadLatest(sessionId).onSuccess { page ->
page.oldestCreatedAt?.let { oldest -> oldestLoadedAt = minOf(oldestLoadedAt ?: oldest, oldest) }
_state.update {
it.copy(
messages = mergeMessages(it.messages, page.messages),
pending = reconcilePending(it.pending, page.messages),
)
}
}
}
}
/**
* 一批**服务端权威消息**到达时,把它们对应的乐观气泡一并收掉(按 `clientMsgId`)。
*
* 通路除了 `message_persisted`,还有两条同样会带来「这条早就落库了」的路径:
* WS 握手 `history` 回放(重连 / 冷启动)与 REST 补拉。少了这一步,
* 「发出去 → 丢帧 → 重连回放」会留下**乐观气泡与落库消息同屏重复**。
*/
private fun reconcilePending(
pending: List<PendingMessage>,
incoming: List<HistoryMessageDto>,
): List<PendingMessage> = incoming
.filter { it.role == "user" }
.fold(pending) { acc, message -> consumePendingFor(acc, message) }
/**
* 回滚乐观气泡:把文本 / 图片还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
* 空文本(纯图片)不覆盖用户当前正在输入的内容;图片照原样还给待发位,不必让用户重挑。
*/
private fun rollback(localId: String, reason: String) {
_state.update { state ->
val failed = state.pending.firstOrNull { it.localId == localId }
state.copy(
pending = state.pending.filterNot { it.localId == localId },
draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
draftImage = failed?.imageUri ?: state.draftImage,
error = reason,
)
} }
} }
@@ -261,8 +453,12 @@ class ChatViewModel(
} else { } else {
val oldest = incoming.minOf { it.createdAt } val oldest = incoming.minOf { it.createdAt }
oldestLoadedAt = oldestLoadedAt?.let { minOf(it, oldest) } ?: oldest oldestLoadedAt = oldestLoadedAt?.let { minOf(it, oldest) } ?: oldest
_state.update { _state.update { state ->
it.copy(messages = mergeMessages(it.messages, incoming), loading = false) state.copy(
messages = mergeMessages(state.messages, incoming),
pending = reconcilePending(state.pending, incoming),
loading = false,
)
} }
} }
} }
@@ -347,6 +543,12 @@ class ChatViewModel(
_state.update { it.copy(queueDepth = event.queueDepth) } _state.update { it.copy(queueDepth = event.queueDepth) }
} }
// 幂等重复(契约 v0.56 §3.13):这条消息**早已受理过**,本端重发(弱网 / 切后台)撞上了
// 服务端的部分唯一索引。它不是错误——按既有 messageId 对齐气泡即可,绝不弹报错。
is WsEvent.ChatDuplicate -> if (event.sessionId == sessionId) {
alignDuplicate(event.clientMsgId, event.messageId)
}
is WsEvent.SessionIdle -> if (event.sessionId == sessionId) { is WsEvent.SessionIdle -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = null, queueDepth = 0) } _state.update { it.copy(busyMode = null, queueDepth = 0) }
} }
@@ -387,7 +589,8 @@ class ChatViewModel(
/** /**
* 落库消息到达: * 落库消息到达:
* - user:按「同文本」把对应的乐观气泡去掉(协议没有 clientMsgId,只能这样匹配) * - user:把对应的乐观气泡去掉——**首选 `clientMsgId`**(v0.56 起服务端回填),
* 没有键时才回落到「同文本」匹配(见 [consumePendingFor])
* - assistant:用落库内容取代流式临时气泡 * - assistant:用落库内容取代流式临时气泡
*/ */
private fun onPersisted(message: HistoryMessageDto?) { private fun onPersisted(message: HistoryMessageDto?) {
@@ -405,15 +608,9 @@ class ChatViewModel(
if (clearStreaming) streamParser = null if (clearStreaming) streamParser = null
_state.update { state -> _state.update { state ->
val pending = if (msg.role == "user") {
val index = state.pending.indexOfFirst { it.text == msg.content }
if (index >= 0) state.pending.filterIndexed { i, _ -> i != index } else state.pending
} else {
state.pending
}
state.copy( state.copy(
messages = mergeMessages(state.messages, listOf(persisted)), messages = mergeMessages(state.messages, listOf(persisted)),
pending = pending, pending = if (msg.role == "user") consumePendingFor(state.pending, msg) else state.pending,
streaming = if (clearStreaming) null else state.streaming, streaming = if (clearStreaming) null else state.streaming,
) )
} }
@@ -62,11 +62,17 @@ fun MessageBubble(message: HistoryMessageDto) {
} }
} }
/** 乐观发送中的用户消息(还没落库)。 */ /**
* 乐观发送中的用户消息(还没落库)。
*
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
*/
@Composable @Composable
fun PendingBubble(item: PendingMessage) { fun PendingBubble(item: PendingMessage) {
MessageGroup(isUser = true, footer = "发送中…") { MessageGroup(isUser = true, footer = "发送中…") {
Bubble(plainBubble(item.text), isUser = true, isLast = true) item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
} }
} }
@@ -0,0 +1,180 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
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/chat`,v0.56,2026-10-04),
* 逐字节照抄(只把 id 换成占位值)。解码器用 [ApiClientFactory.json]——与生产同一份 `Json`
* 配置,所以这里过不了的形状,线上也一定过不了。
*
* 覆盖 v0.56 才出现、手写 JSON 容易漏掉的形状:`stream:true` **不再恒 200**
* (`202` 带 `queued` + `queueDepth`、`409 chat_busy`、`500 chat_unavailable`)、
* 幂等重复的 `{duplicate:true,messageId,sessionId}`、以及 `400 image_too_large` 的长中文文案。
*/
class ChatDtoRealPayloadTest {
private val json = ApiClientFactory.json
// —— 以下 4 段是真实响应体(2026-10-04 线上抓取,逐字节照抄)——
/** `200`:空闲会话受理成功(`stream:true`)。 */
private val realAccepted = """{"data":{"stream":true}}"""
/** `202`:会话正忙,已续接排队(注意键序,解出来是同一件事)。 */
private val realQueued = """{"data":{"queueDepth":1,"queued":true,"stream":true}}"""
/** `200`:同一个 `clientMsgId` 第二次到达(幂等命中,未落库、未起回合)。 */
private val realDuplicate = """{"data":{"duplicate":true,""" +
""""messageId":"09ddfc26b0ab23afd75436dfe152098d",""" +
""""sessionId":"web:cd42a6652cfc58ef0d5869bc79e81460"}}"""
/** `400`:image 超过解码后 5 MiB 的硬上限(文案里的字符数是服务端算出来的真值)。 */
private val realImageTooLarge = """{"error":{"code":"image_too_large","message":""" +
""""图片过大:image 上限约 5.0 MiB(base64 字符串上限 6990572 字符,本次 6990595 字符);""" +
"""请先 POST /api/v1/upload 上传,再用返回的 url 作为 image 发送"}}"""
/**
* `409`:待处理队列已满。**这一段不是抓来的**——它要求先把某个会话的待处理队列(上限 5,
* `session_orchestrator.go`)填满,代价是让主人的后端真的跑 5 个回合;形状与文案逐字取自
* 后端实现 `internal/services/gateway/chat_stream_dispatch.go:123`
* (`apperr.ErrConflict` + `"chat_busy"` + `"会话正忙,待处理消息已满"`),
* 并由后端自己的 e2e `chat_idempotency_test.go` 断言该 code。其余四段都是真实抓包。
*/
private val realBusy = """{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}"""
private fun ack(body: String) =
json.decodeFromString(ApiEnvelope.serializer(ChatAckDto.serializer()), body).data!!
@Test
fun `accepted ack is stream true and says nothing else`() {
val data = ack(realAccepted)
assertEquals(true, data.stream)
// 其余键整键缺席 → null:绝不能把「没有排队」和「排队 0 条」混为一谈。
assertNull(data.queued)
assertNull(data.queueDepth)
assertNull(data.duplicate)
assertNull(data.messageId)
assertNull(data.sessionId)
}
@Test
fun `queued ack keeps stream flag queued flag and depth`() {
val data = ack(realQueued)
assertEquals(true, data.stream)
assertEquals(true, data.queued)
// 202 是「已续接排队」:深度必须真的读出来,UI 的「已排队 N 条」全靠它。
assertEquals(1, data.queueDepth)
assertNull(data.duplicate)
}
@Test
fun `duplicate ack carries the existing message id for bubble alignment`() {
val data = ack(realDuplicate)
assertEquals(true, data.duplicate)
assertEquals("09ddfc26b0ab23afd75436dfe152098d", data.messageId)
assertEquals("web:cd42a6652cfc58ef0d5869bc79e81460", data.sessionId)
// 重复到达**没有**回 stream:它没有起回合,客户端不能把它当「回合已起」。
assertNull(data.stream)
}
@Test
fun `chat_busy 409 is an error envelope not an ack`() {
val envelope = json.decodeFromString(ApiEnvelope.serializer(ChatAckDto.serializer()), realBusy)
// 409 走 `{error}` 信封:data 缺席、error 有码——这正是「不能折进同一个 Result 分支」的原因。
assertNull(envelope.data)
assertEquals("chat_busy", envelope.error!!.code)
assertEquals("会话正忙,待处理消息已满", envelope.error!!.message)
val mapped = ApiErrorMapper.fromHttp(409, realBusy) as CyreneError.Server
assertEquals("chat_busy", mapped.code)
assertEquals(409, mapped.httpStatus)
}
@Test
fun `image_too_large 400 keeps the code the client branches on`() {
val mapped = ApiErrorMapper.fromHttp(400, realImageTooLarge) as CyreneError.Server
assertEquals("image_too_large", mapped.code)
assertEquals(400, mapped.httpStatus)
// 文案由服务端产出(含真实上限),客户端原样展示,不自己编数字。
assertTrue(mapped.message.contains("5.0 MiB"))
assertTrue(mapped.message.contains("/api/v1/upload"))
}
@Test
fun `the server-side base64 cap in the real message equals the client constant`() {
// 真实文案里写着 base64 字符串上限 = 6990572 字符(= ceil(5 MiB / 3) * 4 + 64)。
// 客户端 `ChatImageBudget.SERVER_MAX_CHARS` 必须与它**逐字相等**,否则会出现
// 「本地觉得没超、服务端 400」或反之的漂移。
val declared = realImageTooLarge.substringAfter("base64 字符串上限 ").substringBefore(" 字符")
assertEquals(6990572L, declared.toLong())
assertEquals(
declared.toLong(),
top.yeij.cyrene.domain.chat.ChatImageBudget.SERVER_MAX_CHARS.toLong(),
)
}
@Test
fun `the request body keeps the documented key names and omits empty optional fields`() {
val encoded = json.encodeToString(
ChatRequest.serializer(),
ChatRequest(
sessionId = "web",
text = "长文本",
deviceId = "voiceassist:cyrene:v1",
stream = true,
clientMsgId = "local-1",
),
)
val raw = json.parseToJsonElement(encoded).jsonObject
// 键名逐字对齐契约(`clientMsgId` 是 camelCase,不是 `client_msg_id`)。
assertEquals(setOf("sessionId", "text", "deviceId", "stream", "clientMsgId"), raw.keys)
assertEquals("web", raw["sessionId"]!!.jsonPrimitive.content)
assertEquals("长文本", raw["text"]!!.jsonPrimitive.content)
assertEquals("local-1", raw["clientMsgId"]!!.jsonPrimitive.content)
assertTrue(raw["stream"]!!.jsonPrimitive.boolean)
// 没给的字段整键省略(`image` / `mode`):服务端 `omitempty` 同语义。
assertFalse(raw.containsKey("image"))
assertFalse(raw.containsKey("mode"))
}
@Test
fun `an image-only request omits text and still carries image`() {
val encoded = json.encodeToString(
ChatRequest.serializer(),
ChatRequest(sessionId = "web", image = "data:image/jpeg;base64,AAAA", stream = true),
)
val raw = json.parseToJsonElement(encoded).jsonObject
// `encodeDefaults = false`:空文本整键省略。服务端 Go 侧零值同为空串,语义等价——
// 这条测试把该事实钉住,免得日后有人以为「发图必须带空 text 键」。
assertFalse(raw.containsKey("text"))
assertEquals("data:image/jpeg;base64,AAAA", raw["image"]!!.jsonPrimitive.content)
}
@Test
fun `queueDepth decodes as an integer not a string`() {
val raw = json.parseToJsonElement(realQueued).jsonObject["data"]!!.jsonObject
assertFalse("queueDepth 线上是数字", raw["queueDepth"]!!.jsonPrimitive.isString)
assertEquals(1, raw["queueDepth"]!!.jsonPrimitive.int)
}
}
@@ -0,0 +1,223 @@
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.ChatApi
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* `POST /api/v1/chat` 的**状态码映射**,端到端跑真 OkHttp + 真 Retrofit + 真 HTTP 服务端
* (JDK 自带的 `com.sun.net.httpserver`,不引任何新依赖)。
*
* 这里要证明的核心是**「非 2xx 不等于失败」**:v0.56 起 `202` 排队与 `409` 排队满是编排器的
* 正常回执,`202` 还落在 Retrofit 的 `isSuccessful` 里、`409` 不在——用 `apiCall` 一把折进
* `Result.failure` 就会把两者压成同一个东西。所以每条断言都对着**状态码本身**,
* 而且用真实响应体(形状与线上一致)。
*/
class ChatRepositoryTest {
private val backend = FakeChatBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = ChatRepository(
ApiClientFactory.create(baseUrl = backend.baseUrl, service = ChatApi::class.java),
)
private fun request(text: String = "长文本", clientMsgId: String = "local-1") = ChatRequest(
sessionId = "web",
text = text,
deviceId = "voiceassist:cyrene:v1",
stream = true,
clientMsgId = clientMsgId,
)
@Test
fun `200 stream true maps to accepted`() = runBlocking {
backend.reply(200, """{"data":{"stream":true}}""")
val ack = repository().send(request()).getOrThrow()
assertEquals(ChatAck.Accepted, ack)
}
@Test
fun `202 queued keeps the queue depth instead of being folded into an error`() = runBlocking {
backend.reply(202, """{"data":{"stream":true,"queued":true,"queueDepth":3}}""")
val ack = repository().send(request()).getOrThrow()
// 202 是「已受理、已续接排队」:既不是失败,也不是「已起回合」。
assertEquals(ChatAck.Queued(queueDepth = 3), ack)
}
@Test
fun `200 duplicate carries the existing message id`() = runBlocking {
backend.reply(
200,
"""{"data":{"duplicate":true,"messageId":"m-42","sessionId":"web"}}""",
)
val ack = repository().send(request()).getOrThrow()
assertEquals(ChatAck.Duplicate(messageId = "m-42", sessionId = "web"), ack)
}
@Test
fun `409 chat_busy is queue full and not a network failure`() = runBlocking {
backend.reply(409, """{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}""")
val result = repository().send(request())
// 关键:**成功**地拿到了「队列满」这个业务判定,调用方能把它和 Accepted/Queued 分开。
assertEquals(ChatAck.QueueFull, result.getOrThrow())
}
@Test
fun `500 chat_unavailable is surfaced with its code`() = runBlocking {
backend.reply(
500,
"""{"error":{"code":"chat_unavailable","message":"chat service is not configured"}}""",
)
val ack = repository().send(request()).getOrThrow()
assertEquals(ChatAck.Unavailable("chat_unavailable", "chat service is not configured"), ack)
}
@Test
fun `503 chat_dispatch_failed is surfaced with its code`() = runBlocking {
backend.reply(
503,
"""{"error":{"code":"chat_dispatch_failed","message":"流式通道不可用,本轮未提交;请重试"}}""",
)
val ack = repository().send(request()).getOrThrow()
assertEquals(ChatAck.Unavailable("chat_dispatch_failed", "流式通道不可用,本轮未提交;请重试"), ack)
}
@Test
fun `400 image_too_large keeps the code the caller branches on`() = runBlocking {
backend.reply(
400,
"""{"error":{"code":"image_too_large","message":"图片过大:image 上限约 5.0 MiB"}}""",
)
val ack = repository().send(request()).getOrThrow()
assertEquals(ChatAck.Rejected("image_too_large", "图片过大:image 上限约 5.0 MiB"), ack)
}
@Test
fun `400 bad_request is rejected rather than retried`() = runBlocking {
backend.reply(400, """{"error":{"code":"bad_request","message":"text or image is required"}}""")
val ack = repository().send(request()).getOrThrow()
assertEquals(ChatAck.Rejected("bad_request", "text or image is required"), ack)
}
@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().send(request()).exceptionOrNull() as CyreneException
assertTrue(error.error is CyreneError.Unauthorized)
}
@Test
fun `a 2xx without data is a parse failure not an acceptance`() = runBlocking {
backend.reply(200, "{}")
val error = repository().send(request()).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().send(request()).exceptionOrNull() as CyreneException
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
}
@Test
fun `the request actually carries stream true clientMsgId and the image`() = runBlocking {
backend.reply(200, """{"data":{"stream":true}}""")
repository().send(
ChatRequest(
sessionId = "web",
text = "",
image = "data:image/jpeg;base64,AAAA",
deviceId = "voiceassist:cyrene:v1",
stream = true,
clientMsgId = "local-7",
),
).getOrThrow()
val body = backend.bodies.single()
// 线上必须真的带 `stream:true`(否则会走同步路径、被 60s 读超时打死)。
assertTrue(body, body.contains("\"stream\":true"))
assertTrue(body, body.contains("\"clientMsgId\":\"local-7\""))
assertTrue(body, body.contains("\"image\":\"data:image/jpeg;base64,AAAA\""))
assertTrue(body, body.contains("\"sessionId\":\"web\""))
// 逐字对齐契约:不是 `client_msg_id`、不是 `clientMessageId`。
assertEquals(1, Regex("\"clientMsgId\"").findAll(body).count())
}
/** 一个只会照着 `reply(...)` 回话的假后端:记录收到的请求体,绝不校验鉴权。 */
private inner class FakeChatBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/api/v1/chat", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
val bodies = CopyOnWriteArrayList<String>()
@Volatile
private var status: Int = 200
@Volatile
private var body: String = """{"data":{"stream":true}}"""
fun reply(status: Int, body: String) {
this.status = status
this.body = body
}
private fun handle(exchange: HttpExchange) {
bodies += exchange.requestBody.readBytes().decodeToString()
val bytes = body.toByteArray()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
override fun close() {
server.stop(0)
}
}
}
@@ -0,0 +1,74 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 下行帧 `chat_duplicate`(契约 v0.56 §3.13,纯新增)。
*
* 帧壳带 `sessionId`、**不带** `turnId` / `seq`(它与 `session_busy` 同一出口,不认识「轮次」)。
* 这是幂等重发的明确回执:不落库、不起回合、不发内容帧——客户端只能靠 [WsEvent.ChatDuplicate.messageId]
* 把本地乐观气泡对齐到服务端真相。
*/
class WsEventChatDuplicateTest {
/** 真实帧形状(服务端 `session_events.go` 的 `EventChatDuplicate` 载荷)。 */
private val realFrame = """
{"type":"chat_duplicate","sessionId":"web","data":{"sessionId":"web","clientMsgId":"local-1","messageId":"m-42"}}
""".trimIndent()
@Test
fun `real chat_duplicate frame decodes`() {
val event = WsEvent.parse(realFrame) as WsEvent.ChatDuplicate
assertEquals("chat_duplicate", event.type)
assertEquals("web", event.sessionId)
assertEquals("local-1", event.clientMsgId)
assertEquals("m-42", event.messageId)
// 状态机会话帧不带轮次标识。
assertNull(event.turnId)
assertNull(event.seq)
}
@Test
fun `the shell sessionId wins when the payload omits it`() {
val frame = """{"type":"chat_duplicate","sessionId":"web:abc","data":{"clientMsgId":"local-2"}}"""
val event = WsEvent.parse(frame) as WsEvent.ChatDuplicate
assertEquals("web:abc", event.sessionId)
assertEquals("local-2", event.clientMsgId)
assertNull("载荷没给 messageId 就是 null,不拍成空串", event.messageId)
}
@Test
fun `the payload sessionId fills in when the shell omits it`() {
val frame = """{"type":"chat_duplicate","data":{"sessionId":"web:xyz","clientMsgId":"local-3"}}"""
val event = WsEvent.parse(frame) as WsEvent.ChatDuplicate
assertEquals("web:xyz", event.sessionId)
}
@Test
fun `a chat_duplicate without a payload still parses instead of being dropped`() {
// 缺 clientMsgId 时客户端无从对齐,但**绝不能**因此整帧失败——
// 未知/残缺形状按「拿不到就不做事」处理,而不是让解析器吞掉整类事件。
val event = WsEvent.parse("""{"type":"chat_duplicate","sessionId":"web"}""") as WsEvent.ChatDuplicate
assertEquals("", event.clientMsgId)
assertEquals("web", event.sessionId)
}
@Test
fun `chat_duplicate is scoped to its own session`() {
val event = WsEvent.parse(realFrame)!!
assertTrue(event.isForSession("web"))
// 服务端广播不按会话订阅:别的会话的幂等回执必须被这道闸挡在外面,
// 否则会按值去吃掉本会话无关的气泡。
assertTrue(!event.isForSession("web:other"))
}
}
@@ -0,0 +1,176 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsOutbound
/**
* 上行路由规则与图片预算(纯函数)。
*
* 判据是**UTF-8 字节数**:WS 帧上限是服务端的 `wsReadLimit = 4096` 字节,而超限的后果不是
* 「服务端回个错误」,是**传输层直接断连**——所以这条规则宁早不晚。
*/
class ChatSendPolicyTest {
// —— 路由 ——
@Test
fun `short text goes over the websocket`() {
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("你好", hasImage = false))
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("hello", hasImage = false))
}
@Test
fun `long text goes over REST`() {
val text = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(text, hasImage = false))
}
@Test
fun `any image goes over REST even when the text is one character`() {
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("嗨", hasImage = true))
// 纯图片(空文本)也必须走 REST:base64 不可能塞进 4096 字节的帧。
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("", hasImage = true))
}
@Test
fun `the threshold is inclusive on the websocket side`() {
val atLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
val overLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
assertEquals(ChatSendRouting.WS_TEXT_LIMIT_BYTES, ChatSendRouting.utf8Length(atLimit))
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor(atLimit, hasImage = false))
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(overLimit, hasImage = false))
}
@Test
fun `the threshold counts bytes not characters so CJK falls back earlier`() {
// 中文一个字 3 字节:1194 字 = 3582 字节(仍在限内),1195 字 = 3585 字节(超限)。
// 若按「字符数 ≤ 3584」判,333 个汉字时就该退到 REST 却仍会走 WS —— 这正是要避免的。
val fits = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3)
val overflows = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3 + 1)
assertEquals(3582, ChatSendRouting.utf8Length(fits))
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor(fits, hasImage = false))
assertEquals(3585, ChatSendRouting.utf8Length(overflows))
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(overflows, hasImage = false))
}
@Test
fun `an emoji outside the BMP is four bytes like the wire says`() {
// UTF-16 里一个 emoji 是 2 个 char,但 WS 上按 UTF-8 算是 4 字节 —— 用 `length` 会低估一半。
assertEquals(2, "🎐".length)
assertEquals(4, ChatSendRouting.utf8Length("🎐"))
}
// —— 阈值本身与「真实帧不会超 4096」 ——
@Test
fun `the websocket budget leaves room for the frame envelope`() {
assertEquals(4096, ChatSendRouting.WS_FRAME_LIMIT_BYTES)
assertTrue(
"文本上限必须真的给 JSON 外壳留出空间",
ChatSendRouting.WS_TEXT_LIMIT_BYTES < ChatSendRouting.WS_FRAME_LIMIT_BYTES,
)
assertEquals(
ChatSendRouting.WS_FRAME_LIMIT_BYTES - ChatSendRouting.WS_ENVELOPE_RESERVE_BYTES,
ChatSendRouting.WS_TEXT_LIMIT_BYTES,
)
}
@Test
fun `a chat frame at the threshold still fits the 4096 byte read limit`() {
val text = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
val frame = WsOutbound.Chat(
text = text,
sessionId = "web",
deviceId = "voiceassist:cyrene:v1",
clientMsgId = "local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41",
).toJson()
// 真实的 UTF-8 字节数必须 ≤ 服务端读上限,否则这条帧会让服务端**直接断连**。
assertTrue(
"帧 ${ChatSendRouting.utf8Length(frame)} 字节超过了 4096",
ChatSendRouting.utf8Length(frame) <= ChatSendRouting.WS_FRAME_LIMIT_BYTES,
)
}
@Test
fun `a CJK chat frame at the threshold still fits`() {
// 逐字符碰到上限为止:取最大的、字节数仍 ≤ 上限的中文串。
val text = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3)
val frame = WsOutbound.Chat(
text = text,
sessionId = "web",
deviceId = "voiceassist:cyrene:v1",
clientMsgId = "local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41",
).toJson()
assertTrue(ChatSendRouting.utf8Length(frame) <= ChatSendRouting.WS_FRAME_LIMIT_BYTES)
}
// —— 图片预算 ——
@Test
fun `base64 length follows the 4 to 3 expansion`() {
assertEquals(0, ChatImageBudget.base64Chars(0))
assertEquals(4, ChatImageBudget.base64Chars(1))
assertEquals(4, ChatImageBudget.base64Chars(3))
assertEquals(8, ChatImageBudget.base64Chars(4))
assertEquals(8, ChatImageBudget.base64Chars(6))
}
@Test
fun `the server character cap matches 5 MiB plus the data uri prefix allowance`() {
val expected = ((ChatImageBudget.SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + 64
assertEquals(5 * 1024 * 1024, ChatImageBudget.SERVER_MAX_DECODED_BYTES)
assertEquals(expected, ChatImageBudget.SERVER_MAX_CHARS)
// 服务端 `checkChatImageSize` 的判据是 `len(image) <= limit`:等于上限必须放行。
assertTrue(ChatImageBudget.withinServerLimit(ChatImageBudget.SERVER_MAX_CHARS))
assertFalse(ChatImageBudget.withinServerLimit(ChatImageBudget.SERVER_MAX_CHARS + 1))
}
@Test
fun `the client target budget is far below the server hard cap`() {
assertTrue(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES))
assertFalse(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES + 1))
// 本端目标必须明显更紧:服务端只管「收不收」,管不了「历史有多重」。
assertTrue(ChatImageBudget.MAX_DECODED_BYTES * 4 < ChatImageBudget.SERVER_MAX_DECODED_BYTES)
}
@Test
fun `scaling never upscales and keeps the aspect ratio`() {
assertEquals(ScaledSize(800, 600), ChatImageBudget.scaledSize(800, 600))
assertEquals(ScaledSize(1280, 1280), ChatImageBudget.scaledSize(1280, 1280))
assertEquals(ScaledSize(1280, 960), ChatImageBudget.scaledSize(4000, 3000))
assertEquals(ScaledSize(960, 1280), ChatImageBudget.scaledSize(3000, 4000))
}
@Test
fun `scaling a very thin image keeps both sides at least one pixel`() {
val scaled = ChatImageBudget.scaledSize(1, 5000)
assertEquals(1, scaled.width)
assertEquals(1280, scaled.height)
}
@Test
fun `scaling guards against degenerate dimensions`() {
assertEquals(ScaledSize(1, 1), ChatImageBudget.scaledSize(0, 0))
assertEquals(ScaledSize(1, 1280), ChatImageBudget.scaledSize(-3, 5000))
}
@Test
fun `data uri has the shape coil and the server both accept`() {
val uri = ChatImageBudget.dataUri("image/jpeg", "AAAA")
assertEquals("data:image/jpeg;base64,AAAA", uri)
assertTrue(uri.startsWith("data:image/jpeg;base64,"))
}
}
@@ -0,0 +1,129 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
/**
* 乐观气泡的归一规则(`clientMsgId` 首选、同文本兜底)。
*
* v0.56 之前只能靠「文本相等」猜,而文本是会变的(发送时被 trim、服务端将来会再加工),
* 两条一样的话还会连吃两个气泡。[consumePendingFor] 就是这条规则的唯一实现。
*/
class PendingReconcileTest {
private fun pending(localId: String, text: String) = PendingMessage(localId = localId, text = text)
private fun persisted(
id: String = "m1",
content: String = "你好",
clientMsgId: String? = null,
role: String = "user",
) = HistoryMessageDto(
id = id,
sessionId = "web",
role = role,
content = content,
clientMsgId = clientMsgId,
createdAt = 0L,
)
@Test
fun `a matching clientMsgId wins even when the persisted text differs`() {
val pending = listOf(pending("local-a", "发送时的文本"), pending("local-b", "另一条"))
val left = consumePendingFor(
pending,
persisted(content = "服务端加工后的文本", clientMsgId = "local-a"),
)
// 文本对不上也照样命中;另一条纹丝不动。
assertEquals(listOf("local-b"), left.map { it.localId })
}
@Test
fun `a persisted message without a clientMsgId still falls back to the first equal text`() {
val pending = listOf(pending("local-a", "别的话"), pending("local-b", "旧服务端的消息"))
val left = consumePendingFor(pending, persisted(content = "旧服务端的消息"))
assertEquals(listOf("local-a"), left.map { it.localId })
}
@Test
fun `a blank clientMsgId counts as absent and uses the text fallback`() {
val pending = listOf(pending("local-a", "同文本"))
val left = consumePendingFor(pending, persisted(content = "同文本", clientMsgId = " "))
assertTrue(left.isEmpty())
}
@Test
fun `an unknown clientMsgId consumes nothing and does not fall back to text`() {
val pending = listOf(pending("local-a", "同文本"))
// 带键的消息属于**某一次具体的发送**:键对不上就说明它不是这一条(可能是别的端发的),
// 此时按文本去吃不等于「对齐」,而是吃掉一个无关的气泡。
val left = consumePendingFor(pending, persisted(content = "同文本", clientMsgId = "local-other"))
assertEquals(listOf("local-a"), left.map { it.localId })
}
@Test
fun `two equal-text bubbles are consumed one at a time and never double consumed`() {
val pending = listOf(pending("local-a", "重复的话"), pending("local-b", "重复的话"))
val afterFirst = consumePendingFor(pending, persisted(id = "m1", content = "重复的话", clientMsgId = "local-a"))
assertEquals(listOf("local-b"), afterFirst.map { it.localId })
// 第二条(键对不上任何现存气泡)不该再吃掉 local-b。
val afterSecond = consumePendingFor(
afterFirst,
persisted(id = "m2", content = "重复的话", clientMsgId = "local-a"),
)
assertEquals(listOf("local-b"), afterSecond.map { it.localId })
}
@Test
fun `replaying the same persisted message changes nothing the second time`() {
val pending = listOf(pending("local-a", "同一条"))
val once = consumePendingFor(pending, persisted(content = "同一条"))
val twice = consumePendingFor(once, persisted(content = "同一条"))
assertTrue(once.isEmpty())
assertTrue(twice.isEmpty())
}
@Test
fun `matching keeps the order of the remaining bubbles`() {
val pending = listOf(pending("local-a", "甲"), pending("local-b", "乙"), pending("local-c", "丙"))
val left = consumePendingFor(pending, persisted(content = "乙", clientMsgId = "local-b"))
assertEquals(listOf("local-a", "local-c"), left.map { it.localId })
assertEquals(listOf("甲", "丙"), left.map { it.text })
}
@Test
fun `no match returns the very same list instance`() {
val pending = listOf(pending("local-a", "甲"))
val left = consumePendingFor(pending, persisted(content = "毫不相干", clientMsgId = "local-x"))
// 无命中时不得重建列表:Compose 的 key 稳定性和「什么都没发生」的语义都靠它。
assertSame(pending, left)
}
@Test
fun `the pending input is never mutated`() {
val pending = listOf(pending("local-a", "甲"), pending("local-b", "乙"))
consumePendingFor(pending, persisted(content = "乙", clientMsgId = "local-b"))
assertEquals(listOf("local-a", "local-b"), pending.map { it.localId })
}
}