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:
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user