merge: W3 — B1 /upload(大图走上传,历史只留 URL)
This commit is contained in:
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
|
||||
Reference in new issue
Block a user