merge: W42 —— 图片入口改多选,N 张图按既有切分落成 N 条消息

This commit is contained in:
AskaEth committed 2026-10-05 14:41:46 +08:00
commit 4355ebddea
7 files changed
+354 -52

No files matched your search

@@ -4,7 +4,7 @@ import java.io.ByteArrayOutputStream
import java.io.InputStream
/**
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImage` 平行的概念)。
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImages` 平行的概念)。
*
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
@@ -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 的原始字段提取内容。
*
@@ -315,11 +315,15 @@ fun ChatScreen(
}
var imageTarget by remember { mutableStateOf<ImageSource?>(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<String>,
documents: List<DraftDocument>,
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,15 +859,24 @@ 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<String>, onRemove: (Int) -> Unit) {
if (images.isEmpty()) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
images.forEachIndexed { index, uri ->
Box {
AsyncImage(
model = uri,
@@ -872,7 +886,7 @@ private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
.clip(RoundedCornerShape(10.dp)),
)
IconButton(
onClick = onClear,
onClick = { onRemove(index) },
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
@@ -883,6 +897,7 @@ private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
}
}
}
}
}
@Composable
@@ -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} 个")
@@ -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<StreamingLeg> = 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<String> = 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<String>) {
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-<uuid>`,
@@ -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)
@@ -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<DraftDocument>,
)
/** 一次发送的切分结果([imageSendPlan] 的返回值)。 */
data class ImageSendPlan(
/** 要投递的消息,顺序 = 用户选图的顺序。**至少一条**:没有图时它就是承载正文与附件的那条。 */
val messages: List<PlannedMessage>,
/** 超过 [ShareRules.MAX_IMAGES] 而**不会**发出去的张数(0 = 一张没少)。 */
val truncatedImages: Int,
) {
/** 这次真正会发出去的图片(顺序不变)。超限告知里说的「前 N 张」,N 就是它的长度。 */
val imagesToSend: List<String> 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<String>,
documents: List<DraftDocument>,
): 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,
)
}
@@ -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<String> = (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))
}
}
@@ -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)
}