From 30180ab1e5e13a3b198c5bfd09aa47f10b264cf0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 18:43:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=96=87=E6=A1=A3=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E6=8E=A5=E5=88=B0=20UI=20=E4=B8=8E=E5=8F=91=E9=80=81?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=EF=BC=88=E9=80=89=E6=96=87=E4=BB=B6=20/=20?= =?UTF-8?q?=E5=BE=85=E5=8F=91=E8=8A=AF=E7=89=87=20/=20chat.documents=20/?= =?UTF-8?q?=20=E5=88=86=E4=BA=AB=E5=88=86=E6=B5=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 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 错误)。 --- .../top/yeij/cyrene/app/AppContainer.kt | 31 ++ .../kotlin/top/yeij/cyrene/app/ShareIntake.kt | 88 +++++- .../cyrene/domain/chat/DocumentAttachment.kt | 189 +++++++++++ .../yeij/cyrene/domain/chat/DocumentChip.kt | 113 +++++++ .../cyrene/domain/chat/DocumentDelivery.kt | 22 +- .../yeij/cyrene/domain/share/SharePayload.kt | 143 +++++++-- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 81 ++++- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 297 ++++++++++++++++-- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 103 ++++++ .../domain/chat/DocumentAttachmentsTest.kt | 206 ++++++++++++ .../cyrene/domain/chat/DocumentChipTest.kt | 170 ++++++++++ .../domain/chat/DocumentDeliveryTest.kt | 37 +++ .../domain/share/ShareExtractionTest.kt | 150 ++++++++- .../ui/chat/DocumentUploadFailureTest.kt | 103 ++++++ 14 files changed, 1668 insertions(+), 65 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachment.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachmentsTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/DocumentUploadFailureTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 1a9ef68..af39357 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -9,12 +9,14 @@ import top.yeij.cyrene.core.prefs.SessionPreferences import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ChatApi +import top.yeij.cyrene.data.api.DocumentsApi import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.PersonaApi import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.UploadApi import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.ChatRepository +import top.yeij.cyrene.data.repository.DocumentRepository import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MetaRepository import top.yeij.cyrene.data.repository.PersonaRepository @@ -105,6 +107,35 @@ class AppContainer(context: Context) { val uploadRepository: UploadRepository get() = UploadRepository(uploadApi) + private var cachedDocumentsApi: DocumentsApi? = null + private var cachedDocumentsBaseUrl: String? = null + + /** + * 文档存储端点(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节); + * 与 [chatApi] **必须同源**(同一 baseUrl、同一 token/401 处理)——上传拿到的存储名就是要交给 + * 同一个后端的 `POST /api/v1/chat` 的 `documents[].name`,两条链路不在同一台机器上就必然 404。 + * + * Bearer 与 401 续期不用在这里做额外事:[ApiClientFactory.create] 按 **OkHttpClient** + * 挂 `AuthInterceptor`,文档读侧那点「与图片读侧刻意相反」的鉴权要求因此天然满足。 + */ + val documentsApi: DocumentsApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedDocumentsApi + if (cached != null && cachedDocumentsBaseUrl == baseUrl) return cached + val created = ApiClientFactory.create( + baseUrl = baseUrl, + service = DocumentsApi::class.java, + tokenProvider = authManager::currentToken, + unauthorizedPolicy = authManager.unauthorizedPolicy, + ) + cachedDocumentsBaseUrl = baseUrl + cachedDocumentsApi = created + return created + } + + val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi) + private var cachedAdminApi: AdminApi? = null private var cachedAdminBaseUrl: String? = null diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt index 526da05..7e697c1 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt @@ -3,14 +3,17 @@ package top.yeij.cyrene.app import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.OpenableColumns import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.flow.StateFlow +import top.yeij.cyrene.domain.chat.DocumentAttachments import top.yeij.cyrene.domain.share.PendingShareHolder import top.yeij.cyrene.domain.share.ShareRules import top.yeij.cyrene.domain.share.SharedContent +import top.yeij.cyrene.domain.share.SharedDocument import java.io.File import java.util.UUID @@ -26,13 +29,28 @@ import java.util.UUID * ## 图片为什么在收到的那一刻就落盘 * `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页) * 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`, - * 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。 + * 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。文件(非图片)同理。 + * + * ## 分流:图片还是文件 + * 逐条 URI 看 `ContentResolver.getType`(判定见 [top.yeij.cyrene.domain.share.classifySharedStream]): + * MIME 以 `image/` 开头走既有图片路,**其余一律走文档路**。此前一律当图片拷、当图片解码, + * 一份 `.md` 因此只有两种结局——「图片处理失败」或者被当成一张读不出来的图。 + * + * ⚠️ **Manifest 刻意不声明全通配或文本通配 MIME**:后端**明令拒绝** `text/html` / SVG / XML / + * 压缩包 / 脚本,放宽声明会造出一堆「点进去必然失败」的入口。所以这里能接到的**文件**只有 + * 系统按 `text/plain` 投过来那些(`.txt` / `.md` / `.log` 这类文件管理器确实会报 `text/plain`); + * 报成 `text/markdown` 之类的分享**根本不会**出现在 Cyrene 的分享菜单里。 + * + * ⚠️ **provider 不报 MIME(`getType` 回 null)时按文档路走**,这是刻意的:本端不猜类型, + * 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、 + * 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。 * * ## 清理 * 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以 * 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到 * 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。 - * 目录因此以「一次进程生命周期内的分享次数 × 4」为界,另有系统的 cache 回收兜底。 + * 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,见 [ShareRules]), + * 另有系统的 cache 回收兜底。 */ object ShareIntake { @@ -64,30 +82,41 @@ object ShareIntake { /** * 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。 * - * 提取是纯函数([ShareRules.extract]),拷图是重活,所以在 IO 线程做:`content://` 的读 + * 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读 * 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。 */ fun accept(context: Context, intent: Intent) { + val appContext = context.applicationContext val extracted = ShareRules.extract( action = intent.action, text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT), stream = intent.streamUri(), streams = intent.streamUris(), clipUris = intent.clipUris(), + // 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。 + mimeOf = { uri -> + runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull() + }, ) - if (extracted.isEmpty && extracted.droppedImages == 0) return + // 没有任何文本、也没有任何待落地的 URI ⇒ 这条分享里什么都没有(丢件计数在提取期恒为 0; + // 保留原来那条防御性判断,只是把「文件」那一档一并算上)。 + if (extracted.isEmpty && extracted.droppedImages == 0 && extracted.droppedDocuments == 0) return - val appContext = context.applicationContext scope.launch { val dir = shareDir(appContext).apply { mkdirs() } val copied = extracted.images.mapIndexedNotNull { index, uri -> copyToCache(appContext, dir, uri, index) } + val documents = extracted.documents.mapIndexedNotNull { index, document -> + copyDocumentToCache(appContext, dir, document, index) + } holder.put( extracted.copy( images = copied, + documents = documents, // 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。 failedImages = extracted.failedImages + (extracted.images.size - copied.size), + failedDocuments = extracted.failedDocuments + (extracted.documents.size - documents.size), ), ) } @@ -103,6 +132,55 @@ object ShareIntake { private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME) + /** + * 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数; + * 失败回 `null`。 + * + * 显示名必须先取(拷完之后外部 URI 就只在这个调用栈里了),取不到就回落 URI 最后一段—— + * 它是**上传部件的名字**,服务端只从中取扩展名(判定白名单),空名字则由仓库层兜 + * `document.txt`。 + */ + private fun copyDocumentToCache( + context: Context, + dir: File, + document: SharedDocument, + index: Int, + ): SharedDocument? { + val parsed = runCatching { Uri.parse(document.uri) }.getOrNull() ?: return null + if (parsed.scheme.isNullOrBlank()) return null + + val name = displayNameOf(context, parsed) + val target = File(dir, ShareRules.documentCacheFileName(UUID.randomUUID().toString(), index, name)) + return try { + val source = context.contentResolver.openInputStream(parsed) ?: return null + source.use { input -> target.outputStream().use { output -> input.copyTo(output) } } + val size = target.length() + if (size > 0L) { + document.copy(uri = Uri.fromFile(target).toString(), displayName = name, sizeBytes = size) + } else { + target.delete() + null + } + } catch (_: Exception) { + // 半截文件不能留下:下一次发送会把它当一份真文件去读,报出的错会指向错的地方。 + target.delete() + null + } + } + + /** provider 报的显示名优先;拿不到就回落到 URI 最后一段(都不给就留空,不猜扩展名)。 */ + private fun displayNameOf(context: Context, uri: Uri): String { + val fromProvider = runCatching { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index < 0 || cursor.isNull(index)) null else cursor.getString(index) + } + }.getOrNull() + return fromProvider?.takeIf { it.isNotBlank() } + ?: DocumentAttachments.fallbackDocumentName(uri.toString()) + } + /** * 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。 * 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 new file mode 100644 index 0000000..c22ef72 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachment.kt @@ -0,0 +1,189 @@ +package top.yeij.cyrene.domain.chat + +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImage` 平行的概念)。 + * + * 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它; + * 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB + * 的字节一直摆在 UI 状态里(`ByteArray` 一进 `StateFlow` 就等于每次重组都摸一遍它)。 + * 字节只在真的要上传时按 [uri] 现读(见 `ChatViewModel` 的发送路径)。 + * + * 不用 `displayName` 去拼本地路径、也不改它的大小写:它**只用来给上传部件命名** + * (服务端要的是扩展名,其余部分会被内容寻址丢掉),以及给用户看。 + */ +data class DraftDocument( + /** 本地可读 URI(SAF 的 `content://` 或分享落地后的 `file://`)。 */ + val uri: String, + /** 给用户看的名字,含扩展名;空 = 让仓库层兜 `document.txt`。 */ + val displayName: String, + /** 元数据里报的字节数;[UNKNOWN_SIZE] = 拿不到(此时不做本地体积预检,交给服务端裁决)。 */ + val sizeBytes: Long = UNKNOWN_SIZE, +) { + companion object { + /** `ContentResolver` 拿不到 `SIZE` 时的取值:**不猜**,也不因此拒绝这个文件。 */ + const val UNKNOWN_SIZE: Long = -1L + } +} + +/** + * 一次「选文件」的结果:接受了哪些、又有哪些**没能接受**。 + * + * 三个计数分开记,因为它们该说的话不一样,而且**一个都不许静默消失**—— + * 「用户以为附件都挂上了、其实少了两个」正是本模块要消灭的东西。 + */ +data class AttachmentPickResult( + val accepted: List = emptyList(), + /** 超过 [DocumentAttachments.MAX_FILE_BYTES] 而未被接受的显示名。 */ + val tooLarge: List = emptyList(), + /** 本回合附件已满([DocumentAttachments.MAX_PER_TURN])而未被接受的份数。 */ + val overLimit: Int = 0, + /** 与已选附件重复、因此没有重复加入的份数。 */ + val duplicate: Int = 0, +) { + /** 如实告知的那句话;没有任何东西被挡下时回 `null`(正常选中一个字都不说)。 */ + val notice: String? + get() { + val parts = buildList { + if (tooLarge.isNotEmpty()) { + add( + "有 ${tooLarge.size} 个文件超过 " + + "${DocumentAttachments.maxFileSizeLabel}," + + "没有加入(${tooLarge.joinToString("、")})", + ) + } + if (overLimit > 0) { + add("一个回合最多 ${DocumentAttachments.MAX_PER_TURN} 个附件,另有 $overLimit 个没有加入") + } + if (duplicate > 0) add("有 $duplicate 个和已选附件重复,没有重复加入") + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(";") + } +} + +/** + * 待发附件的准入规则(纯函数,VM 与测试共用)。 + * + * ## 它为什么在客户端也要有一份 + * 服务端才是裁决者(上限是 `CHAT_DOCUMENT_MAX_BYTES`,可部署配置;类型裁决只有字节嗅探一条), + * 所以这里的预检**只做一件服务端做不到的事**:**在把字节传上去之前**就说话。 + * 一份 30 MiB 的文件照传一遍再吃 `413`,用户等的是上传的那几分钟,而结论本端现在就能给。 + * 代价是这份上限可能与部署实际不同——因此一旦两边不一致,**服务端的话更权威**: + * 本端放过去的,服务端照旧能用 `413` / `415` 拦下(那两条文案会原样显示给用户)。 + * + * ## 为什么不裁剪 + * [admit] 只**不收**,从不**少收**:多出来的份数与超限的文件都进 [AttachmentPickResult], + * 由调用方如实告知。服务端对 >5 个回 `400 too_many_documents`、对非法名字回 `404`, + * 两边都不做静默裁剪,客户端也就不该在本地悄悄只发前 5 个。 + */ +object DocumentAttachments { + + /** + * 单份附件的本端预检上限。 + * + * 取服务端文档上限的**默认值**(`CHAT_DOCUMENT_MAX_BYTES`,契约 v0.59 §1.2 = 20 MiB)。 + * 刻意与图片上限(5 MiB,[top.yeij.cyrene.data.repository.UploadRepository] 那条)分开: + * 服务端把两者解耦,客户端合成一个数就会先自己把文档卡在图片的量级上。 + */ + const val MAX_FILE_BYTES: Long = 20L * 1024 * 1024 + + /** 上限的可读说法(给提示文案用,避免两处各写一个数字)。 */ + const val maxFileSizeLabel: String = "20 MB" + + /** 一个回合的附件数上限,与判定矩阵同源([DocumentDelivery.MAX_ATTACHMENTS_PER_TURN])。 */ + const val MAX_PER_TURN: Int = DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + + /** + * 把这次选中的文件并进已选列表。 + * + * 顺序:**去重 → 体积 → 份数**。去重放在最前,是因为重复选同一份文件既不该被算成 + * 「超限」也不该被算成「超体积」——它压根不是一个新附件。 + * + * @param existing 已选附件(输入栏上方那些芯片)。 + * @param picked 本次选中的文件(顺序即用户看到的顺序)。 + */ + fun admit(existing: List, picked: List): AttachmentPickResult { + val seen = existing.mapTo(mutableSetOf()) { it.uri } + val tooLarge = mutableListOf() + val fresh = mutableListOf() + var duplicate = 0 + + picked.forEach { document -> + if (!seen.add(document.uri)) { + duplicate++ + return@forEach + } + // 体积拿不到(-1)时不判:本端猜不准的东西,留给服务端的 413 说。 + if (document.sizeBytes > MAX_FILE_BYTES) { + tooLarge += document.displayName.ifBlank { document.uri } + return@forEach + } + fresh += document + } + + val room = (MAX_PER_TURN - existing.size).coerceAtLeast(0) + val accepted = fresh.take(room) + return AttachmentPickResult( + accepted = accepted, + tooLarge = tooLarge, + overLimit = fresh.size - accepted.size, + duplicate = duplicate, + ) + } + + /** 从已选列表里去掉第 [index] 个(越界即原样返回,UI 的移除按钮不必自己判界)。 */ + fun removeAt(existing: List, index: Int): List = + if (index !in existing.indices) existing else existing.filterIndexed { i, _ -> i != index } + + /** + * URI 里能看出来的名字(`ContentResolver` 报不出 `DISPLAY_NAME` 时的兜底)。 + * + * 取最后一段、去掉查询串;拿不到就回空串——**不猜**扩展名(扩展名真的参与服务端裁决, + * 猜错等于替用户把一个合规文件变成 `415`)。 + */ + fun fallbackDocumentName(uri: String): String = + uri.substringBefore('?').substringAfterLast('/').takeIf { it.isNotBlank() }.orEmpty() +} + +/** + * 读一份附件的字节时会遇到的三种结果。 + * + * 「太大」与「读不到」必须分开:前者的正确动作是换文件,后者是把这份内容**再读一次** + * (授权过期、文件被删、provider 抽风)——折成一句「附件失败」会让用户做错事。 + */ +sealed interface DocumentRead { + + data class Ok(val bytes: ByteArray) : DocumentRead + + /** 实际字节超过 [DocumentAttachments.MAX_FILE_BYTES]。 */ + data object TooLarge : DocumentRead + + /** 打不开 / 读不动(授权失效、文件已不在、provider 报错)。 */ + data object Unavailable : DocumentRead +} + +/** + * 从 [input] 读至多 [limit] 字节。 + * + * **返回值语义**:`null` = 这份内容**超过了** [limit];IO 异常**原样抛出**(调用方要能把它 + * 与「超限」分开说)。这样定是因为调用方必须给出不同的提示,而「读到一半发现超了」不能 + * 靠 `available()` 猜——`available()` 在 `content://` 流上不可靠。 + * + * 读满 [limit] 就停:一份被换成 500 MiB 的文件(选完之后、发送之前被替换)不会先在 + * 内存里炸掉进程再报错。 + */ +fun readBounded(input: InputStream, limit: Long): ByteArray? { + val buffer = ByteArrayOutputStream() + val chunk = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0L + while (true) { + val read = input.read(chunk) + if (read < 0) break + total += read + if (total > limit) return null + buffer.write(chunk, 0, read) + } + return buffer.toByteArray() +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt new file mode 100644 index 0000000..8178282 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt @@ -0,0 +1,113 @@ +package top.yeij.cyrene.domain.chat + +import top.yeij.cyrene.data.api.dto.DocumentRefDto +import java.util.Locale + +/** + * 一枚附件芯片要显示什么(纯数据,UI 直接照着渲染)。 + * + * 字段刻意是**已格式化好的字符串**而不是原始数值:气泡里没有第二处地方能解释这些数, + * 而「12.0 KB」「8,000 字」这种换算一旦写进 Composable 就没法单测了。 + */ +data class DocumentChipModel( + /** 服务端存储名(`<32hex>.`)或待发附件的本地显示名。 */ + val name: String, + /** 类型标签(扩展名大写);判不出类型时是「文件」。 */ + val typeLabel: String, + /** 体积标签;读不出来时是空串。 */ + val sizeLabel: String, + /** 本回合注入的字符数;服务端没报就是 `null`。 */ + val charsLabel: String?, + /** 抽取状态;服务端没报就回 `null`,未知取值**原样显示**。 */ + val statusLabel: String?, + /** + * `truncated` 的标签:只表示**注入被预算截断**(正文照样进了上下文), + * 与 [unreadable] 是两件事,文案也必须分开写。 + */ + val truncatedLabel: String?, + /** `status == "unreadable"`:这是唯一一个「昔涟读不到」的信号,UI 据此给灰态。 */ + val unreadable: Boolean, +) { + /** 芯片副行:`MD · 12 KB · 8,000 字 · 已读入`(缺哪项少哪项,全缺就是空串)。 */ + val detail: String + get() = listOfNotNull(typeLabel, sizeLabel, charsLabel, statusLabel, truncatedLabel) + .filter { it.isNotEmpty() } + .joinToString(" · ") +} + +/** 附件芯片的展示换算(纯函数,与 UI 无关)。 */ +object DocumentChips { + + /** + * 一条**已落库**的文档引用 → 芯片模型。 + * + * 名字只有服务端存储名可用:服务端按内容寻址落盘(`sha256[:32] + ext`),**原始文件名 + * 永不参与路径**,也就不会回到客户端。所以这里**不编**一个「用户当初叫它什么」的名字—— + * 那会是本端凭空捏造的信息(历史消息里根本没有它)。长名字由 UI 侧 + * `TextOverflow.MiddleEllipsis` 处理,保住扩展名。 + */ + fun of(ref: DocumentRefDto): DocumentChipModel = DocumentChipModel( + name = ref.name, + typeLabel = typeLabel(ref.name), + sizeLabel = sizeLabel(ref.size), + charsLabel = charsLabel(ref.chars), + statusLabel = statusLabel(ref.status), + truncatedLabel = if (ref.truncated) TRUNCATED else null, + unreadable = DocumentStatus.isUnreadable(ref.status), + ) + + /** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */ + fun of(draft: DraftDocument): DocumentChipModel = DocumentChipModel( + name = draft.displayName.ifBlank { draft.uri }, + typeLabel = typeLabel(draft.displayName), + sizeLabel = sizeLabel(draft.sizeBytes), + charsLabel = null, + statusLabel = null, + truncatedLabel = null, + unreadable = false, + ) + + /** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */ + fun typeLabel(name: String): String { + val extension = name.substringAfterLast('.', "") + val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() } + return if (usable) extension.uppercase(Locale.ROOT) else NO_TYPE + } + + /** 体积;[DraftDocument.UNKNOWN_SIZE](拿不到元数据)回空串,**不显示一个假的 0 B**。 */ + fun sizeLabel(bytes: Long): String = when { + bytes < 0 -> "" + bytes < KIB -> "$bytes B" + bytes < MIB -> oneDecimal(bytes / KIB.toDouble()) + " KB" + else -> oneDecimal(bytes / MIB.toDouble()) + " MB" + } + + /** 注入字符数;`null`(服务端没报这一项)回 `null`。 */ + fun charsLabel(chars: Int?): String? = + chars?.let { String.format(Locale.ROOT, "%,d", it) + " 字" } + + /** + * 抽取状态 → 文案。`""`(服务端未报)回 `null`,**未知取值原样显示**—— + * 与项目里 `mode` / `platform` / `mood` 的开放集合口径一致,不丢弃、不占位符。 + * + * 三句分开写是刻意的:[LISTED] 只说明「正文没进上下文」,**不是**读不到; + * [UNREADABLE] 才是读不到。 + */ + fun statusLabel(status: String): String? = when (status) { + "" -> null + DocumentStatus.EXTRACTED -> "已读入" + DocumentStatus.LISTED -> "只看得到清单" + DocumentStatus.UNREADABLE -> "读不到" + else -> status + } + + private fun oneDecimal(value: Double): String = String.format(Locale.ROOT, "%.1f", value) + + private const val NO_TYPE = "文件" + + private const val TRUNCATED = "注入已截断" + + private const val KIB = 1024L + + private const val MIB = 1024L * 1024L +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt index 0fd5eb6..c8c14ec 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt @@ -130,9 +130,25 @@ object DocumentDelivery { * @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。 * @param files 本轮要挂的本地附件。 */ - fun plan(text: String, files: List): DocumentPlan = when { - files.size > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments) - files.isNotEmpty() -> DocumentPlan.AsDocuments(files = files, textAsDocument = textNeedsFile(text)) + fun plan(text: String, files: List): DocumentPlan = + when (val shape = planForCount(text, files.size)) { + is DocumentPlan.AsDocuments -> shape.copy(files = files) + else -> shape + } + + /** + * 同 [plan] 的判定矩阵,但**只看附件份数**。 + * + * 为什么需要它:调用方(`ChatViewModel`)手上还没有字节——最多 5×20 MiB 一次性读进内存 + * 是不可接受的,而判定矩阵里**唯一**与文件内容有关的就是份数(正文那一项看 [textNeedsFile])。 + * 有了这一条,调用方就能在读字节之前先答出「要不要拒绝」「要不要走上传这条链」, + * 而不是先读一堆字节再发现自己根本不该发。 + * + * @param attachmentCount 本回合要挂的附件份数。 + */ + fun planForCount(text: String, attachmentCount: Int): DocumentPlan = when { + attachmentCount > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments) + attachmentCount > 0 -> DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = textNeedsFile(text)) textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true) else -> DocumentPlan.Inline } 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 6c9ca9a..216512d 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 @@ -3,36 +3,87 @@ package top.yeij.cyrene.domain.share /** * 从别的 App 分享进来、已经可以落地的内容。 * - * [images] 是**本端自己落盘后**的 `file://` URI:外部 `content://` 的读授权只在这个 intent - * 的接收方(Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以图片必须在收到的那一刻 - * 就拷进 app 的 cache 目录(见 `app/ShareIntake.kt`)。 + * [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt` + * 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方 + * (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。 * - * 两个丢件计数分开记,是因为它们该说的话不一样:截断是「本端的规则」,读不到是「对方给的 - * 东西用不了」。分享进来的内容**不许静默少发**——少发的部分要在界面上如实讲清楚。 + * 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」 + * 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。 */ data class SharedContent( val text: String = "", val images: List = emptyList(), - /** 超过 [ShareRules.MAX_IMAGES] 而未被接收的条数。 */ + /** + * 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条 + * (判定见 [classifySharedStream])——这正是修复「把一份 `.md` 当图片去解码、 + * 最后报一句『图片读不到』」的地方。 + */ + val documents: List = emptyList(), + /** 超过 [ShareRules.MAX_IMAGES] 而未被接收的图片条数。 */ val truncatedImages: Int = 0, - /** 已接收但**拷贝失败**(读不到 / 写不进 cache)的条数。 */ + /** 已接收但**拷贝失败**(读不到 / 写不进 cache)的图片条数。 */ val failedImages: Int = 0, + /** 超过 [ShareRules.MAX_DOCUMENTS] 而未被接收的文件条数。 */ + val truncatedDocuments: Int = 0, + /** 已接收但**拷贝失败**的文件条数。 */ + val failedDocuments: Int = 0, ) { /** 没能落地的总条数。 */ val droppedImages: Int get() = truncatedImages + failedImages - /** 没有任何可发送的内容(此时只剩下 [droppedImages] 需要告知)。 */ - val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() + /** 没能落地的文件总条数。 */ + val droppedDocuments: Int get() = truncatedDocuments + failedDocuments + + /** 没有任何可发送的内容(此时只剩下丢件计数需要告知)。 */ + val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() && documents.isEmpty() +} + +/** + * 一份分享进来的**非图片**文件。 + * + * [uri] 是拷进 cache 后的本端 `file://` 地址;[displayName] 只用来给上传部件命名(服务端只看 + * 扩展名)与给用户看,[sizeBytes] 用于发送前的本地体积预检(拿不到时是 -1,不猜)。 + */ +data class SharedDocument( + val uri: String, + val displayName: String = "", + val sizeBytes: Long = -1L, +) + +/** 一条分享进来的 URI 该走哪条路。 */ +enum class SharedStreamKind { Image, Document } + +/** + * 按 MIME 分流(纯函数,可单测)。 + * + * **以 `image/` 开头才当图片**,其余(含 `null`、`text/plain`、`application/pdf`……)一律走文档路: + * 此前把所有 `EXTRA_STREAM` 都当图片拷、当图片解码,于是一份 `.md` 只有两种结局—— + * 「图片处理失败」或者被悄悄当成一张读不出来的图。文档路至少会把字节原样交给服务端, + * 由**字节嗅探**裁决(白名单内的放行,其余用 `415` 的文案如实说)。 + * + * `null`(provider 没报类型)也走文档路:本端**不猜**类型,猜错等于替用户把一个合规文件 + * 变成一次失败。代价是「图片但 provider 没报 MIME」会落到文档路并被服务端拒——这是刻意的 + * 取舍,见 `app/ShareIntake.kt` 的 KDoc。 + */ +fun classifySharedStream(mime: String?): SharedStreamKind { + val base = mime?.substringBefore(';')?.trim()?.lowercase() + return if (base != null && base.startsWith("image/")) SharedStreamKind.Image else SharedStreamKind.Document } /** * 分享入口的规则(纯函数,与 Android 无关,可单测;Intent 的读取在 `app/ShareIntake.kt`)。 * - * ## 为什么只认这两个 action、两类 MIME - * Manifest 只声明 `text/plain` 与 `image/*`:声明的每一类都必须是**本端真能落地**的东西。 - * 声明 `*/*` 会让「分享 PDF / 视频 / 压缩包给昔涟」出现在系统分享菜单里,而对话路径上今天 - * 根本没有「文件」这个表示(协同文档 `02/F1`:表示 / 存储 / 摄取三缺)——那会造出一个 - * **点进去必然失败**的入口。收窄之后非图片文件干脆不进分享菜单,用户看不到一个假的选项。 + * ## 为什么只认这两个 action、Manifest 里只声明两类 MIME + * Manifest 只声明 `text/plain` 与图片那一类(`image/` 前缀):声明的每一类都必须是**本端真能 + * 落地**的东西。后端**明令拒绝** `text/html` / SVG / XML / 压缩包 / 脚本,声明全通配 MIME + * 或文本通配 MIME 会造出一堆「点进去必然失败」的入口(`02/F1` 那条的三缺早已补上,但类型 + * 白名单本身就是 deny-by-default 的 22 个扩展名)。`text/plain` 已经能接住 `.txt` / `.md` / + * `.log` 这类文件管理器会报 `text/plain` 的文件;`.md` 若被报成 `text/markdown`,那它**不进** + * 分享菜单——少一个入口,好过一个必然失败的入口。(**Manifest 不动**是硬约束。) + * + * ## 两个 action 与两条路 + * [ACTION_SEND] 收文本 + 一个 URI(图片或文件);[ACTION_SEND_MULTIPLE] 本端只声明了图片那一类, + * 所以多选一条实际只可能是图片。URI 走哪条路由 [classifySharedStream] 按 MIME 判。 * * ## 为什么正文一个字都不加 * **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem` @@ -41,10 +92,10 @@ data class SharedContent( */ object ShareRules { - /** `Intent.ACTION_SEND`:一条文本,或一张图片(可能还带一段说明文字)。 */ + /** `Intent.ACTION_SEND`:一条文本,或一张图片 / 一个文件(可能还带一段说明文字)。 */ const val ACTION_SEND = "android.intent.action.SEND" - /** `Intent.ACTION_SEND_MULTIPLE`:多条;本端只接图片。 */ + /** `Intent.ACTION_SEND_MULTIPLE`:多条;本端 Manifest 只声明了图片那一类。 */ const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE" /** @@ -56,10 +107,19 @@ object ShareRules { */ const val MAX_IMAGES = 4 + /** + * 一次分享最多接收几个非图片文件。 + * + * 取服务端的一个回合上限(契约 v0.59 §1.2:`documents` **≤5 条**)——附件是**挂在同一条 + * 消息上**的,所以这里不能像图片那样「多出来的另起几条消息」:超过就是服务端 + * `400 too_many_documents`。超出的部分同样如实告知,绝不静默裁。 + */ + const val MAX_DOCUMENTS = 5 + /** * 从分享 Intent 的原始字段提取内容。 * - * 图片的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有 + * URI 的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有 * App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。 * 两者都有时**只取前者**,不会把同一张图数两遍。 * @@ -69,6 +129,9 @@ object ShareRules { * @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串) * @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表) * @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略 + * @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是 + * **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走 + * (见 [classifySharedStream],默认值就是「不猜」)。 */ fun extract( action: String?, @@ -76,6 +139,7 @@ object ShareRules { stream: String? = null, streams: List? = null, clipUris: List = emptyList(), + mimeOf: (String) -> String? = { null }, ): SharedContent { val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE if (!recognized) return SharedContent() @@ -93,10 +157,23 @@ object ShareRules { .filter(String::isNotEmpty) .distinct() + val images = mutableListOf() + val documents = mutableListOf() + unique.forEach { uri -> + when (classifySharedStream(mimeOf(uri))) { + SharedStreamKind.Image -> images += uri + SharedStreamKind.Document -> documents += uri + } + } + return SharedContent( text = body, - images = unique.take(MAX_IMAGES), - truncatedImages = (unique.size - MAX_IMAGES).coerceAtLeast(0), + images = images.take(MAX_IMAGES), + // 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI; + // ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。 + documents = documents.take(MAX_DOCUMENTS).map { SharedDocument(uri = it) }, + truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0), + truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0), ) } @@ -108,13 +185,31 @@ object ShareRules { * 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成 * `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。 */ - fun cacheFileName(token: String, index: Int, mime: String?): String { - val safe = token.map { c -> - if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_' - }.joinToString("") - return "share-${safe.ifEmpty { "x" }}-$index.${extensionFor(mime)}" + fun cacheFileName(token: String, index: Int, mime: String?): String = + "share-${safeToken(token)}-$index.${extensionFor(mime)}" + + /** + * 非图片分享文件的缓存名。 + * + * 扩展名**照抄对方给的名字**(服务端按扩展名判白名单,缓存件本身只是本端的临时载体, + * 但名字对得上排查时少一次误解),并且同样只由本端 token 与序号构成路径—— + * 对方名字里的 `/` 与 `..` 活不下来(见 [safeExtension])。 + */ + fun documentCacheFileName(token: String, index: Int, originalName: String): String = + "share-${safeToken(token)}-$index.${safeExtension(originalName)}" + + /** 非白名单 / 空 / 含非法字符的扩展名一律回落 `txt`(**不猜**类型)。 */ + private fun safeExtension(originalName: String): String { + val extension = originalName.substringAfterLast('.', "").lowercase() + val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() } + return if (usable) extension else "txt" } + private fun safeToken(token: String): String = + token.map { c -> + if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_' + }.joinToString("").ifEmpty { "x" } + /** MIME → 扩展名;未知 / 缺失回落 `img`。 */ private fun extensionFor(mime: String?): String = when (mime?.substringBefore(';')?.trim()?.lowercase()) { 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 740f2c5..35170dd 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 @@ -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, 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, 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(";") 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 88b40e0..dda9537 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 @@ -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 = emptyList(), ) /** @@ -191,6 +207,12 @@ data class ChatUiState( val draft: String = "", /** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */ val draftImage: String? = null, + /** + * 已选好、还没发出去的文档附件(与 [draftImage] 平行的概念)。发送失败时与正文、图片一起 + * 回到这里(见 [ChatViewModel.rollback]),所以它们必须一路带着**本地引用**—— + * 重新上传的代价是几 MB 的流量与一次白等。 + */ + val draftDocuments: List = 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) { + 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, + 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, + ) { + // 判定矩阵([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): List? { + val names = mutableListOf() + 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, + ) { 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 = 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 ?: "请重试") + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index a23a907..842a612 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -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)时按 `

