feat(chat): 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)

把 W6 做好的「文档进对话」数据层接上 UI 与发送路径,产品决定按 05 的定案执行:
**不做长正文自动转文件**、**附件与正文各自独立存在**、上行**只给服务端存储名 `name`、≤5 条**、
带附件一律走 REST。

改了什么(按清单):
- AppContainer:照 cachedUploadApi 的写法加 documentsApi + documentRepository,**与 chatApi 同
  baseUrl / 同 token·401 策略**(上传拿到的 name 要交给同一个后端的 /chat,不同源必然 404)。
- domain/chat/DocumentAttachment.kt(新):DraftDocument(待发附件,只带本地引用,**不带字节**)、
  admit()(去重 → 20 MiB 单份预检 → 5 份上限,被挡下的**都计数**、绝不静默裁)、
  readBounded()(超限回 null、IO 异常原样抛——「太大」与「读不到」必须分得开)。
- domain/chat/DocumentChip.kt(新):附件芯片的展示换算(类型 / 体积 / 注入字数 / 状态),
  unreadable(读不到)与 truncated(注入被预算截断)**两句文案分开**,未知 status 原样显示。
- domain/chat/DocumentDelivery.kt:加 planForCount(text, 份数)——同一套判定矩阵,但只看份数,
  因为 VM 手上还没有字节(最多 5×20 MiB 一次性读进内存不可接受)。plan() 委托给它,行为不变。
- ChatViewModel:ChatUiState 加 draftDocuments / attachmentNotice;send() → deliver() 走
  DocumentDelivery 判定 → 逐份上传(保留扩展名、200+deduped 当成功)→ payload(names) 为 null
  时**提示而不截断** → sendViaRest 带 documents;失败与正文、图片一起回滚(附件还给输入栏)。
- ChatScreen:SAF OpenMultipleDocuments 入口(MiuixIcons.File + contentDescription「添加文件附件」),
  MIME 列表贴近后端文本白名单的语义(含 octet-stream 以免「后端收得下、用户选不到」),
  输入栏上方一排可移除芯片;发送按钮在只有附件时也可用(纯附件回合服务端支持)。
- MessageBubble:documents 渲染成芯片(名字中略保住扩展名 / 类型·体积·字数·状态),
  unreadable 整枚转灰并明写「读不到」;空列表不占位。PendingBubble 显示本地待发件。
- ShareIntake + ShareRules:EXTRA_STREAM 按 ContentResolver.getType 分流——image/ 前缀走既有
  图片路,其余(含 null)走**文档路**(拷进 cache、带显示名与真实字节数)。此前一律当图片拷,
  一份 .md 只能得到「图片处理失败」。**Manifest 一行未改**:后端明令拒绝 html/SVG/zip/脚本,
  声明全通配会造出「点进去必然失败」的入口,text/plain 已能接住 .txt/.md/.log。

测试:485 → 536(+51,0 失败 0 错误)。
This commit is contained in:
AskaEth committed 2026-10-04 18:44:23 +08:00
1 parent a6b4bae2a0
commit 30180ab1e5
14 files changed
+1668 -65

No files matched your search

