merge: W3 — B1 /upload(大图走上传,历史只留 URL)

This commit is contained in:
AskaEth committed 2026-10-04 17:44:23 +08:00
commit 664fd8dcef
11 files changed
+869 -6

No files matched your search

@@ -11,10 +11,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.historyForSession
@@ -23,7 +25,9 @@ import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.data.ws.isForSession
import top.yeij.cyrene.domain.chat.ChatImageDelivery
import top.yeij.cyrene.domain.chat.ChatSendRouting
import top.yeij.cyrene.domain.chat.ImageDelivery
import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser
@@ -219,6 +223,9 @@ data class ChatUiState(
*
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
* 图片在 REST 之前还多一层选择:小图内联成 `data:` URI,大图先 `POST /api/v1/upload`
* 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页
* 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。
*
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
@@ -287,7 +294,9 @@ class ChatViewModel(
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
*
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
* 也不把几 MB base64 永久留在每一页历史里。
*/
fun send() {
val snapshot = _state.value
@@ -324,7 +333,30 @@ class ChatViewModel(
if (!sent) rollback(localId, "发送失败,请重试")
}
SendChannel.Rest -> sendViaRest(localId, text, image)
SendChannel.Rest -> if (image == null) {
sendViaRest(localId, text, image = null)
} else {
deliverImage(localId, text, image)
}
}
}
/**
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
*
* **上传失败不回退内联**:回退等于把「省历史重量」这件事当场作废——用户以为发的是 URL、
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
*/
private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) {
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri)
ImageDelivery.Upload -> container.uploadRepository
.uploadImage(image.bytes, image.mime)
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url) }
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
}
}
@@ -667,5 +699,24 @@ class ChatViewModel(
private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试"
/**
* 上传失败的文案(纯函数,可单测)。
*
* 前两条是服务端的**机器可读**拒绝码(对着在线后端实测过,见 `UploadDtoRealPayloadTest`):
* 在本端预算下它们不该出现(重编码出口 ≤ 1 MiB,类型非 png 即 jpeg,都在白名单里),
* 但真出现时正确的动作是「换一张图」而不是「重试」,文案必须把这两者分开。
*
* 其余(网络 / 超时 / 5xx)用既有错误口径,并**点明是上传这一步**失败——否则用户看到
* 「无法连接到服务器」会以为整条消息没发出去,而实际上文字也是跟着一起回滚的。
*/
fun uploadFailureMessage(e: Throwable): String {
val error = (e as? CyreneException)?.error
return when ((error as? CyreneError.Server)?.code) {
"payload_too_large" -> "图片超过服务端 5 MB 上限,请换一张更小的"
"unsupported_media_type" -> "服务端不接受这张图片的格式,请换一张"
else -> "图片上传失败," + (error?.userMessage ?: "请重试")
}
}
/** 思维链在流式结束后继续保留的时长。 */
private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L