` 切分, * `` / `` 解析成灰斜体小字,依附到所属气泡的上方/下方; * - 其它 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, + 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) { diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachmentsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachmentsTest.kt new file mode 100644 index 0000000..1446510 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentAttachmentsTest.kt @@ -0,0 +1,206 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream + +/** + * 待发附件的准入规则与读取边界(纯函数)。 + * + * 这里锚的是**「不许静默」**这条行为承诺:被挡下的东西必须有计数、有名字、有一句能被界面讲出 + * 来的话;**绝不**「只发前 5 个」或者悄悄少挂一份——服务端对 >5 回 `400 too_many_documents`、 + * 对超限回 `413 document_too_large`,两边都不做静默裁剪,客户端也就不该在本地悄悄裁。 + */ +class DocumentAttachmentsTest { + + private fun draft(uri: String, name: String = "note.txt", size: Long = 16L) = + DraftDocument(uri = uri, displayName = name, sizeBytes = size) + + private fun drafts(count: Int, prefix: String = "content://doc/") = + List(count) { draft(uri = "$prefix$it", name = "note-$it.txt") } + + // —— 份数上限 —— + + @Test + fun `exactly the maximum number of attachments is accepted`() { + val result = DocumentAttachments.admit(emptyList(), drafts(DocumentAttachments.MAX_PER_TURN)) + + assertEquals(DocumentAttachments.MAX_PER_TURN, result.accepted.size) + assertEquals(DocumentAttachments.MAX_PER_TURN, DocumentDelivery.MAX_ATTACHMENTS_PER_TURN) + assertEquals(0, result.overLimit) + assertNull(result.notice) + } + + @Test + fun `one attachment over the maximum is reported, never trimmed silently`() { + val result = DocumentAttachments.admit(emptyList(), drafts(6)) + + assertEquals(5, result.accepted.size) + assertEquals(1, result.overLimit) + assertTrue(result.notice!!, result.notice!!.contains("最多 5 个")) + } + + @Test + fun `the room left is computed against what is already attached`() { + val existing = drafts(3, prefix = "content://old/") + + val result = DocumentAttachments.admit(existing, drafts(4, prefix = "content://new/")) + + assertEquals(2, result.accepted.size) + assertEquals(2, result.overLimit) + } + + @Test + fun `a full attachment list accepts nothing and says so`() { + val existing = drafts(DocumentAttachments.MAX_PER_TURN, prefix = "content://old/") + + val result = DocumentAttachments.admit(existing, drafts(1, prefix = "content://new/")) + + assertTrue(result.accepted.isEmpty()) + assertEquals(1, result.overLimit) + } + + // —— 单份体积 —— + + @Test + fun `a file exactly at the size limit is accepted`() { + val atLimit = draft("content://doc/big", name = "big.txt", size = DocumentAttachments.MAX_FILE_BYTES) + + val result = DocumentAttachments.admit(emptyList(), listOf(atLimit)) + + assertEquals(1, result.accepted.size) + assertNull(result.notice) + } + + @Test + fun `one byte over the size limit is refused before anything is uploaded`() { + // 本地预检的意义就在这儿:30 MiB 的文件照传一遍再吃 413,用户白等的是上传那几分钟。 + val overLimit = draft("content://doc/big", name = "big.txt", size = DocumentAttachments.MAX_FILE_BYTES + 1) + + val result = DocumentAttachments.admit(emptyList(), listOf(overLimit)) + + assertTrue(result.accepted.isEmpty()) + assertEquals(listOf("big.txt"), result.tooLarge) + assertTrue(result.notice!!, result.notice!!.contains("big.txt")) + } + + @Test + fun `an unknown size is not judged locally`() { + // 拿不到 SIZE 时本端不猜:宁可让服务端用它自己的上限回 413(带上真实数字), + // 也不要在这边凭空拒掉一个可能合规的文件。 + val unknown = draft("content://doc/x", name = "x.txt", size = DraftDocument.UNKNOWN_SIZE) + + val result = DocumentAttachments.admit(emptyList(), listOf(unknown)) + + assertEquals(1, result.accepted.size) + assertNull(result.notice) + } + + @Test + fun `an oversized file does not eat the room of the ones after it`() { + val files = listOf( + draft("content://doc/huge", name = "huge.txt", size = DocumentAttachments.MAX_FILE_BYTES + 1), + draft("content://doc/ok", name = "ok.txt"), + ) + + val result = DocumentAttachments.admit(emptyList(), files) + + assertEquals(listOf("ok.txt"), result.accepted.map { it.displayName }) + assertEquals(1, result.tooLarge.size) + } + + // —— 去重 —— + + @Test + fun `picking the same file twice does not attach it twice`() { + val once = draft("content://doc/a") + + val result = DocumentAttachments.admit(listOf(once), listOf(once)) + + assertTrue(result.accepted.isEmpty()) + assertEquals(1, result.duplicate) + // 重复既不算「超限」也不算「超体积」——它压根不是一个新附件。 + assertEquals(0, result.overLimit) + assertTrue(result.tooLarge.isEmpty()) + assertTrue(result.notice!!, result.notice!!.contains("重复")) + } + + @Test + fun `duplicates inside one pick are collapsed too`() { + val result = DocumentAttachments.admit(emptyList(), listOf(draft("content://doc/a"), draft("content://doc/a"))) + + assertEquals(1, result.accepted.size) + assertEquals(1, result.duplicate) + } + + // —— 移除 —— + + @Test + fun `removeAt drops exactly one chip and survives an out of range index`() { + val files = drafts(3) + + assertEquals(listOf("note-0.txt", "note-2.txt"), DocumentAttachments.removeAt(files, 1).map { it.displayName }) + assertEquals(files, DocumentAttachments.removeAt(files, -1)) + assertEquals(files, DocumentAttachments.removeAt(files, 3)) + } + + // —— URI 兜底名 —— + + @Test + fun `the fallback name is the last path segment and never guesses an extension`() { + assertEquals("report.md", DocumentAttachments.fallbackDocumentName("content://doc/a/b/report.md")) + assertEquals("report.md", DocumentAttachments.fallbackDocumentName("file:///cache/x/report.md?token=1")) + // 拿不到名字就留空串:让仓库层去兜 document.txt,比本端编一个扩展名安全。 + assertEquals("", DocumentAttachments.fallbackDocumentName("content://doc/")) + } + + // —— 有界读取 —— + + @Test + fun `readBounded returns the whole content when it fits`() { + val bytes = ByteArray(8) { it.toByte() } + + assertArrayEquals(bytes, readBounded(ByteArrayInputStream(bytes), limit = 8)) + assertArrayEquals(bytes, readBounded(ByteArrayInputStream(bytes), limit = 1024)) + } + + @Test + fun `readBounded returns null as soon as the content is over the limit`() { + // 选完之后、发送之前文件被换成一个超大文件:绝不能先在内存里把它整个读进来再报错。 + val bytes = ByteArray(9) + + assertNull(readBounded(ByteArrayInputStream(bytes), limit = 8)) + } + + @Test + fun `readBounded lets IO failures through so they can be told apart from too large`() { + // 「太大」与「读不到」要用户做的事不一样(换文件 / 重新选),所以这里**不吞**异常: + // 调用方靠「null = 超限、抛出 = 读不到」把两者分开。 + val failing = object : InputStream() { + override fun read(): Int = throw IOException("boom") + } + + var thrown: IOException? = null + try { + readBounded(failing, limit = 8) + } catch (e: IOException) { + thrown = e + } + + assertEquals("boom", thrown?.message) + } + + @Test + fun `a zero byte stream reads as empty rather than failing`() { + val empty = readBounded(ByteArrayInputStream(ByteArray(0)), limit = 8) + + assertFalse(empty == null) + assertEquals(0, empty!!.size) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt new file mode 100644 index 0000000..625fa26 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt @@ -0,0 +1,170 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.DocumentRefDto + +/** + * 附件芯片的展示换算(纯函数)。 + * + * 这里锚三件事: + * 1. **状态与截断不许互相顶替**——`unreadable` 才是「读不到」,`truncated` 只说明注入被预算 + * 截断(正文照样进了上下文),两句文案必须分开; + * 2. **未知 `status` 原样显示**(开放集合:不丢弃、不报错、不占位符); + * 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来。 + */ +class DocumentChipTest { + + private fun ref( + name: String = "0123456789abcdef0123456789abcdef.md", + mime: String = "text/plain", + size: Long = 12_345, + chars: Int? = 8_000, + truncated: Boolean = false, + status: String = DocumentStatus.EXTRACTED, + ) = DocumentRefDto( + name = name, + mime = mime, + size = size, + chars = chars, + truncated = truncated, + status = status, + ) + + // —— 类型 —— + + @Test + fun `the type label comes from the extension, not from the sniffed mime`() { + // 服务端的 mime 是**嗅探基类型**(`data.json` 变 `text/plain`),按它显示类型会全变成 + // 「TEXT/PLAIN」;有语义的只有存储名里的扩展名。 + val chip = DocumentChips.of(ref(name = "0123456789abcdef0123456789abcdef.json")) + + assertEquals("JSON", chip.typeLabel) + } + + @Test + fun `a name without a usable extension is shown as a plain file`() { + assertEquals("文件", DocumentChips.typeLabel("0123456789abcdef0123456789abcdef")) + assertEquals("文件", DocumentChips.typeLabel("")) + assertEquals("文件", DocumentChips.typeLabel("a.this-is-not-an-extension")) + assertEquals("MD", DocumentChips.typeLabel("a.md")) + } + + // —— 体积 / 字数 —— + + @Test + fun `sizes are rendered in the unit a human reads`() { + assertEquals("0 B", DocumentChips.sizeLabel(0)) + assertEquals("512 B", DocumentChips.sizeLabel(512)) + assertEquals("1.0 KB", DocumentChips.sizeLabel(1024)) + assertEquals("12.1 KB", DocumentChips.sizeLabel(12_345)) + assertEquals("20.0 MB", DocumentChips.sizeLabel(DocumentAttachments.MAX_FILE_BYTES)) + } + + @Test + fun `an unknown size renders as nothing rather than a fake zero`() { + assertEquals("", DocumentChips.sizeLabel(DraftDocument.UNKNOWN_SIZE)) + } + + @Test + fun `the char count is grouped and a missing one stays missing`() { + assertEquals("8,000 字", DocumentChips.charsLabel(8_000)) + assertEquals("0 字", DocumentChips.charsLabel(0)) + assertNull(DocumentChips.charsLabel(null)) + } + + // —— 状态 —— + + @Test + fun `the three known statuses each get their own sentence`() { + assertEquals("已读入", DocumentChips.statusLabel(DocumentStatus.EXTRACTED)) + assertEquals("只看得到清单", DocumentChips.statusLabel(DocumentStatus.LISTED)) + assertEquals("读不到", DocumentChips.statusLabel(DocumentStatus.UNREADABLE)) + } + + @Test + fun `an unknown status is shown verbatim`() { + assertEquals("extracting", DocumentChips.statusLabel("extracting")) + assertNull(DocumentChips.statusLabel("")) + } + + // —— 不可读与截断是两件事 —— + + @Test + fun `unreadable marks the chip grey and says so`() { + val chip = DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, chars = 0)) + + assertTrue(chip.unreadable) + assertEquals("读不到", chip.statusLabel) + assertNull("不可读不该被说成截断", chip.truncatedLabel) + } + + @Test + fun `truncated only means the injection was cut and never means unreadable`() { + val chip = DocumentChips.of(ref(truncated = true, status = DocumentStatus.EXTRACTED)) + + assertFalse(chip.unreadable) + assertEquals("已读入", chip.statusLabel) + assertEquals("注入已截断", chip.truncatedLabel) + assertTrue(chip.detail, chip.detail.contains("已读入")) + assertTrue(chip.detail, chip.detail.contains("注入已截断")) + } + + @Test + fun `a plain reference has no status sentence at all`() { + val chip = DocumentChips.of(ref(chars = null, status = "")) + + assertNull(chip.statusLabel) + assertNull(chip.truncatedLabel) + assertEquals("MD · 12.1 KB", chip.detail) + } + + // —— 副行拼接 —— + + @Test + fun `the detail line joins what is actually known`() { + val chip = DocumentChips.of(ref()) + + assertEquals("MD · 12.1 KB · 8,000 字 · 已读入", chip.detail) + } + + @Test + fun `the detail line skips the parts the server did not report`() { + val chip = DocumentChips.of(ref(size = 0, chars = null, status = DocumentStatus.LISTED)) + + assertEquals("MD · 0 B · 只看得到清单", chip.detail) + } + + // —— 待发附件(还没有任何服务端结论)—— + + @Test + fun `a draft attachment shows its local name and size with no status`() { + val chip = DocumentChips.of( + DraftDocument(uri = "content://doc/1", displayName = "报告.md", sizeBytes = 2048), + ) + + assertEquals("报告.md", chip.name) + assertEquals("MD", chip.typeLabel) + assertEquals("2.0 KB", chip.sizeLabel) + assertNull(chip.statusLabel) + assertFalse(chip.unreadable) + assertEquals("MD · 2.0 KB", chip.detail) + } + + @Test + fun `a draft with no metadata falls back to the uri and says nothing about size`() { + val chip = DocumentChips.of( + DraftDocument( + uri = "content://doc/1", + displayName = "", + sizeBytes = DraftDocument.UNKNOWN_SIZE, + ), + ) + + assertEquals("content://doc/1", chip.name) + assertEquals("", chip.sizeLabel) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt index ec8fbb1..cd64913 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt @@ -236,4 +236,41 @@ class DocumentDeliveryTest { fun `the txt file name can be overridden for the share intake`() { assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName) } + + // —— 只看份数的那条入口(调用方还没读字节时用)—— + + @Test + fun `planForCount answers the same shape as plan for every branch`() { + // 只比「形状」(拒绝 / 该不该走上传 / 正文要不要转文件):`plan` 还会把字节带上, + // `planForCount` 带不了——那是它存在的理由(见下一个用例)。 + assertEquals(DocumentPlan.Inline, DocumentDelivery.planForCount("嗨", 0)) + assertEquals( + DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = false), + DocumentDelivery.planForCount("嗨", 2), + ) + assertEquals( + DocumentPlan.Rejected(DocumentRejection.TooManyAttachments), + DocumentDelivery.planForCount("", 6), + ) + } + + @Test + fun `planForCount only differs from plan by not carrying the files`() { + // 调用方(ChatViewModel)用它**在读字节之前**决定「要不要拒绝 / 要不要走上传这条链」—— + // 字节是发送时才现读的,最多 5×20 MiB 一次性读进内存不可接受。 + val two = files(2) + + assertEquals( + DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = false), + DocumentDelivery.planForCount("看看这个", two.size), + ) + } + + @Test + fun `planForCount stops at the same attachment limit`() { + assertEquals( + DocumentPlan.Rejected(DocumentRejection.TooManyAttachments), + DocumentDelivery.planForCount("", DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1), + ) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt index 8bfbb96..c331c55 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/share/ShareExtractionTest.kt @@ -15,6 +15,15 @@ import top.yeij.cyrene.domain.chat.SendChannel */ class ShareExtractionTest { + /** + * 分享进来的一张图:`ContentResolver.getType` 照例回图片那一类。分流就发生在提取这一步, + * 所以每个与图片有关的用例都必须把它交进来——**默认值是「拿不到类型」**,那按文档路走。 + */ + private val imageMime: (String) -> String? = { "image/png" } + + /** 分享进来的一个文件:文件管理器对 `.txt` / `.md` / `.log` 的常见报法。 */ + private val textMime: (String) -> String? = { "text/plain" } + // —— 文本 —— @Test @@ -26,6 +35,7 @@ class ShareExtractionTest { assertEquals("帮我看一下这段话", share.text) assertTrue(share.images.isEmpty()) + assertTrue(share.documents.isEmpty()) assertEquals(0, share.droppedImages) assertFalse(share.isEmpty) } @@ -77,9 +87,11 @@ class ShareExtractionTest { val share = ShareRules.extract( action = ShareRules.ACTION_SEND, stream = "content://media/external/images/media/7", + mimeOf = imageMime, ) assertEquals(listOf("content://media/external/images/media/7"), share.images) + assertTrue(share.documents.isEmpty()) assertFalse(share.isEmpty) } @@ -89,6 +101,7 @@ class ShareExtractionTest { action = ShareRules.ACTION_SEND, text = "看这个", stream = "content://media/external/images/media/7", + mimeOf = imageMime, ) assertEquals("看这个", share.text) @@ -99,7 +112,11 @@ class ShareExtractionTest { fun `a multiple image share keeps them in order`() { val uris = (1..3).map { "content://media/external/images/media/$it" } - val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris) + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND_MULTIPLE, + streams = uris, + mimeOf = imageMime, + ) assertEquals(uris, share.images) assertEquals(0, share.truncatedImages) @@ -109,7 +126,11 @@ class ShareExtractionTest { fun `images beyond the cap are reported instead of silently dropped`() { val uris = (1..7).map { "content://media/external/images/media/$it" } - val share = ShareRules.extract(action = ShareRules.ACTION_SEND_MULTIPLE, streams = uris) + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND_MULTIPLE, + streams = uris, + mimeOf = imageMime, + ) assertEquals(ShareRules.MAX_IMAGES, share.images.size) assertEquals(uris.take(ShareRules.MAX_IMAGES), share.images) @@ -128,6 +149,7 @@ class ShareExtractionTest { "content://media/external/images/media/1", "content://media/external/images/media/2", ), + mimeOf = imageMime, ) assertEquals( @@ -143,7 +165,11 @@ class ShareExtractionTest { @Test fun `a single image share ignores a stream that only holds blank space`() { - val share = ShareRules.extract(action = ShareRules.ACTION_SEND, stream = " ") + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + stream = " ", + mimeOf = imageMime, + ) assertTrue(share.images.isEmpty()) assertTrue(share.isEmpty) @@ -159,6 +185,7 @@ class ShareExtractionTest { "content://media/external/images/media/9", "content://media/external/images/media/8", ), + mimeOf = imageMime, ) assertEquals( @@ -179,11 +206,102 @@ class ShareExtractionTest { "content://media/external/images/media/1", "content://media/external/images/media/2", ), + mimeOf = imageMime, ) assertEquals(listOf("content://media/external/images/media/1"), share.images) } + // —— 文件(非图片)分流 —— + + @Test + fun `a non image stream goes to the document path instead of the image path`() { + // 这是本模块修掉的那个真事故:一份 `.md` 此前被当图片拷、当图片解码, + // 结局只有「图片处理失败」或者被当成一张读不出来的图。 + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + stream = "content://com.android.providers.downloads.documents/document/42", + mimeOf = textMime, + ) + + assertTrue("非图片不该进图片路", share.images.isEmpty()) + assertEquals( + listOf("content://com.android.providers.downloads.documents/document/42"), + share.documents.map { it.uri }, + ) + assertFalse(share.isEmpty) + } + + @Test + fun `a stream whose type is unknown goes to the document path, never guessed`() { + // provider 不报类型(getType 回 null)时按文档路走:本端不猜类型,猜错就是替用户 + // 把一个合规文件变成一次失败。默认 mimeOf 就是「拿不到类型」。 + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + stream = "content://com.example/files/notes", + ) + + assertTrue(share.images.isEmpty()) + assertEquals(1, share.documents.size) + } + + @Test + fun `text and a file are kept side by side`() { + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND, + text = "看看这份", + stream = "content://com.example/files/report.md", + mimeOf = textMime, + ) + + assertEquals("看看这份", share.text) + assertEquals(1, share.documents.size) + assertEquals(0, share.truncatedDocuments) + } + + @Test + fun `images and files are split before either cap is applied`() { + // 混着分享时两边各自的份数必须独立计算:图片的上限是 4、文件的上限是 5, + // 先按一个上限裁完再分流,会把另一边的份额挤掉。 + val uris = (1..6).map { "content://media/external/images/media/$it" } + + (1..7).map { "content://com.example/files/doc$it" } + val mimeOf: (String) -> String? = { uri -> + if (uri.contains("images")) "image/png" else "text/plain" + } + + val share = ShareRules.extract( + action = ShareRules.ACTION_SEND_MULTIPLE, + streams = uris, + mimeOf = mimeOf, + ) + + assertEquals(ShareRules.MAX_IMAGES, share.images.size) + assertEquals(6 - ShareRules.MAX_IMAGES, share.truncatedImages) + assertEquals(ShareRules.MAX_DOCUMENTS, share.documents.size) + assertEquals(7 - ShareRules.MAX_DOCUMENTS, share.truncatedDocuments) + assertEquals(2, share.droppedDocuments) + } + + // —— MIME 分流本身(纯函数)—— + + @Test + fun `the image prefix decides the path, case and parameters included`() { + assertEquals(SharedStreamKind.Image, classifySharedStream("image/png")) + assertEquals(SharedStreamKind.Image, classifySharedStream("image/jpeg; charset=binary")) + assertEquals(SharedStreamKind.Image, classifySharedStream("IMAGE/PNG")) + assertEquals(SharedStreamKind.Image, classifySharedStream(" image/webp ")) + } + + @Test + fun `anything that is not an image goes to the document path`() { + assertEquals(SharedStreamKind.Document, classifySharedStream(null)) + assertEquals(SharedStreamKind.Document, classifySharedStream("")) + assertEquals(SharedStreamKind.Document, classifySharedStream("text/plain")) + assertEquals(SharedStreamKind.Document, classifySharedStream("application/octet-stream")) + // 已知会被服务端拒的也走文档路:由服务端按 415 说,而不是在这儿假装它是图片。 + assertEquals(SharedStreamKind.Document, classifySharedStream("application/pdf")) + } + // —— 超大文本 —— @Test @@ -229,4 +347,30 @@ class ShareExtractionTest { fun `an empty token still yields a usable file name`() { assertEquals("share-x-0.png", ShareRules.cacheFileName("", 0, "image/png")) } + + // —— 文件的缓存文件名 —— + + @Test + fun `a document cache name keeps the original extension so the server whitelist can judge it`() { + assertEquals("share-3f2a-0.md", ShareRules.documentCacheFileName("3f2a", 0, "报告.md")) + assertEquals("share-3f2a-2.toml", ShareRules.documentCacheFileName("3f2a", 2, "Cargo.toml")) + } + + @Test + fun `a document cache name falls back to txt when there is no usable extension`() { + assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "")) + assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "notes")) + // 超长 / 带分隔符的「扩展名」不是扩展名:一律回落 txt,绝不把它接进路径。 + assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "a.this-is-way-too-long")) + assertEquals("share-3f2a-0.txt", ShareRules.documentCacheFileName("3f2a", 0, "a.md/../../etc/passwd")) + } + + @Test + fun `a hostile document name cannot escape the cache directory either`() { + val name = ShareRules.documentCacheFileName("../../etc", 0, "../../etc/passwd") + + assertFalse(name, name.contains('/')) + assertFalse(name, name.contains("..")) + assertTrue(name, name.startsWith("share-")) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/DocumentUploadFailureTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/DocumentUploadFailureTest.kt new file mode 100644 index 0000000..c2faac3 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/DocumentUploadFailureTest.kt @@ -0,0 +1,103 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException + +/** + * 附件上传失败的文案(纯函数)。 + * + * 这里锚的是**每个拒绝码对应的正确动作**:这四个码在用户能做的事上各不相同 + * (415/413 = 换文件、400 = 少选几个、404 = 重传、401/403 = 重新登录),折成一句 + * 「附件失败」等于让用户去猜。文案由**服务端**给(含真实上限),客户端**不自己编数字**。 + * + * 另一个锚点是「这是上传这一步失败」:网络类失败必须点明,否则用户看到「无法连接到服务器」 + * 会以为整条消息没发出去,而实际上文字与附件一起回到了输入框。 + */ +class DocumentUploadFailureTest { + + private fun server(code: String, message: String, status: Int = 400) = + CyreneException(CyreneError.Server(code, message, status)) + + @Test + fun `unsupported media type keeps the server wording instead of inventing one`() { + val message = documentUploadFailureMessage( + server("unsupported_media_type", "extension not allowed or content is not plain text", 415), + ) + + assertEquals("extension not allowed or content is not plain text", message) + } + + @Test + fun `too large keeps the server limit so the number is the real one`() { + val message = documentUploadFailureMessage( + server("document_too_large", "file exceeds the 20971520 byte limit", 413), + ) + + assertEquals("file exceeds the 20971520 byte limit", message) + } + + @Test + fun `too many documents keeps the server wording as well`() { + val message = documentUploadFailureMessage(server("too_many_documents", "at most 5 documents", 400)) + + assertEquals("at most 5 documents", message) + } + + @Test + fun `a missing document says to send it again`() { + val message = documentUploadFailureMessage(server("document_not_found", "document not found", 404)) + + assertEquals("document not found", message) + } + + @Test + fun `an expired session asks for a re-login, not for another file`() { + val message = documentUploadFailureMessage( + CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")), + ) + + assertTrue(message, message.contains("重新登录")) + assertFalse("鉴权失败不该劝用户换文件", message.contains("换")) + } + + @Test + fun `a network failure names the step that failed and keeps the reason`() { + val message = documentUploadFailureMessage( + CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)), + ) + + assertEquals("附件上传失败,网络不可用,请检查连接", message) + } + + @Test + fun `an unknown server code still carries the server message`() { + val message = documentUploadFailureMessage(server("weird_new_code", "something went wrong", 500)) + + assertEquals("附件上传失败,something went wrong", message) + } + + @Test + fun `a non Cyrene throwable still produces an actionable message`() { + assertEquals("附件上传失败,请重试", documentUploadFailureMessage(IllegalStateException("boom"))) + assertEquals("附件上传失败,请重试", documentUploadFailureMessage(RuntimeException())) + } + + @Test + fun `no failure path ever claims the attachment was sent`() { + val messages = listOf( + documentUploadFailureMessage(server("unsupported_media_type", "x", 415)), + documentUploadFailureMessage(server("document_too_large", "x", 413)), + documentUploadFailureMessage(server("too_many_documents", "x", 400)), + documentUploadFailureMessage(server("document_not_found", "x", 404)), + documentUploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))), + documentUploadFailureMessage(IllegalStateException()), + ) + + // 文案里绝不能出现「已发送 / 已上传」这类暗示:失败即整轮回滚,附件与正文一起回到输入框。 + assertTrue(messages.toString(), messages.none { it.contains("已发送") || it.contains("已上传") }) + } +}