@@ -47,6 +47,8 @@ import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button
@@ -59,6 +61,7 @@ import top.yukonga.miuix.kmp.basic.TextField
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.extended.File
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -71,6 +74,31 @@ private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
/**
* 选文件时交给系统选择器的 MIME 列表。
*
* 判据不是「后端白名单的 22 个扩展名」本身——那 22 个扩展名里有 `.go` / `.rs` / `.sql` / `.log`,
* 各家文件管理器给它们报的类型五花八门(`text/x-go`、`application/octet-stream`……)。列窄了
* 会出现「后端收得下、用户却选不到」的假失败,所以这里取**语义上贴近文本/代码**的那一族,
* 并含 `application/octet-stream`(未知扩展名的常见报法)。
*
* **刻意不列**已经确定会被后端拒的那几类(`application/pdf`、`application/zip`、
* `image/svg+xml`、`text/html` 等):让它们在选择器里就是灰的,比让用户传完再看一句
* 「服务端不接受这个格式」更省事。最终裁决仍是**服务端的字节嗅探**——这里放行的,服务端
* 照旧可以用 `415 unsupported_media_type` 拒掉,那条文案会原样显示给用户,不假装成功。
*/
private val DocumentPickerMimeTypes = arrayOf(
"text/*",
"application/json",
"application/x-json",
"application/x-yaml",
"application/yaml",
"text/yaml",
"application/toml",
"application/x-toml",
"application/octet-stream",
)
@Composable
fun ChatScreen(
container: AppContainer,
@@ -91,6 +119,14 @@ fun ChatScreen(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
// 文件选择器(SAF `OpenMultipleDocuments`):同样不需要任何存储权限——授权是**逐次**给的,
// 因此这条 URI 的读授权只活到本 Activity 任务结束;而发送可能发生在登录之后(分享落地)
// 或很久之后,那时授权还在(同一个任务内)。跨任务的情况由 `ShareIntake` 自己拷进 cache 兜住。
// 一次可选多份(上限由 VM 的准入规则管,超了会如实告知)。
val documentPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments(),
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给既有发送路径。
// - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
@@ -158,6 +194,7 @@ fun ChatScreen(
InputBar(
value = state.draft,
imageUri = state.draftImage,
documents = state.draftDocuments,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
@@ -167,6 +204,8 @@ fun ChatScreen(
)
},
onClearImage = viewModel::clearDraftImage,
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
onRemoveDocument = viewModel::removeDraftDocument,
)
},
) { padding ->
@@ -222,6 +261,9 @@ fun ChatScreen(
state.shareNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
state.attachmentNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
}
}
}
@@ -245,11 +287,14 @@ private fun Banner(text: String, color: Color) {
private fun InputBar(
value: String,
imageUri: String?,
documents: List<DraftDocument>,
busyText: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onPickImage: () -> Unit,
onClearImage: () -> Unit,
onPickDocument: () -> Unit,
onRemoveDocument: (Int) -> Unit,
) {
Column(
modifier = Modifier
@@ -258,6 +303,8 @@ private fun InputBar(
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
DraftDocumentChips(documents = documents, onRemove = onRemoveDocument)
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) {
@@ -270,6 +317,15 @@ private fun InputBar(
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
TextField(
value = value,
onValueChange = onValueChange,
@@ -283,8 +339,9 @@ private fun InputBar(
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
// 图片可以单独发出(契约:text / image 至少一项非空)。
enabled = value.isNotBlank() || imageUri != null,
// 附件可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || imageUri != null || documents.isNotEmpty(),
) {
Text("发送")
}
@@ -306,6 +363,16 @@ private fun InputBar(
}
}
/** 待发附件的芯片排:横向可滚(5 枚芯片不挤成两行,也不会把输入框顶上去)。 */
@Composable
private fun DraftDocumentChips(documents: List<DraftDocument>, onRemove: (Int) -> Unit) {
DocumentChipRow(
chips = documents.map(DocumentChips::of),
onRemove = onRemove,
modifier = Modifier.padding(bottom = 6.dp),
)
}
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
@Composable
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
@@ -373,19 +440,23 @@ private fun busyLabel(state: ChatUiState): String = when {
}
/**
* 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图)
* 一个字都不说。
* 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图 /
* 一个文件)一个字都不说。
*
* 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10`
* (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西
* 用不了,混成一句会让人以为图还在路上。
* 用不了,混成一句会让人以为图还在路上。图片与文件也分开说:两者的上限不是一个数。
*/
private fun shareNoticeFor(share: SharedContent): String? {
val parts = buildList {
if (share.truncatedImages > 0) {
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张")
}
if (share.truncatedDocuments > 0) {
add("分享的文件超过 ${ShareRules.MAX_DOCUMENTS} 个,只发送前 ${share.documents.size} 个")
}
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
if (share.isEmpty) add("分享的内容是空的,没有可发送的内容")
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
@@ -1,8 +1,10 @@
package top.yeij.cyrene.ui.chat
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -14,6 +16,7 @@ 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.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage
@@ -27,8 +30,16 @@ 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.DocumentAttachments
import top.yeij.cyrene.domain.chat.DocumentDelivery
import top.yeij.cyrene.domain.chat.DocumentFailure
import top.yeij.cyrene.domain.chat.DocumentPlan
import top.yeij.cyrene.domain.chat.DocumentRead
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageDelivery
import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.chat.readBounded
import top.yeij.cyrene.domain.chat.toDocumentFailure
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser
import top.yeij.cyrene.domain.share.SharedContent
@@ -53,6 +64,11 @@ data class PendingMessage(
val text: String,
/** 本次带的图片的**本地可读** URI(`content://` 或 `file://`);null = 纯文本消息。 */
val imageUri: String? = null,
/**
* 本次带的文档附件(本地引用,还没上传)。它们与 [text]、[imageUri] 各自独立存在:
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
*/
val documents: List<DraftDocument> = emptyList(),
)
/**
@@ -191,6 +207,12 @@ data class ChatUiState(
val draft: String = "",
/** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */
val draftImage: String? = null,
/**
* 已选好、还没发出去的文档附件(与 [draftImage] 平行的概念)。发送失败时与正文、图片一起
* 回到这里(见 [ChatViewModel.rollback]),所以它们必须一路带着**本地引用**——
* 重新上传的代价是几 MB 的流量与一次白等。
*/
val draftDocuments: List<DraftDocument> = emptyList(),
val loading: Boolean = true,
val loadingOlder: Boolean = false,
val hasMore: Boolean = true,
@@ -202,6 +224,13 @@ data class ChatUiState(
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。
*/
val shareNotice: String? = null,
/**
* 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。
*
* 与 [error] 分开、也与 [shareNotice] 分开:被挡下的文件**不是发送失败**,用户要做的动作
* 也不一样(换文件 / 先发一批)。同样到期自动消失([ATTACHMENT_NOTICE_MILLIS])。
*/
val attachmentNotice: String? = null,
val connection: WsState = WsState.Idle,
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null,
@@ -298,31 +327,64 @@ class ChatViewModel(
fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
/**
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片还给输入框。
* 选好待发附件(SAF `OpenDocument` / `OpenMultipleDocuments` 回来的 URI)。
*
* 路由 [ChatSendRouting]:**短文本且无图 → WS**(直达、绕过有损总线,今天就能用);
* **长文本或任何图片 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上流式
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
* 元数据(显示名 / 字节数)要问 `ContentResolver`,所以放到 IO 线程;准入判定本身是纯函数
* ([DocumentAttachments.admit]),被挡下的份数如实告知、**不静默裁**。
*/
fun onDocumentsPicked(uris: List<String>) {
val picked = uris.map(String::trim).filter(String::isNotEmpty)
if (picked.isEmpty()) return
viewModelScope.launch {
val documents = withContext(Dispatchers.IO) { picked.map(::draftDocumentFor) }
var notice: String? = null
_state.update { state ->
val result = DocumentAttachments.admit(state.draftDocuments, documents)
notice = result.notice
state.copy(draftDocuments = state.draftDocuments + result.accepted)
}
notice?.let(::showAttachmentNotice)
}
}
/** 移除第 [index] 个待发附件(越界原样不动,UI 的移除按钮不必自己判界)。 */
fun removeDraftDocument(index: Int) =
_state.update { it.copy(draftDocuments = DocumentAttachments.removeAt(it.draftDocuments, index)) }
fun clearDraftDocuments() = _state.update { it.copy(draftDocuments = emptyList()) }
/**
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片 / 附件还给输入框。
*
* 路由 [ChatSendRouting]:**短文本且无图无附件 → WS**(直达、绕过有损总线,今天就能用);
* **长文本、任何图片、任何附件 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上
* 流式回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
*
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
* 也不把几 MB base64 永久留在每一页历史里。
*
* 附件**一律先上传**换服务端存储名(`POST /api/v1/documents`),再随 `chat.documents` 引用
* 发出,上行只带 `name`(见 [DocumentDelivery.payload] 与 [uploadDocuments])。
*/
fun send() {
val snapshot = _state.value
val text = snapshot.draft.trim()
val imageUri = snapshot.draftImage
if (text.isEmpty() && imageUri == null) return
viewModelScope.launch { emitAndDeliver(text, imageUri, clearDraft = true) }
val documents = snapshot.draftDocuments
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
viewModelScope.launch { emitAndDeliver(text, imageUri, documents, clearDraft = true) }
}
/**
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
* (短文本 → WS,长文本 / 图片 → REST,图片先 [ChatImagePreparer] 重编码、大图先 `/upload`),
* **不另起一套发送实现**,也不动用户正文里的一个字。
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
* 大图先 `/upload`、附件先 `/documents`),**不另起一套发送实现**,也不动用户正文里的一个字。
*
* 一条消息只能带一张图,所以 N 张图 = N 条消息:第一条带上分享的正文,其余每条一张图。
* 非图片文件([SharedContent.documents])没有这个限制——它们与正文、图片挂在**同一条**
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
*
* 两处刻意的取舍:
@@ -334,10 +396,13 @@ class ChatViewModel(
fun sendShared(share: SharedContent, notice: String? = null) {
notice?.let(::showShareNotice)
val body = share.text.trim()
if (body.isEmpty() && share.images.isEmpty()) return
val documents = share.documents.map {
DraftDocument(uri = it.uri, displayName = it.displayName, sizeBytes = it.sizeBytes)
}
if (body.isEmpty() && share.images.isEmpty() && documents.isEmpty()) return
viewModelScope.launch {
emitAndDeliver(body, share.images.firstOrNull(), clearDraft = false)
share.images.drop(1).forEach { emitAndDeliver("", it, clearDraft = false) }
emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false)
share.images.drop(1).forEach { emitAndDeliver("", it, emptyList(), clearDraft = false) }
}
}
@@ -350,29 +415,60 @@ class ChatViewModel(
}
}
/** 记一条附件提示,到期自动收走(与 [showShareNotice] 同一形态,只是另一块牌子)。 */
private fun showAttachmentNotice(text: String) {
_state.update { it.copy(attachmentNotice = text) }
viewModelScope.launch {
kotlinx.coroutines.delay(ATTACHMENT_NOTICE_MILLIS)
_state.update { if (it.attachmentNotice == text) it.copy(attachmentNotice = null) else it }
}
}
/**
* 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。
*
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
*/
private suspend fun emitAndDeliver(text: String, imageUri: String?, clearDraft: Boolean) {
if (text.isEmpty() && imageUri == null) return
private suspend fun emitAndDeliver(
text: String,
imageUri: String?,
documents: List<DraftDocument>,
clearDraft: Boolean,
) {
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
val localId = "local-${UUID.randomUUID()}"
_state.update {
it.copy(
pending = it.pending + PendingMessage(localId, text, imageUri),
pending = it.pending + PendingMessage(localId, text, imageUri, documents),
draft = if (clearDraft) "" else it.draft,
draftImage = if (clearDraft) null else it.draftImage,
draftDocuments = if (clearDraft) emptyList() else it.draftDocuments,
error = null,
)
}
deliver(localId, text, imageUri)
deliver(localId, text, imageUri, documents)
}
private suspend fun deliver(localId: String, text: String, imageUri: String?) {
private suspend fun deliver(
localId: String,
text: String,
imageUri: String?,
documents: List<DraftDocument>,
) {
// 判定矩阵([DocumentDelivery]):附件 >5 **直接拒绝,绝不裁剪**。这里用只需份数的
// [DocumentDelivery.planForCount]——字节还没读(最多 5×20 MiB 一次性读进内存不可接受),
// 而矩阵里唯一与文件内容有关的就是份数。
if (DocumentDelivery.planForCount(text, documents.size) is DocumentPlan.Rejected) {
rollback(localId, "一个回合最多 ${DocumentDelivery.MAX_ATTACHMENTS_PER_TURN} 个附件,没有发送")
return
}
// 产品定案(`05` 末节):**不做「长正文自动转文件」**。plan 的 `textAsDocument` 因此
// 一律不落地——用户打的字照发,长正文本来就已经由 [ChatSendRouting] 走 REST;
// 把它悄悄换成附件只会让它在历史里搜不到、气泡变成一枚芯片。
val image = imageUri?.let { uri ->
withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
}
@@ -382,20 +478,122 @@ class ChatViewModel(
return
}
when (ChatSendRouting.channelFor(text, hasImage = image != null)) {
// 附件先换存储名(失败已在里面回滚,这一轮到此为止)。
val payload = if (documents.isEmpty()) emptyList() else {
val names = uploadDocuments(localId, documents) ?: return
// 载荷不合法(>5 或有空名字)**必须提示**:服务端对这两种情况分别回 400 / 404,
// 客户端这边绝不能截断到 5 条、也不能把空名字那条悄悄丢掉。
DocumentDelivery.payload(names) ?: run {
rollback(localId, "附件引用不合法,没有发送")
return
}
}
when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
SendChannel.Ws -> {
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
if (!sent) rollback(localId, "发送失败,请重试")
}
SendChannel.Rest -> if (image == null) {
sendViaRest(localId, text, image = null)
sendViaRest(localId, text, image = null, documents = payload)
} else {
deliverImage(localId, text, image)
deliverImage(localId, text, image, payload)
}
}
}
/**
* 逐份把待发附件换成服务端存储名(`POST /api/v1/documents`,部件名 `file`、**保留扩展名**)。
*
* 三处刻意的取舍:
* 1. **一份一份传**(不是并发):上传失败要**立刻停**并把这一轮整个回滚,并发只会让后面
* 几份白传一遍,而服务端那边**没有删除端点**(`02/F7`)⇒ 多传一份就是多一份孤儿文件;
* 2. **字节现读**:`DraftDocument` 只带本地引用(见其 KDoc),最多 5×20 MiB 的字节不会
* 一直摆在 UI 状态里;读的时候用 [readBounded] 卡住上限,选完之后被换成超大文件也不会
* 先在内存里炸掉;
* 3. **`200 + deduped:true` 当成功**:[DocumentRepository] 已按 `apiCall` 的口径处理,
* 这里不做区分(同一份字节重复上传回的就是同一个 `name`)。
*
* @return 服务端存储名(顺序即附件顺序);任一份失败回 `null`(此时已经回滚并给过提示)。
*/
private suspend fun uploadDocuments(localId: String, documents: List<DraftDocument>): List<String>? {
val names = mutableListOf<String>()
documents.forEach { document ->
when (val read = readDocument(document)) {
is DocumentRead.TooLarge -> {
rollback(
localId,
"「${documentLabel(document)}」超过 ${DocumentAttachments.maxFileSizeLabel},没有发送",
)
return null
}
is DocumentRead.Unavailable -> {
rollback(localId, "读不到「${documentLabel(document)}」,请重新选择后再发")
return null
}
is DocumentRead.Ok -> {
val uploaded = container.documentRepository
.upload(read.bytes, document.displayName)
.getOrElse { e ->
rollback(localId, documentUploadFailureMessage(e))
return null
}
names += uploaded.name
}
}
}
return names
}
/**
* 读一份待发附件的字节。元数据里已经报了体积的就先按体积挡(省一次几 MB 的读),
* 其余边读边卡上限——**超限与读不到必须分开**,两者要用户做的事不一样。
*/
private suspend fun readDocument(document: DraftDocument): DocumentRead {
if (document.sizeBytes > DocumentAttachments.MAX_FILE_BYTES) return DocumentRead.TooLarge
return withContext(Dispatchers.IO) {
try {
val input = container.appContext.contentResolver.openInputStream(Uri.parse(document.uri))
?: return@withContext DocumentRead.Unavailable
input.use { stream ->
val bytes = readBounded(stream, DocumentAttachments.MAX_FILE_BYTES)
if (bytes == null) DocumentRead.TooLarge else DocumentRead.Ok(bytes)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
DocumentRead.Unavailable
}
}
}
/** 一条待发附件的元数据(SAF 回来的 URI → 显示名 + 字节数)。拿不到就留空 / 留 -1,不猜。 */
private fun draftDocumentFor(uri: String): DraftDocument {
val parsed = runCatching { Uri.parse(uri) }.getOrNull()
var name = ""
var size = DraftDocument.UNKNOWN_SIZE
if (parsed != null) {
runCatching {
container.appContext.contentResolver.query(parsed, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) name = cursor.getString(nameIndex).orEmpty()
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex)
}
}
}
}
return DraftDocument(
uri = uri,
displayName = name.ifBlank { DocumentAttachments.fallbackDocumentName(uri) },
sizeBytes = size,
)
}
/**
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
@@ -404,18 +602,32 @@ class ChatViewModel(
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
*/
private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) {
private suspend fun deliverImage(
localId: String,
text: String,
image: PreparedImage,
documents: List<DocumentRefDto>,
) {
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri)
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri, documents)
ImageDelivery.Upload -> container.uploadRepository
.uploadImage(image.bytes, image.mime)
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url) }
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url, documents) }
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
}
}
private suspend fun sendViaRest(localId: String, text: String, image: String?) {
/**
* 走 REST 发出这一轮(`stream:true`)。[documents] 是**已上传**的附件引用(空 = 没附件):
* 空列表在线上整键省略(`encodeDefaults = false`),所以不带附件的请求与加入该字段之前逐字节相同。
*/
private suspend fun sendViaRest(
localId: String,
text: String,
image: String?,
documents: List<DocumentRefDto> = emptyList(),
) {
val request = ChatRequest(
sessionId = sessionId,
text = text,
@@ -425,6 +637,7 @@ class ChatViewModel(
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
stream = true,
clientMsgId = localId,
documents = documents,
)
container.chatRepository
.send(request)
@@ -507,8 +720,9 @@ class ChatViewModel(
.fold(pending) { acc, message -> consumePendingFor(acc, message) }
/**
* 回滚乐观气泡:把文本 / 图片还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
* 空文本(纯图片)不覆盖用户当前正在输入的内容;图片照原样还给待发位,不必让用户重挑。
* 回滚乐观气泡:把文本 / 图片 / 附件还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
* 空文本(纯图片或纯附件)不覆盖用户当前正在输入的内容;图片与附件照原样还给待发位,
* 不必让用户重挑(附件已经上传过的那几份在服务端是孤儿,但本地引用仍然有效、可直接重发)。
*/
private fun rollback(localId: String, reason: String) {
_state.update { state ->
@@ -517,6 +731,7 @@ class ChatViewModel(
pending = state.pending.filterNot { it.localId == localId },
draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
draftImage = failed?.imageUri ?: state.draftImage,
draftDocuments = failed?.documents?.takeIf { it.isNotEmpty() } ?: state.draftDocuments,
error = reason,
)
}
@@ -778,3 +993,35 @@ private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
/** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */
private const val SHARE_NOTICE_MILLIS = 8 * 1000L
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
/** 附件的可读名字:显示名拿不到时用 URI,再拿不到就只说「附件」(不编造一个名字)。 */
private fun documentLabel(document: DraftDocument): String =
document.displayName.ifBlank { document.uri }.ifBlank { "附件" }
/**
* 附件上传失败的文案(纯函数,可单测)。
*
* 四个服务端机器可读拒绝码各有**不同的正确动作**,所以那句话由服务端说(含真实上限与原因),
* 客户端原样展示、**绝不自己编一个数字**:`415 unsupported_media_type` / `413 document_too_large`
* 是「换文件」,`400 too_many_documents` 是「少选几个」,`404 document_not_found` 是「重传」
* (见 [DocumentFailure])。401/403 也原样透出——那种情况下正确的动作是重新登录,
* 而不是「换一份文件」。
*
* 其余(网络 / 超时 / 未知码 / 5xx)用既有错误口径,并**点明是上传这一步**失败:否则用户看到
* 「无法连接到服务器」会以为整条消息没发出去,而实际上文字与附件是跟着一起回滚的。
*/
fun documentUploadFailureMessage(e: Throwable): String {
val error = (e as? CyreneException)?.error
return when (val failure = e.toDocumentFailure()) {
is DocumentFailure.UnsupportedType -> failure.message
is DocumentFailure.TooLarge -> failure.message
is DocumentFailure.TooMany -> failure.message
is DocumentFailure.NotFound -> failure.message
is DocumentFailure.Unreadable -> failure.message
is DocumentFailure.Forbidden -> failure.message
is DocumentFailure.Other -> "附件上传失败," + (error?.userMessage ?: "请重试")
}
}
@@ -1,13 +1,16 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
@@ -16,26 +19,38 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
/** 附件芯片排的最大宽度:与气泡同级,避免 5 枚芯片把整行铺满屏幕。 */
private val CHIP_ROW_MAX_WIDTH = 320.dp
/**
* 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `image` 非空时在气泡上方显示图片。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
@@ -55,6 +70,7 @@ fun MessageBubble(message: HistoryMessageDto) {
MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
DocumentChipRow(chips = message.documents.map(DocumentChips::of))
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
bubbles.forEachIndexed { index, bubble ->
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
@@ -67,15 +83,102 @@ fun MessageBubble(message: HistoryMessageDto) {
*
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
*/
@Composable
fun PendingBubble(item: PendingMessage) {
MessageGroup(isUser = true, footer = "发送中…") {
DocumentChipRow(chips = item.documents.map(DocumentChips::of))
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
/**
* 附件芯片排:横向可滚(最多 5 枚也不挤成两行),一枚一枚展示
* 「名字(中略,保住扩展名)/ 类型 · 体积 · 字数 · 状态」。
*
* [onRemove] 非空即带移除按钮——待发位(输入栏上方)要能反悔,历史气泡里则不给。
*
* 附件可能是空列表(绝大多数消息都是),此时**不占位、不留空白**。
*/
@Composable
fun DocumentChipRow(
chips: List<DocumentChipModel>,
modifier: Modifier = Modifier,
onRemove: ((Int) -> Unit)? = null,
) {
if (chips.isEmpty()) return
Row(
modifier = modifier
.widthIn(max = CHIP_ROW_MAX_WIDTH)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
chips.forEachIndexed { index, chip ->
DocumentChip(model = chip, onRemove = onRemove?.let { remove -> { remove(index) } })
}
}
}
/**
* 一枚附件芯片。
*
* 状态分两处说,**不许互相顶替**(契约 v0.59 §1.2):
* - `status:"unreadable"` = **昔涟读不到这个文件** → 整枚转灰 + 明写「读不到」;
* - `truncated:true` = **注入被预算截断**(正文照样进了上下文)→ 只加一句「注入已截断」。
*
* 未知 `status` 原样显示(开放集合,见 [DocumentChips.statusLabel])。
*/
@Composable
private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
if (model.unreadable) {
MiuixTheme.colorScheme.surfaceContainer
} else {
MiuixTheme.colorScheme.surfaceContainerHigh
},
)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = model.name,
style = MiuixTheme.textStyles.footnote1,
color = if (model.unreadable) {
MiuixTheme.colorScheme.onSurfaceVariantSummary
} else {
MiuixTheme.colorScheme.onSurfaceContainerHigh
},
maxLines = 1,
// 存储名是 32 位十六进制 + 扩展名,长名字必须中略——从尾部截会把扩展名切掉,
// 而扩展名正是这里唯一有语义的部分。
overflow = TextOverflow.MiddleEllipsis,
)
Text(
text = model.detail,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
onRemove?.let { remove ->
IconButton(onClick = remove) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除附件 ${model.name}",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
@Composable
fun StreamingBubble(streaming: StreamingState) {