From 5a9f52a0692c487bdeef1003b4467ff178022ee0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 14:41:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=9B=BE=E7=89=87=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E6=94=B9=E5=A4=9A=E9=80=89=EF=BC=8CN=20=E5=BC=A0?= =?UTF-8?q?=E5=9B=BE=E6=8C=89=E6=97=A2=E6=9C=89=E5=88=87=E5=88=86=E8=90=BD?= =?UTF-8?q?=E6=88=90=20N=20=E6=9D=A1=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-05 定案「那肯定要多选」:图片按钮从 PickVisualMedia(单选、回一个 Uri)换成 PickMultipleVisualMedia(系统 Photo Picker 的多选形态,仍然零权限,不退回落 SAF / 存储权限)。 文件按钮那条 OpenMultipleDocuments 一个字没动。 为什么复用分享路径那一段(不另起一套发送逻辑): 分享落地(ChatViewModel.sendShared)早就把「一条消息只能带一张图 ⇒ N 张图 = N 条消息」做完了, 那段切分本来内联在它自己的 forEach 里,不是可复用的纯函数。这次把判据抽成纯函数 imageSendPlan (ui/chat/ImageSendPlan.kt,跟 SharePayload.kt 与 SendQueue.kt 一样的写法,可单测),投递抽成 emitPlannedMessages(私有),输入栏那条路与分享落地那条路共用同一段:两处各写一份循环,迟早会在 「哪条带正文 / 哪条带附件 / 哪条清草稿 / 超过上限怎么办」上分叉。每张图各自的解码 / 缩放 / 重编码 与「内联还是上传」的取舍仍旧逐张走既有实现(deliver 与 deliverImage),一行没改。 为什么 N 张 = N 条(而不是一条带多张): 契约里 chat.image 是单图字段,一次上行只能带一张,所以多选进来的 N 张只能落成 N 条消息: 第一条带正文 + 第一张图 + 全部附件,其余每条一张图,顺序 = 用户在多选器里选的顺序。契约与服务端 一个字没动。 上限与告知沿用分享路径的口径: 上限仍是 ShareRules.MAX_IMAGES(4)。超限那几张不静默丢,如实告知;那句文案抽成 ShareRules.truncatedImagesNotice,分享入口与选图入口共用同一句(逐字未改,ShareNoticeTest 钉住)。 系统选择器自己会把可选张数卡在 4 上,只有回落成 ACTION_OPEN_DOCUMENT 的设备(它表达不了最多几张) 以及「草稿里已有几张、再选几张」这两种情形会真超,那时由入口与投递两处说出同一句。 其余口径与今天一致: 输入栏最多攒 4 张(到顶后不再显示图片入口),每张各自可移除;取消选择什么都不做; 发送时第一条带正文并清草稿(N 条是一次发送,草稿只清一次),失败的那一条只把自己那一张还给输入栏。 一次发送切出的多条里只有第一条套用「她忙时发消息」偏好——第 2 条起声明打断就是打断自己刚起的 上一轮(与分享路径不声明同一条理由),N = 1 时与今天逐字节相同。 顺带: - ChatUiState.draftImage(String?)改为 draftImages(List),rollbackPending 把失败那张补回 待发列表(不再覆盖),输入栏渲染成每张各自带移除键的一排预览; - QueueFullOutcomeTest 里断言单图的四处改成新事实(断言一条没删); - 新增 ImageSendPlanTest,覆盖 N = 0 / 1 / 2 / 4 / 5(超限),并钉住附件只跟第一条、顺序不重排、 超限那句与分享路径同一句。 --- .../cyrene/domain/chat/DocumentAttachment.kt | 2 +- .../yeij/cyrene/domain/share/SharePayload.kt | 12 ++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 79 ++++++---- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 149 ++++++++++++++---- .../top/yeij/cyrene/ui/chat/ImageSendPlan.kt | 70 ++++++++ .../yeij/cyrene/ui/chat/ImageSendPlanTest.kt | 113 +++++++++++++ .../cyrene/ui/chat/QueueFullOutcomeTest.kt | 11 +- 7 files changed, 369 insertions(+), 67 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlan.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachment.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachment.kt index c22ef72..39c6e65 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachment.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachment.kt @@ -4,7 +4,7 @@ import java.io.ByteArrayOutputStream import java.io.InputStream /** - * 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImage` 平行的概念)。 + * 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImages` 平行的概念)。 * * 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它; * 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt index b8bbfeb..0cd049b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt @@ -151,6 +151,18 @@ object ShareRules { */ const val MAX_DOCUMENTS = 5 + /** + * 图片超限的那一句(**纯函数**):**分享入口与图片选择入口共用同一句**,所以它只有一个出处。 + * + * 两个入口的上限本来就是同一个数([MAX_IMAGES])⇒ 告知也必须是同一句:各写一句,迟早会出现 + * 「分享说只发前 4 张、选图说只发前 4 张(措辞不同)」这种同一件事的两种说法。 + * + * @param kept 这次真正发出去的张数(`SharedContent.images` 的长度 / `ImageSendPlan.imagesToSend` 的长度) + * @param dropped 被上限截掉的张数;0 ⇒ `null`(一张没少就没什么要说的,说话才是噪声) + */ + fun truncatedImagesNotice(kept: Int, dropped: Int): String? = + if (dropped > 0) "分享的图片超过 $MAX_IMAGES 张,只发送前 $kept 张" else null + /** * 从分享 Intent 的原始字段提取内容。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 837e3f2..6619d3c 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -315,11 +315,15 @@ fun ChatScreen( } var imageTarget by remember { mutableStateOf(null) } - // 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。 + // 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。 + // 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上, + // 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上 + // (老系统 / 没有 Photo Picker 时它退化成 `ACTION_OPEN_DOCUMENT`,那份 Intent 只能表达 + // 「可多选」、表达不了「最多几张」)。真超了由 VM 如实告知、**绝不静默截断**(见 onImagesPicked)。 // 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。 val imagePicker = rememberLauncherForActivityResult( - ActivityResultContracts.PickVisualMedia(), - ) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } } + ActivityResultContracts.PickMultipleVisualMedia(ShareRules.MAX_IMAGES), + ) { uris -> viewModel.onImagesPicked(uris.map { it.toString() }) } // 文件选择器(SAF `OpenMultipleDocuments`):同样不需要任何存储权限——授权是**逐次**给的, // 因此这条 URI 的读授权只活到本 Activity 任务结束;而发送可能发生在登录之后(分享落地) @@ -451,7 +455,7 @@ fun ChatScreen( bottomBar = { InputBar( value = state.draft, - imageUri = state.draftImage, + images = state.draftImages, documents = state.draftDocuments, busyText = busyLabel(state), onValueChange = viewModel::onDraftChange, @@ -461,7 +465,7 @@ fun ChatScreen( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), ) }, - onClearImage = viewModel::clearDraftImage, + onRemoveImage = viewModel::removeDraftImage, onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) }, onRemoveDocument = viewModel::removeDraftDocument, onPreviewDocument = { index -> @@ -756,13 +760,13 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null) @Composable private fun InputBar( value: String, - imageUri: String?, + images: List, documents: List, busyText: String, onValueChange: (String) -> Unit, onSend: () -> Unit, onPickImage: () -> Unit, - onClearImage: () -> Unit, + onRemoveImage: (Int) -> Unit, onPickDocument: () -> Unit, onRemoveDocument: (Int) -> Unit, onPreviewDocument: (Int) -> Unit, @@ -773,7 +777,7 @@ private fun InputBar( .imePadding() .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), ) { - imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) } + DraftImagePreviews(images = images, onRemove = onRemoveImage) // 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。 // 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。 DraftDocumentChips( @@ -782,8 +786,9 @@ private fun InputBar( onClick = onPreviewDocument, ) Row(verticalAlignment = Alignment.Bottom) { - // 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。 - if (imageUri == null) { + // 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个 + // 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。 + if (images.size < ShareRules.MAX_IMAGES) { IconButton(onClick = onPickImage) { Icon( imageVector = MiuixIcons.Image, @@ -815,9 +820,9 @@ private fun InputBar( Spacer(Modifier.width(8.dp)) Button( onClick = onSend, - // 附件可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可, + // 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可, // 纯附件回合服务端**支持**)。 - enabled = value.isNotBlank() || imageUri != null || documents.isNotEmpty(), + enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(), ) { Text("发送") } @@ -854,32 +859,42 @@ private fun DraftDocumentChips( ) } -/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */ +/** + * 待发图片的一排本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 + * + * 多选之后不再是「一张 + 一枚移除键」:每一张各自带一枚移除键,**顺序就是发送顺序** + * (第一条消息带第一张,见 [imageSendPlan])。刻意不做横向滚动——上限 4 张,一排放得下; + * 也不做「只显示前 N 张 + 一个计数」,那会让人以为自己选的某张不见了。 + */ @Composable -private fun DraftImagePreview(uri: String, onClear: () -> Unit) { +private fun DraftImagePreviews(images: List, onRemove: (Int) -> Unit) { + if (images.isEmpty()) return Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(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, + images.forEachIndexed { index, uri -> + Box { + AsyncImage( + model = uri, + contentDescription = "待发送的图片", + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(10.dp)), ) + IconButton( + onClick = { onRemove(index) }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + imageVector = MiuixIcons.Basic.Close, + contentDescription = "移除图片", + tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } } } } @@ -995,7 +1010,9 @@ fun shareNoticeFor(share: SharedContent): String? { if (!share.worthDelivering) return null val parts = buildList { if (share.truncatedImages > 0) { - add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张") + // 这一句只有一处出处([ShareRules.truncatedImagesNotice]):图片选择入口与分享入口的 + // 上限是同一个数,所以告知也共用同一句。 + ShareRules.truncatedImagesNotice(share.images.size, share.truncatedImages)?.let(::add) } if (share.truncatedDocuments > 0) { add("分享的文件超过 ${ShareRules.MAX_DOCUMENTS} 个,只发送前 ${share.documents.size} 个") diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 94928db..3c4046d 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -69,6 +69,7 @@ import top.yeij.cyrene.domain.chat.withSteeredMessage import top.yeij.cyrene.domain.chat.withTurnBegin import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.StreamingContentParser +import top.yeij.cyrene.domain.share.ShareRules import top.yeij.cyrene.domain.share.SharedContent import java.util.UUID @@ -355,7 +356,9 @@ fun ChatUiState.rollbackPending( return copy( pending = pending.filterNot { it.localId == localId }, draft = failed?.text?.ifBlank { draft } ?: draft, - draftImage = failed?.imageUri ?: draftImage, + // 挂在失败那条气泡上的图片**补回**待发列表(而不是覆盖):N 张图是 N 条消息,各失败各的, + // 谁失败谁把自己那一张还给输入栏,没失败的那几条照旧在飞。 + draftImages = failed?.imageUri?.let { draftImages + it } ?: draftImages, draftDocuments = failed?.documents?.takeIf { it.isNotEmpty() } ?: draftDocuments, error = reason, errorIsQueueFullNotice = queueFullNotice, @@ -579,10 +582,17 @@ data class ChatUiState( */ val streams: List = emptyList(), val draft: String = "", - /** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */ - val draftImage: String? = null, /** - * 已选好、还没发出去的文档附件(与 [draftImage] 平行的概念)。发送失败时与正文、图片一起 + * 已选好、还没发出去的图片(本地 URI 字符串),**顺序 = 用户选图的顺序**(也就是发送顺序)。 + * + * 它是一个列表(W42 主人定案:图片入口要能多选):契约里 `chat.image` 是**单图字段**,所以 + * N 张图要落成 N 条消息,切分判据是纯函数 [imageSendPlan](第一条带正文 + 第一张图 + 全部附件)。 + * 上限 [ShareRules.MAX_IMAGES] 在入口处就压住(免得攒得没边),发送那处照旧再判一次—— + * 规则本体在 [imageSendPlan],入口只是不许超。 + */ + val draftImages: List = emptyList(), + /** + * 已选好、还没发出去的文档附件(与 [draftImages] 平行的概念)。发送失败时与正文、图片一起 * 回到这里(见 [ChatViewModel.rollback]),所以它们必须一路带着**本地引用**—— * 重新上传的代价是几 MB 的流量与一次白等。 */ @@ -935,11 +945,37 @@ class ChatViewModel( fun onDraftChange(text: String) = _state.update { it.copy(draft = text) } - /** 选好待发图片(本地 `content://` URI);传 null 或空白串即清除。 */ - fun onImagePicked(uri: String?) = - _state.update { it.copy(draftImage = uri?.takeIf { value -> value.isNotBlank() }) } + /** + * 选好待发图片(系统相册多选回来的本地 `content://` URI,**顺序即发送顺序**)。 + * + * 与附件那条([onDocumentsPicked])同一形态:**追加**在已有待发图片之后(分两次选的图也该按 + * 选的先后发出去),并把总数压在 [ShareRules.MAX_IMAGES] 以内。 + * + * 超限的那几张**不静默丢**:说一句。文案与判据都沿用分享路径那一句 + * ([ShareRules.truncatedImagesNotice])——两个入口的上限本来就是同一个数,各写一句迟早会 + * 变成两种口径。 + * + * 空选择(用户在选择器里取消)时 [uris] 是空的:**什么都不做**(与今天单选回调拿到 null 时 + * 一致:不弹气泡、不动草稿、不说一句话)。 + */ + fun onImagesPicked(uris: List) { + val picked = uris.map(String::trim).filter(String::isNotEmpty) + if (picked.isEmpty()) return + val current = _state.value.draftImages + val kept = (current + picked).take(ShareRules.MAX_IMAGES) + _state.update { it.copy(draftImages = kept) } + ShareRules.truncatedImagesNotice(kept.size, current.size + picked.size - kept.size) + ?.let(::showShareNotice) + } - fun clearDraftImage() = _state.update { it.copy(draftImage = null) } + /** 移除第 [index] 张待发图片(越界原样不动,UI 的移除按钮不必自己判界)。 */ + fun removeDraftImage(index: Int) = _state.update { state -> + if (index !in state.draftImages.indices) { + state + } else { + state.copy(draftImages = state.draftImages.filterIndexed { i, _ -> i != index }) + } + } /** * 选好待发附件(SAF `OpenDocument` / `OpenMultipleDocuments` 回来的 URI)。 @@ -1136,16 +1172,29 @@ class ChatViewModel( * * 附件**一律先上传**换服务端存储名(`POST /api/v1/documents`),再随 `chat.documents` 引用 * 发出,上行只带 `name`(见 [DocumentDelivery.payload] 与 [uploadDocuments])。 + * + * 输入栏里可以选**多张**图(W42 主人定案:「那肯定要多选」):契约里 `chat.image` 是单图字段, + * 所以 N 张 = N 条消息,切分判据是纯函数 [imageSendPlan],投递那一段与分享落地**共用同一个 + * 函数**([emitPlannedMessages])——两条路各写一份循环,迟早会在「哪条带正文 / 哪条带附件 / + * 超过上限怎么办」上分叉。 */ fun send() { val snapshot = _state.value val text = snapshot.draft.trim() - val imageUri = snapshot.draftImage + val images = snapshot.draftImages val documents = snapshot.draftDocuments // 空输入的四件事都不发生(不上屏、不排队、不发帧、不说话)——判据与投递口**同一个函数** // ([hasSendableContent]),免得入口与投递口对「有没有东西可发」各有一套说法。 - if (!hasSendableContent(text, imageUri, documents)) return - viewModelScope.launch { emitAndDeliver(text, imageUri, documents, clearDraft = true) } + // 这个判据只问「有没有至少一张图」,切分要几张由 [imageSendPlan] 回答,两件事各归各的。 + if (!hasSendableContent(text, images.firstOrNull(), documents)) return + viewModelScope.launch { + emitPlannedMessages( + plan = imageSendPlan(text, images, documents), + clearDraft = true, + applySendPreference = true, + localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX, + ) + } } /** @@ -1157,6 +1206,8 @@ class ChatViewModel( * 非图片文件([SharedContent.documents])没有这个限制——它们与正文、图片挂在**同一条** * 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。 * 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。 + * 这套切分与顺序**不是这里独有的**:输入栏的图片入口也能多选(W42),两者走的是同一个纯函数 + * ([imageSendPlan])与同一段投递([emitPlannedMessages])。 * * ## 「分享进来的」这条事实写在幂等键上(角标的唯一判据) * 这里给每一条投递传 `localIdPrefix = "share-"`,于是铸出来的键形如 `share-`, @@ -1170,7 +1221,7 @@ class ChatViewModel( * - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。 * * ## 分享不声明(`applySendPreference` 恒为 false) - * 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,上面那个 `forEach` 会逐条投递, + * 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递, * 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。 * 两种声明在这里**都**是错的: * @@ -1227,24 +1278,12 @@ class ChatViewModel( } // 每一条都带 `share-` 前缀(多张图 = 多条消息,它们**都是**分享进来的): // 角标由此在渲染层长出来,正文照旧一字未改。 - emitAndDeliver( - text, - share.images.firstOrNull(), - files, + emitPlannedMessages( + plan = imageSendPlan(text, share.images, files), clearDraft = false, applySendPreference = false, localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX, ) - share.images.drop(1).forEach { - emitAndDeliver( - "", - it, - emptyList(), - clearDraft = false, - applySendPreference = false, - localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX, - ) - } } } @@ -1309,14 +1348,63 @@ class ChatViewModel( } } + /** + * 按一份切分([ImageSendPlan])把这次发送**逐条投递**([send] 与 [sendShared] 共用这一段)。 + * + * ## 为什么两条路共用 + * 「一次发送带 N 张图」的规则只有一条(契约里 `chat.image` 是单图字段 ⇒ N 张 = N 条),而两条路 + * 都可能有 N 张:输入栏那条([send],草稿里就是多张图)与分享落地那条([sendShared])。切分判据 + * 是纯函数 [imageSendPlan](可单测),投递**只此一段**:各写一份循环,迟早会在「哪条带正文 / + * 哪条带附件 / 哪条清草稿」上分叉。 + * + * ## 顺序、静默 + * 顺序就是 [ImageSendPlan.messages] 的顺序(= 用户选图的顺序)——同一个协程里依次投递, + * 各投各的会让它们互相赛跑、落库顺序反过来。上限之外的那几张**不静默丢**:说一句 + * (文案 [ShareRules.truncatedImagesNotice],与分享路径同一句),而且**在投递之前**说—— + * 它说的是「这次只发前几张」,不是一次失败,用户该在消息上屏的同时看到它。 + * + * ## 偏好至多声明一次(落在第一条上) + * 「她忙时发消息」那个偏好会逐条声明打断 / 并行,而一次发送切出来的 N 条**不是 N 次发送**: + * 第 2 条起声明打断,就是在**打断自己刚起的上一轮**(最后只剩最后一张图的回答,前面几条的回合 + * 被取消——理由与 [sendShared] 的「分享不声明」逐字相同)。所以这一次发送**至多声明一次**, + * 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则 + * 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。 + * + * @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送, + * 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。 + * @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的 + * 那条传 false(分享不是用户按的发送键,从不声明)。 + * @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。 + */ + private suspend fun emitPlannedMessages( + plan: ImageSendPlan, + clearDraft: Boolean, + applySendPreference: Boolean, + localIdPrefix: String, + ) { + ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages) + ?.let(::showShareNotice) + plan.messages.forEachIndexed { index, message -> + emitAndDeliver( + text = message.text, + imageUri = message.imageUri, + documents = message.documents, + clearDraft = clearDraft && index == 0, + applySendPreference = applySendPreference && index == 0, + localIdPrefix = localIdPrefix, + ) + } + } + /** * 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。 * * @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条; * 分享落地是替用户发一条他自己录进来的内容,草稿不是它。 * @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明: - * `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;只有 [sendShared] - * 传 false——它是批量动作,批量里逐条声明只会自相干扰(两种声明各自的理由见 [sendShared])。 + * `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false: + * [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些 + * ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。 * @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认), * [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在 * 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动** @@ -1354,7 +1442,7 @@ class ChatViewModel( it.copy( pending = it.pending + PendingMessage(localId, text, imageUri, documents), draft = if (clearDraft) "" else it.draft, - draftImage = if (clearDraft) null else it.draftImage, + draftImages = if (clearDraft) emptyList() else it.draftImages, draftDocuments = if (clearDraft) emptyList() else it.draftDocuments, error = null, ) @@ -1411,7 +1499,8 @@ class ChatViewModel( // // 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。 // [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍 - // 见 [sendShared] 的「分享不声明」一段)。 + // 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true + // (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。 val mode = currentInterruptMode() val busy = _state.value.busyMode != null if (applySendPreference) interruptRunningTurn(mode, busy) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlan.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlan.kt new file mode 100644 index 0000000..19868be --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlan.kt @@ -0,0 +1,70 @@ +package top.yeij.cyrene.ui.chat + +import top.yeij.cyrene.domain.chat.DraftDocument +import top.yeij.cyrene.domain.share.ShareRules + +/** + * 一次发送切出来的一条消息([imageSendPlan] 的元素)。 + * + * [imageUri] 是这条消息要带的那张图(契约里 `chat.image` 是**单图字段**);[documents] 是这条 + * 消息要带的非图片附件(`chat.documents` 是数组,但一次只挂在一条消息上)。 + */ +data class PlannedMessage( + val text: String, + val imageUri: String?, + val documents: List, +) + +/** 一次发送的切分结果([imageSendPlan] 的返回值)。 */ +data class ImageSendPlan( + /** 要投递的消息,顺序 = 用户选图的顺序。**至少一条**:没有图时它就是承载正文与附件的那条。 */ + val messages: List, + /** 超过 [ShareRules.MAX_IMAGES] 而**不会**发出去的张数(0 = 一张没少)。 */ + val truncatedImages: Int, +) { + /** 这次真正会发出去的图片(顺序不变)。超限告知里说的「前 N 张」,N 就是它的长度。 */ + val imagesToSend: List get() = messages.mapNotNull { it.imageUri } +} + +/** + * 「一次发送(正文 + N 张图 + 若干附件)要切成几条消息、每条带什么、超限几张」——**纯函数**, + * 与 Android 无关、可单测(见 `ImageSendPlanTest`)。 + * + * ## 为什么 N 张图 = N 条消息 + * 契约里 `chat.image` 是**单图字段**:一次上行只能带一张。所以多选进来的 N 张只能落成 N 条消息, + * 顺序 = 用户选图的顺序,而**第一条**带上正文与全部附件——正文是用户打的这一段字、附件是挂在 + * 这条消息上的数组,两者本来就只该出现一次。第二条起每条只有一张图、正文为空:空正文的那几条 + * 不会凭空替用户多说一个字。 + * + * ## 上限只在这里判一次 + * 上限 [ShareRules.MAX_IMAGES] 与分享路径用**同一个数**(4):两条路都是「一次最多接住几张图」。 + * 多出来的张数由 [ImageSendPlan.truncatedImages] 如实报出来,调用方据此说一句 + * (文案 [ShareRules.truncatedImagesNotice])——**绝不静默截断**。 + * + * ## N = 0 为什么也回一条 + * 没有图时这一次发送仍然是一条普通消息(正文 + 附件),它是同一条规则的空形态。把「有没有东西 + * 可发」留给 [hasSendableContent] 判,这里只回答「要切几条、每条带什么」——两个判据各管一件事, + * 谁也不替谁下结论。空串 / 重复项由调用方在入口处清掉(草稿与分享两条入口本来就各自清了)。 + */ +fun imageSendPlan( + text: String, + images: List, + documents: List, +): ImageSendPlan { + val kept = images.take(ShareRules.MAX_IMAGES) + val messages = if (kept.isEmpty()) { + listOf(PlannedMessage(text = text, imageUri = null, documents = documents)) + } else { + kept.mapIndexed { index, uri -> + PlannedMessage( + text = if (index == 0) text else "", + imageUri = uri, + documents = if (index == 0) documents else emptyList(), + ) + } + } + return ImageSendPlan( + messages = messages, + truncatedImages = images.size - kept.size, + ) +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt new file mode 100644 index 0000000..ce4d8ef --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt @@ -0,0 +1,113 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.domain.chat.DraftDocument +import top.yeij.cyrene.domain.share.ShareRules + +/** + * 「一次发送带 N 张图」的切分判据([imageSendPlan])。 + * + * 背景(W42 主人定案:「那肯定要多选」):契约里 `chat.image` 是**单图字段**,所以 N 张图 = N 条 + * 消息,第一条带正文 + 第一张图 + 全部附件,其余每条一张图。切错的两种样子用户都看得见——正文挂错 + * 条(第二张图把用户打的那段字带走了)、图少发一张(选了几张、发出去几张对不上)。而它本身是纯 + * 函数:不需要组合界面,就能把 N = 0 / 1 / 2 / 4(正好到顶)/ 5(超限)逐条穷举。 + * + * 顺带钉住超限的那句告知:它与分享路径**同一句**([ShareRules.truncatedImagesNotice]),因为两个 + * 入口的上限本来就是同一个数。 + */ +class ImageSendPlanTest { + + private val doc = DraftDocument(uri = "content://doc", displayName = "笔记.md") + + private fun images(n: Int): List = (1..n).map { "content://img$it" } + + @Test + fun `no image is one plain message carrying the text and the documents`() { + val plan = imageSendPlan(text = "看这个", images = emptyList(), documents = listOf(doc)) + + assertEquals(1, plan.messages.size) + assertEquals("看这个", plan.messages.single().text) + assertNull(plan.messages.single().imageUri) + assertEquals(listOf(doc), plan.messages.single().documents) + assertEquals(0, plan.truncatedImages) + } + + @Test + fun `an empty send still plans one message - whether there is anything to send is a different judge`() { + // N = 0 且三样都空:这条规则照旧回一条(它是同一条规则的空形态)。「有没有东西可发」由 + // hasSendableContent 判——两个判据各管一件事,谁也不替谁下结论。 + val plan = imageSendPlan(text = "", images = emptyList(), documents = emptyList()) + + assertEquals(1, plan.messages.size) + assertTrue(plan.imagesToSend.isEmpty()) + assertEquals(0, plan.truncatedImages) + } + + @Test + fun `one image stays one message with the text and the documents on it`() { + val plan = imageSendPlan(text = "带一张", images = images(1), documents = listOf(doc)) + + assertEquals(1, plan.messages.size) + assertEquals("带一张", plan.messages.single().text) + assertEquals("content://img1", plan.messages.single().imageUri) + assertEquals(listOf(doc), plan.messages.single().documents) + assertEquals(listOf("content://img1"), plan.imagesToSend) + } + + @Test + fun `two images become two messages and only the first carries the text and the documents`() { + val plan = imageSendPlan(text = "两张", images = images(2), documents = listOf(doc)) + + assertEquals(2, plan.messages.size) + assertEquals(listOf("两张", ""), plan.messages.map { it.text }) + assertEquals(listOf("content://img1", "content://img2"), plan.imagesToSend) + assertEquals(listOf(doc), plan.messages[0].documents) + // 附件只随第一条:它本来就挂在**这条**消息上(`chat.documents` 是数组,但不是每条各一份)。 + assertTrue(plan.messages[1].documents.isEmpty()) + assertEquals(0, plan.truncatedImages) + } + + @Test + fun `the order the user picked is the order the messages go out`() { + val plan = imageSendPlan(text = "四张", images = images(4), documents = emptyList()) + + assertEquals(4, plan.messages.size) + assertEquals(images(4), plan.imagesToSend) + assertEquals("四张", plan.messages.first().text) + // 第二条起正文为空:不替用户多说一个字,也不把上一张的配文复制一遍。 + assertTrue(plan.messages.drop(1).all { it.text.isEmpty() }) + } + + @Test + fun `four images is exactly the cap and nothing is dropped`() { + val plan = imageSendPlan(text = "到顶了", images = images(ShareRules.MAX_IMAGES), documents = emptyList()) + + assertEquals(ShareRules.MAX_IMAGES, plan.messages.size) + assertEquals(ShareRules.MAX_IMAGES, plan.imagesToSend.size) + assertEquals(0, plan.truncatedImages) + } + + @Test + fun `five images keeps the first four and reports one dropped - never silently`() { + val plan = imageSendPlan(text = "超了一张", images = images(5), documents = listOf(doc)) + + assertEquals(ShareRules.MAX_IMAGES, plan.messages.size) + assertEquals(images(4), plan.imagesToSend) + assertEquals(1, plan.truncatedImages) + // 「只发送前 N 张」里的 N 就是真发出去的那几张(告知那句文案拿它拼)。 + assertEquals( + "分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${ShareRules.MAX_IMAGES} 张", + ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages), + ) + } + + @Test + fun `the over-limit notice says nothing when nothing was dropped`() { + // 一张没少时说话才是噪声:这正是「分享一条正常的图什么都不说」的那条边界。 + assertNull(ShareRules.truncatedImagesNotice(1, 0)) + assertNull(ShareRules.truncatedImagesNotice(ShareRules.MAX_IMAGES, 0)) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueueFullOutcomeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueueFullOutcomeTest.kt index 93242bd..f6dcea7 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueueFullOutcomeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueueFullOutcomeTest.kt @@ -46,7 +46,7 @@ class QueueFullOutcomeTest { val state = ChatUiState( pending = listOf(pending("local-a", text = "这条", imageUri = "content://img", documents = listOf(draft))), draft = "用户正在输入的下一条", - draftImage = null, + draftImages = emptyList(), draftDocuments = emptyList(), error = null, ) @@ -59,7 +59,7 @@ class QueueFullOutcomeTest { // 的消息再发一遍(而且会覆盖他正在输入的内容)。 assertTrue(after.pending.isEmpty()) assertEquals("用户正在输入的下一条", after.draft) - assertNull(after.draftImage) + assertTrue(after.draftImages.isEmpty()) assertTrue(after.draftDocuments.isEmpty()) assertNull(after.error) } @@ -80,7 +80,7 @@ class QueueFullOutcomeTest { val state = ChatUiState( pending = listOf(pending("local-a", text = "被拒的正文", imageUri = "content://img", documents = listOf(draft))), draft = "", - draftImage = null, + draftImages = emptyList(), draftDocuments = emptyList(), ) @@ -90,7 +90,8 @@ class QueueFullOutcomeTest { assertTrue(after.pending.isEmpty()) assertEquals("被拒的正文", after.draft) - assertEquals("content://img", after.draftImage) + // N 张图 = N 条消息之后,失败的那一条只把自己那一张还给输入栏(别的几条各自的图不归它管)。 + assertEquals(listOf("content://img"), after.draftImages) assertEquals(listOf(draft), after.draftDocuments) assertEquals(QUEUE_FULL_MESSAGE, after.error) } @@ -107,7 +108,7 @@ class QueueFullOutcomeTest { ) assertEquals("正在输入", after.draft) - assertEquals("content://img", after.draftImage) + assertEquals(listOf("content://img"), after.draftImages) assertEquals(QUEUE_FULL_MESSAGE, after.error) }