merge: W9 — 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)
This commit is contained in:
commit
ab76798bff
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) {
|
||||
|
||||
Reference in new issue
Block a user