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

This commit is contained in:
AskaEth committed 2026-10-04 18:48:42 +08:00
commit ab76798bff
14 files changed
+1668 -65

No files matched your search

@@ -9,12 +9,14 @@ import top.yeij.cyrene.core.prefs.SessionPreferences
import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ChatApi 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.MetaApi
import top.yeij.cyrene.data.api.PersonaApi import top.yeij.cyrene.data.api.PersonaApi
import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.UploadApi import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository 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.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.PersonaRepository import top.yeij.cyrene.data.repository.PersonaRepository
@@ -105,6 +107,35 @@ class AppContainer(context: Context) {
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi) 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 cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = null private var cachedAdminBaseUrl: String? = null
@@ -3,14 +3,17 @@ package top.yeij.cyrene.app
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow 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.PendingShareHolder
import top.yeij.cyrene.domain.share.ShareRules import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@@ -26,13 +29,28 @@ import java.util.UUID
* ## 图片为什么在收到的那一刻就落盘 * ## 图片为什么在收到的那一刻就落盘
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页) * `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`, * 之后再去读,拿到的多半是 `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])。运行期间不做细粒度回收:那些文件在发送失败被回滚到 * 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。 * 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
* 目录因此以「一次进程生命周期内的分享次数 × 4」为界,另有系统的 cache 回收兜底。 * 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,见 [ShareRules]),
* 另有系统的 cache 回收兜底。
*/ */
object ShareIntake { object ShareIntake {
@@ -64,30 +82,41 @@ object ShareIntake {
/** /**
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。 * 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
* *
* 提取是纯函数([ShareRules.extract]),拷图是重活,所以在 IO 线程做:`content://` 的读 * 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。 * 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
*/ */
fun accept(context: Context, intent: Intent) { fun accept(context: Context, intent: Intent) {
val appContext = context.applicationContext
val extracted = ShareRules.extract( val extracted = ShareRules.extract(
action = intent.action, action = intent.action,
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT), text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
stream = intent.streamUri(), stream = intent.streamUri(),
streams = intent.streamUris(), streams = intent.streamUris(),
clipUris = intent.clipUris(), 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 { scope.launch {
val dir = shareDir(appContext).apply { mkdirs() } val dir = shareDir(appContext).apply { mkdirs() }
val copied = extracted.images.mapIndexedNotNull { index, uri -> val copied = extracted.images.mapIndexedNotNull { index, uri ->
copyToCache(appContext, dir, uri, index) copyToCache(appContext, dir, uri, index)
} }
val documents = extracted.documents.mapIndexedNotNull { index, document ->
copyDocumentToCache(appContext, dir, document, index)
}
holder.put( holder.put(
extracted.copy( extracted.copy(
images = copied, images = copied,
documents = documents,
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。 // 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
failedImages = extracted.failedImages + (extracted.images.size - copied.size), 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) 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`。 * 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
* *
@@ -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<DraftDocument> = emptyList(),
/** 超过 [DocumentAttachments.MAX_FILE_BYTES] 而未被接受的显示名。 */
val tooLarge: List<String> = 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<DraftDocument>, picked: List<DraftDocument>): AttachmentPickResult {
val seen = existing.mapTo(mutableSetOf()) { it.uri }
val tooLarge = mutableListOf<String>()
val fresh = mutableListOf<DraftDocument>()
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<DraftDocument>, index: Int): List<DraftDocument> =
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()
}
@@ -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>.<ext>`)或待发附件的本地显示名。 */
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
}
@@ -130,9 +130,25 @@ object DocumentDelivery {
* @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。 * @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
* @param files 本轮要挂的本地附件。 * @param files 本轮要挂的本地附件。
*/ */
fun plan(text: String, files: List<DocumentFile>): DocumentPlan = when { fun plan(text: String, files: List<DocumentFile>): DocumentPlan =
files.size > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments) when (val shape = planForCount(text, files.size)) {
files.isNotEmpty() -> DocumentPlan.AsDocuments(files = files, textAsDocument = textNeedsFile(text)) 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) textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
else -> DocumentPlan.Inline else -> DocumentPlan.Inline
} }
@@ -3,36 +3,87 @@ package top.yeij.cyrene.domain.share
/** /**
* 从别的 App 分享进来、已经可以落地的内容。 * 从别的 App 分享进来、已经可以落地的内容。
* *
* [images] 是**本端自己落盘后**的 `file://` URI:外部 `content://` 的读授权只在这个 intent * [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt`
* 的接收方(Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以图片必须在收到的那一刻 * 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方
* 就拷进 app 的 cache 目录(见 `app/ShareIntake.kt`)。 * (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。
* *
* 两个丢件计数分开记,是因为它们该说的话不一样:截断是「本端的规则」,读不到是「对方给的 * 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
* 东西用不了」。分享进来的内容**不许静默少发**——少发的部分要在界面上如实讲清楚。 * 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
*/ */
data class SharedContent( data class SharedContent(
val text: String = "", val text: String = "",
val images: List<String> = emptyList(), val images: List<String> = emptyList(),
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的条数。 */ /**
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
* (判定见 [classifySharedStream])——这正是修复「把一份 `.md` 当图片去解码、
* 最后报一句『图片读不到』」的地方。
*/
val documents: List<SharedDocument> = emptyList(),
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的图片条数。 */
val truncatedImages: Int = 0, val truncatedImages: Int = 0,
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的条数。 */ /** 已接收但**拷贝失败**(读不到 / 写不进 cache)的图片条数。 */
val failedImages: Int = 0, val failedImages: Int = 0,
/** 超过 [ShareRules.MAX_DOCUMENTS] 而未被接收的文件条数。 */
val truncatedDocuments: Int = 0,
/** 已接收但**拷贝失败**的文件条数。 */
val failedDocuments: Int = 0,
) { ) {
/** 没能落地的总条数。 */ /** 没能落地的总条数。 */
val droppedImages: Int get() = truncatedImages + failedImages 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`)。 * 分享入口的规则(纯函数,与 Android 无关,可单测;Intent 的读取在 `app/ShareIntake.kt`)。
* *
* ## 为什么只认这两个 action、两类 MIME * ## 为什么只认这两个 action、Manifest 里只声明两类 MIME
* Manifest 只声明 `text/plain` 与 `image/*`:声明的每一类都必须是**本端真能落地**的东西。 * Manifest 只声明 `text/plain` 与图片那一类(`image/` 前缀):声明的每一类都必须是**本端真能
* 声明 `*/*` 会让「分享 PDF / 视频 / 压缩包给昔涟」出现在系统分享菜单里,而对话路径上今天 * 落地**的东西。后端**明令拒绝** `text/html` / SVG / XML / 压缩包 / 脚本,声明全通配 MIME
* 根本没有「文件」这个表示(协同文档 `02/F1`:表示 / 存储 / 摄取三缺)——那会造出一个 * 或文本通配 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` * **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
@@ -41,10 +92,10 @@ data class SharedContent(
*/ */
object ShareRules { object ShareRules {
/** `Intent.ACTION_SEND`:一条文本,或一张图片(可能还带一段说明文字)。 */ /** `Intent.ACTION_SEND`:一条文本,或一张图片 / 一个文件(可能还带一段说明文字)。 */
const val ACTION_SEND = "android.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" const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"
/** /**
@@ -56,10 +107,19 @@ object ShareRules {
*/ */
const val MAX_IMAGES = 4 const val MAX_IMAGES = 4
/**
* 一次分享最多接收几个非图片文件。
*
* 取服务端的一个回合上限(契约 v0.59 §1.2:`documents` **≤5 条**)——附件是**挂在同一条
* 消息上**的,所以这里不能像图片那样「多出来的另起几条消息」:超过就是服务端
* `400 too_many_documents`。超出的部分同样如实告知,绝不静默裁。
*/
const val MAX_DOCUMENTS = 5
/** /**
* 从分享 Intent 的原始字段提取内容。 * 从分享 Intent 的原始字段提取内容。
* *
* 图片的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有 * URI 的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
* App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。 * App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。
* 两者都有时**只取前者**,不会把同一张图数两遍。 * 两者都有时**只取前者**,不会把同一张图数两遍。
* *
@@ -69,6 +129,9 @@ object ShareRules {
* @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串) * @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串)
* @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表) * @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表)
* @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略 * @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
* (见 [classifySharedStream],默认值就是「不猜」)。
*/ */
fun extract( fun extract(
action: String?, action: String?,
@@ -76,6 +139,7 @@ object ShareRules {
stream: String? = null, stream: String? = null,
streams: List<String>? = null, streams: List<String>? = null,
clipUris: List<String> = emptyList(), clipUris: List<String> = emptyList(),
mimeOf: (String) -> String? = { null },
): SharedContent { ): SharedContent {
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
if (!recognized) return SharedContent() if (!recognized) return SharedContent()
@@ -93,10 +157,23 @@ object ShareRules {
.filter(String::isNotEmpty) .filter(String::isNotEmpty)
.distinct() .distinct()
val images = mutableListOf<String>()
val documents = mutableListOf<String>()
unique.forEach { uri ->
when (classifySharedStream(mimeOf(uri))) {
SharedStreamKind.Image -> images += uri
SharedStreamKind.Document -> documents += uri
}
}
return SharedContent( return SharedContent(
text = body, text = body,
images = unique.take(MAX_IMAGES), images = images.take(MAX_IMAGES),
truncatedImages = (unique.size - MAX_IMAGES).coerceAtLeast(0), // 提取阶段还不知道显示名与大小(要 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] 都不信——非法字符一律折成 * 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成
* `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。 * `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。
*/ */
fun cacheFileName(token: String, index: Int, mime: String?): String { fun cacheFileName(token: String, index: Int, mime: String?): String =
val safe = token.map { c -> "share-${safeToken(token)}-$index.${extensionFor(mime)}"
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)}" * 非图片分享文件的缓存名。
*
* 扩展名**照抄对方给的名字**(服务端按扩展名判白名单,缓存件本身只是本端的临时载体,
* 但名字对得上排查时少一次误解),并且同样只由本端 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`。 */ /** MIME → 扩展名;未知 / 缺失回落 `img`。 */
private fun extensionFor(mime: String?): String = private fun extensionFor(mime: String?): String =
when (mime?.substringBefore(';')?.trim()?.lowercase()) { when (mime?.substringBefore(';')?.trim()?.lowercase()) {
@@ -47,6 +47,8 @@ import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.data.ws.WsState 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.ShareRules
import top.yeij.cyrene.domain.share.SharedContent import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button 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.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.basic.Sidebar 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.icon.extended.Image
import top.yukonga.miuix.kmp.theme.MiuixTheme 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 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 @Composable
fun ChatScreen( fun ChatScreen(
container: AppContainer, container: AppContainer,
@@ -91,6 +119,14 @@ fun ChatScreen(
ActivityResultContracts.PickVisualMedia(), ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } } ) { 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`),这里只做三件事: // 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给既有发送路径。 // 挑时机、取走一条、交给既有发送路径。
// - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上), // - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
@@ -158,6 +194,7 @@ fun ChatScreen(
InputBar( InputBar(
value = state.draft, value = state.draft,
imageUri = state.draftImage, imageUri = state.draftImage,
documents = state.draftDocuments,
busyText = busyLabel(state), busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange, onValueChange = viewModel::onDraftChange,
onSend = viewModel::send, onSend = viewModel::send,
@@ -167,6 +204,8 @@ fun ChatScreen(
) )
}, },
onClearImage = viewModel::clearDraftImage, onClearImage = viewModel::clearDraftImage,
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
onRemoveDocument = viewModel::removeDraftDocument,
) )
}, },
) { padding -> ) { padding ->
@@ -222,6 +261,9 @@ fun ChatScreen(
state.shareNotice?.let { message -> state.shareNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary) 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( private fun InputBar(
value: String, value: String,
imageUri: String?, imageUri: String?,
documents: List<DraftDocument>,
busyText: String, busyText: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
onPickImage: () -> Unit, onPickImage: () -> Unit,
onClearImage: () -> Unit, onClearImage: () -> Unit,
onPickDocument: () -> Unit,
onRemoveDocument: (Int) -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -258,6 +303,8 @@ private fun InputBar(
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) { ) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) } imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
DraftDocumentChips(documents = documents, onRemove = onRemoveDocument)
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。 // 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) { if (imageUri == null) {
@@ -270,6 +317,15 @@ private fun InputBar(
} }
Spacer(Modifier.width(4.dp)) 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( TextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
@@ -283,8 +339,9 @@ private fun InputBar(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Button( Button(
onClick = onSend, onClick = onSend,
// 图片可以单独发出(契约:text / image 至少一项非空)。 // 附件可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
enabled = value.isNotBlank() || imageUri != null, // 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || imageUri != null || documents.isNotEmpty(),
) { ) {
Text("发送") Text("发送")
} }
@@ -306,6 +363,16 @@ private fun InputBar(
} }
} }
/** 待发附件的芯片排:横向可滚(5 枚芯片不挤成两行,也不会把输入框顶上去)。 */
@Composable
private fun DraftDocumentChips(documents: List<DraftDocument>, onRemove: (Int) -> Unit) {
DocumentChipRow(
chips = documents.map(DocumentChips::of),
onRemove = onRemove,
modifier = Modifier.padding(bottom = 6.dp),
)
}
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */ /** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
@Composable @Composable
private fun DraftImagePreview(uri: String, onClear: () -> Unit) { private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
@@ -373,19 +440,23 @@ private fun busyLabel(state: ChatUiState): String = when {
} }
/** /**
* 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图) * 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图 /
* 一个字都不说。 * 一个文件)一个字都不说。
* *
* 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10` * 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10`
* (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西 * (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西
* 用不了,混成一句会让人以为图还在路上。 * 用不了,混成一句会让人以为图还在路上。图片与文件也分开说:两者的上限不是一个数。
*/ */
private fun shareNoticeFor(share: SharedContent): String? { private fun shareNoticeFor(share: SharedContent): String? {
val parts = buildList { val parts = buildList {
if (share.truncatedImages > 0) { if (share.truncatedImages > 0) {
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张") 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.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
if (share.isEmpty) add("分享的内容是空的,没有可发送的内容") if (share.isEmpty) add("分享的内容是空的,没有可发送的内容")
} }
return parts.takeIf { it.isNotEmpty() }?.joinToString(";") return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
@@ -1,8 +1,10 @@
package top.yeij.cyrene.ui.chat package top.yeij.cyrene.ui.chat
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest 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.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage 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.data.ws.isForSession
import top.yeij.cyrene.domain.chat.ChatImageDelivery import top.yeij.cyrene.domain.chat.ChatImageDelivery
import top.yeij.cyrene.domain.chat.ChatSendRouting 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.ImageDelivery
import top.yeij.cyrene.domain.chat.SendChannel 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.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser import top.yeij.cyrene.domain.parse.StreamingContentParser
import top.yeij.cyrene.domain.share.SharedContent import top.yeij.cyrene.domain.share.SharedContent
@@ -53,6 +64,11 @@ data class PendingMessage(
val text: String, val text: String,
/** 本次带的图片的**本地可读** URI(`content://` 或 `file://`);null = 纯文本消息。 */ /** 本次带的图片的**本地可读** URI(`content://` 或 `file://`);null = 纯文本消息。 */
val imageUri: String? = null, val imageUri: String? = null,
/**
* 本次带的文档附件(本地引用,还没上传)。它们与 [text]、[imageUri] 各自独立存在:
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
*/
val documents: List<DraftDocument> = emptyList(),
) )
/** /**
@@ -191,6 +207,12 @@ data class ChatUiState(
val draft: String = "", val draft: String = "",
/** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */ /** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */
val draftImage: String? = null, val draftImage: String? = null,
/**
* 已选好、还没发出去的文档附件(与 [draftImage] 平行的概念)。发送失败时与正文、图片一起
* 回到这里(见 [ChatViewModel.rollback]),所以它们必须一路带着**本地引用**——
* 重新上传的代价是几 MB 的流量与一次白等。
*/
val draftDocuments: List<DraftDocument> = emptyList(),
val loading: Boolean = true, val loading: Boolean = true,
val loadingOlder: Boolean = false, val loadingOlder: Boolean = false,
val hasMore: Boolean = true, val hasMore: Boolean = true,
@@ -202,6 +224,13 @@ data class ChatUiState(
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。 * 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。
*/ */
val shareNotice: String? = null, val shareNotice: String? = null,
/**
* 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。
*
* 与 [error] 分开、也与 [shareNotice] 分开:被挡下的文件**不是发送失败**,用户要做的动作
* 也不一样(换文件 / 先发一批)。同样到期自动消失([ATTACHMENT_NOTICE_MILLIS])。
*/
val attachmentNotice: String? = null,
val connection: WsState = WsState.Idle, val connection: WsState = WsState.Idle,
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */ /** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null, val busyMode: String? = null,
@@ -298,31 +327,64 @@ class ChatViewModel(
fun clearDraftImage() = _state.update { it.copy(draftImage = null) } fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
/** /**
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片还给输入框。 * 选好待发附件(SAF `OpenDocument` / `OpenMultipleDocuments` 回来的 URI)。
* *
* 路由 [ChatSendRouting]:**短文本且无图 → WS**(直达、绕过有损总线,今天就能用); * 元数据(显示名 / 字节数)要问 `ContentResolver`,所以放到 IO 线程;准入判定本身是纯函数
* **长文本或任何图片 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上流式 * ([DocumentAttachments.admit]),被挡下的份数如实告知、**不静默裁**。
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。 */
fun onDocumentsPicked(uris: List<String>) {
val picked = uris.map(String::trim).filter(String::isNotEmpty)
if (picked.isEmpty()) return
viewModelScope.launch {
val documents = withContext(Dispatchers.IO) { picked.map(::draftDocumentFor) }
var notice: String? = null
_state.update { state ->
val result = DocumentAttachments.admit(state.draftDocuments, documents)
notice = result.notice
state.copy(draftDocuments = state.draftDocuments + result.accepted)
}
notice?.let(::showAttachmentNotice)
}
}
/** 移除第 [index] 个待发附件(越界原样不动,UI 的移除按钮不必自己判界)。 */
fun removeDraftDocument(index: Int) =
_state.update { it.copy(draftDocuments = DocumentAttachments.removeAt(it.draftDocuments, index)) }
fun clearDraftDocuments() = _state.update { it.copy(draftDocuments = emptyList()) }
/**
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片 / 附件还给输入框。
*
* 路由 [ChatSendRouting]:**短文本且无图无附件 → WS**(直达、绕过有损总线,今天就能用);
* **长文本、任何图片、任何附件 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上
* 流式回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
* *
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上; * 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传** * 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`, * (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
* 也不把几 MB base64 永久留在每一页历史里。 * 也不把几 MB base64 永久留在每一页历史里。
*
* 附件**一律先上传**换服务端存储名(`POST /api/v1/documents`),再随 `chat.documents` 引用
* 发出,上行只带 `name`(见 [DocumentDelivery.payload] 与 [uploadDocuments])。
*/ */
fun send() { fun send() {
val snapshot = _state.value val snapshot = _state.value
val text = snapshot.draft.trim() val text = snapshot.draft.trim()
val imageUri = snapshot.draftImage val imageUri = snapshot.draftImage
if (text.isEmpty() && imageUri == null) return val documents = snapshot.draftDocuments
viewModelScope.launch { emitAndDeliver(text, imageUri, clearDraft = true) } if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
viewModelScope.launch { emitAndDeliver(text, imageUri, documents, clearDraft = true) }
} }
/** /**
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出 * 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
* (短文本 → WS,长文本 / 图片 → REST,图片先 [ChatImagePreparer] 重编码、大图先 `/upload`), * (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
* **不另起一套发送实现**,也不动用户正文里的一个字。 * 大图先 `/upload`、附件先 `/documents`),**不另起一套发送实现**,也不动用户正文里的一个字。
* *
* 一条消息只能带一张图,所以 N 张图 = N 条消息:第一条带上分享的正文,其余每条一张图。 * 一条消息只能带一张图,所以 N 张图 = N 条消息:第一条带上分享的正文,其余每条一张图。
* 非图片文件([SharedContent.documents])没有这个限制——它们与正文、图片挂在**同一条**
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。 * 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
* *
* 两处刻意的取舍: * 两处刻意的取舍:
@@ -334,10 +396,13 @@ class ChatViewModel(
fun sendShared(share: SharedContent, notice: String? = null) { fun sendShared(share: SharedContent, notice: String? = null) {
notice?.let(::showShareNotice) notice?.let(::showShareNotice)
val body = share.text.trim() 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 { viewModelScope.launch {
emitAndDeliver(body, share.images.firstOrNull(), clearDraft = false) emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false)
share.images.drop(1).forEach { emitAndDeliver("", it, 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] 共用)。 * 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。
* *
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条; * @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。 * 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
*/ */
private suspend fun emitAndDeliver(text: String, imageUri: String?, clearDraft: Boolean) { private suspend fun emitAndDeliver(
if (text.isEmpty() && imageUri == null) return text: String,
imageUri: String?,
documents: List<DraftDocument>,
clearDraft: Boolean,
) {
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。 // localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
val localId = "local-${UUID.randomUUID()}" val localId = "local-${UUID.randomUUID()}"
_state.update { _state.update {
it.copy( it.copy(
pending = it.pending + PendingMessage(localId, text, imageUri), pending = it.pending + PendingMessage(localId, text, imageUri, documents),
draft = if (clearDraft) "" else it.draft, draft = if (clearDraft) "" else it.draft,
draftImage = if (clearDraft) null else it.draftImage, draftImage = if (clearDraft) null else it.draftImage,
draftDocuments = if (clearDraft) emptyList() else it.draftDocuments,
error = null, error = null,
) )
} }
deliver(localId, text, imageUri) deliver(localId, text, imageUri, documents)
} }
private suspend fun deliver(localId: String, text: String, imageUri: String?) { private suspend fun deliver(
localId: String,
text: String,
imageUri: String?,
documents: List<DraftDocument>,
) {
// 判定矩阵([DocumentDelivery]):附件 >5 **直接拒绝,绝不裁剪**。这里用只需份数的
// [DocumentDelivery.planForCount]——字节还没读(最多 5×20 MiB 一次性读进内存不可接受),
// 而矩阵里唯一与文件内容有关的就是份数。
if (DocumentDelivery.planForCount(text, documents.size) is DocumentPlan.Rejected) {
rollback(localId, "一个回合最多 ${DocumentDelivery.MAX_ATTACHMENTS_PER_TURN} 个附件,没有发送")
return
}
// 产品定案(`05` 末节):**不做「长正文自动转文件」**。plan 的 `textAsDocument` 因此
// 一律不落地——用户打的字照发,长正文本来就已经由 [ChatSendRouting] 走 REST;
// 把它悄悄换成附件只会让它在历史里搜不到、气泡变成一枚芯片。
val image = imageUri?.let { uri -> val image = imageUri?.let { uri ->
withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) } withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
} }
@@ -382,20 +478,122 @@ class ChatViewModel(
return 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 -> { SendChannel.Ws -> {
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
if (!sent) rollback(localId, "发送失败,请重试") if (!sent) rollback(localId, "发送失败,请重试")
} }
SendChannel.Rest -> if (image == null) { SendChannel.Rest -> if (image == null) {
sendViaRest(localId, text, image = null) sendViaRest(localId, text, image = null, documents = payload)
} else { } else {
deliverImage(localId, text, image) deliverImage(localId, text, image, payload)
} }
} }
} }
/**
* 逐份把待发附件换成服务端存储名(`POST /api/v1/documents`,部件名 `file`、**保留扩展名**)。
*
* 三处刻意的取舍:
* 1. **一份一份传**(不是并发):上传失败要**立刻停**并把这一轮整个回滚,并发只会让后面
* 几份白传一遍,而服务端那边**没有删除端点**(`02/F7`)⇒ 多传一份就是多一份孤儿文件;
* 2. **字节现读**:`DraftDocument` 只带本地引用(见其 KDoc),最多 5×20 MiB 的字节不会
* 一直摆在 UI 状态里;读的时候用 [readBounded] 卡住上限,选完之后被换成超大文件也不会
* 先在内存里炸掉;
* 3. **`200 + deduped:true` 当成功**:[DocumentRepository] 已按 `apiCall` 的口径处理,
* 这里不做区分(同一份字节重复上传回的就是同一个 `name`)。
*
* @return 服务端存储名(顺序即附件顺序);任一份失败回 `null`(此时已经回滚并给过提示)。
*/
private suspend fun uploadDocuments(localId: String, documents: List<DraftDocument>): List<String>? {
val names = mutableListOf<String>()
documents.forEach { document ->
when (val read = readDocument(document)) {
is DocumentRead.TooLarge -> {
rollback(
localId,
"「${documentLabel(document)}」超过 ${DocumentAttachments.maxFileSizeLabel},没有发送",
)
return null
}
is DocumentRead.Unavailable -> {
rollback(localId, "读不到「${documentLabel(document)}」,请重新选择后再发")
return null
}
is DocumentRead.Ok -> {
val uploaded = container.documentRepository
.upload(read.bytes, document.displayName)
.getOrElse { e ->
rollback(localId, documentUploadFailureMessage(e))
return null
}
names += uploaded.name
}
}
}
return names
}
/**
* 读一份待发附件的字节。元数据里已经报了体积的就先按体积挡(省一次几 MB 的读),
* 其余边读边卡上限——**超限与读不到必须分开**,两者要用户做的事不一样。
*/
private suspend fun readDocument(document: DraftDocument): DocumentRead {
if (document.sizeBytes > DocumentAttachments.MAX_FILE_BYTES) return DocumentRead.TooLarge
return withContext(Dispatchers.IO) {
try {
val input = container.appContext.contentResolver.openInputStream(Uri.parse(document.uri))
?: return@withContext DocumentRead.Unavailable
input.use { stream ->
val bytes = readBounded(stream, DocumentAttachments.MAX_FILE_BYTES)
if (bytes == null) DocumentRead.TooLarge else DocumentRead.Ok(bytes)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
DocumentRead.Unavailable
}
}
}
/** 一条待发附件的元数据(SAF 回来的 URI → 显示名 + 字节数)。拿不到就留空 / 留 -1,不猜。 */
private fun draftDocumentFor(uri: String): DraftDocument {
val parsed = runCatching { Uri.parse(uri) }.getOrNull()
var name = ""
var size = DraftDocument.UNKNOWN_SIZE
if (parsed != null) {
runCatching {
container.appContext.contentResolver.query(parsed, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) name = cursor.getString(nameIndex).orEmpty()
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex)
}
}
}
}
return DraftDocument(
uri = uri,
displayName = name.ifBlank { DocumentAttachments.fallbackDocumentName(uri) },
sizeBytes = size,
)
}
/** /**
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image` * 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。 * (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
@@ -404,18 +602,32 @@ class ChatViewModel(
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与 * 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。 * [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
*/ */
private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) { private suspend fun deliverImage(
localId: String,
text: String,
image: PreparedImage,
documents: List<DocumentRefDto>,
) {
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) { 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 ImageDelivery.Upload -> container.uploadRepository
.uploadImage(image.bytes, image.mime) .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)) } .onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
} }
} }
private suspend fun sendViaRest(localId: String, text: String, image: String?) { /**
* 走 REST 发出这一轮(`stream:true`)。[documents] 是**已上传**的附件引用(空 = 没附件):
* 空列表在线上整键省略(`encodeDefaults = false`),所以不带附件的请求与加入该字段之前逐字节相同。
*/
private suspend fun sendViaRest(
localId: String,
text: String,
image: String?,
documents: List<DocumentRefDto> = emptyList(),
) {
val request = ChatRequest( val request = ChatRequest(
sessionId = sessionId, sessionId = sessionId,
text = text, text = text,
@@ -425,6 +637,7 @@ class ChatViewModel(
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。 // 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
stream = true, stream = true,
clientMsgId = localId, clientMsgId = localId,
documents = documents,
) )
container.chatRepository container.chatRepository
.send(request) .send(request)
@@ -507,8 +720,9 @@ class ChatViewModel(
.fold(pending) { acc, message -> consumePendingFor(acc, message) } .fold(pending) { acc, message -> consumePendingFor(acc, message) }
/** /**
* 回滚乐观气泡:把文本 / 图片还给输入框并给出原因(挂掉的气泡本身就是权威副本)。 * 回滚乐观气泡:把文本 / 图片 / 附件还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
* 空文本(纯图片)不覆盖用户当前正在输入的内容;图片照原样还给待发位,不必让用户重挑。 * 空文本(纯图片或纯附件)不覆盖用户当前正在输入的内容;图片与附件照原样还给待发位,
* 不必让用户重挑(附件已经上传过的那几份在服务端是孤儿,但本地引用仍然有效、可直接重发)。
*/ */
private fun rollback(localId: String, reason: String) { private fun rollback(localId: String, reason: String) {
_state.update { state -> _state.update { state ->
@@ -517,6 +731,7 @@ class ChatViewModel(
pending = state.pending.filterNot { it.localId == localId }, pending = state.pending.filterNot { it.localId == localId },
draft = failed?.text?.ifBlank { state.draft } ?: state.draft, draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
draftImage = failed?.imageUri ?: state.draftImage, draftImage = failed?.imageUri ?: state.draftImage,
draftDocuments = failed?.documents?.takeIf { it.isNotEmpty() } ?: state.draftDocuments,
error = reason, 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 SHARE_NOTICE_MILLIS = 8 * 1000L
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
/** 附件的可读名字:显示名拿不到时用 URI,再拿不到就只说「附件」(不编造一个名字)。 */
private fun documentLabel(document: DraftDocument): String =
document.displayName.ifBlank { document.uri }.ifBlank { "附件" }
/**
* 附件上传失败的文案(纯函数,可单测)。
*
* 四个服务端机器可读拒绝码各有**不同的正确动作**,所以那句话由服务端说(含真实上限与原因),
* 客户端原样展示、**绝不自己编一个数字**:`415 unsupported_media_type` / `413 document_too_large`
* 是「换文件」,`400 too_many_documents` 是「少选几个」,`404 document_not_found` 是「重传」
* (见 [DocumentFailure])。401/403 也原样透出——那种情况下正确的动作是重新登录,
* 而不是「换一份文件」。
*
* 其余(网络 / 超时 / 未知码 / 5xx)用既有错误口径,并**点明是上传这一步**失败:否则用户看到
* 「无法连接到服务器」会以为整条消息没发出去,而实际上文字与附件是跟着一起回滚的。
*/
fun documentUploadFailureMessage(e: Throwable): String {
val error = (e as? CyreneException)?.error
return when (val failure = e.toDocumentFailure()) {
is DocumentFailure.UnsupportedType -> failure.message
is DocumentFailure.TooLarge -> failure.message
is DocumentFailure.TooMany -> failure.message
is DocumentFailure.NotFound -> failure.message
is DocumentFailure.Unreadable -> failure.message
is DocumentFailure.Forbidden -> failure.message
is DocumentFailure.Other -> "附件上传失败," + (error?.userMessage ?: "请重试")
}
}
@@ -1,13 +1,16 @@
package top.yeij.cyrene.ui.chat package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -16,26 +19,38 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs 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.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.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse 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.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 流式进行中的光标。 */ /** 流式进行中的光标。 */
private const val CURSOR = "▍" private const val CURSOR = "▍"
/** 附件芯片排的最大宽度:与气泡同级,避免 5 枚芯片把整行铺满屏幕。 */
private val CHIP_ROW_MAX_WIDTH = 320.dp
/** /**
* 一条落库消息渲染成一个或多个气泡: * 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分, * - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方; * `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签; * - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片; * - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `image` 非空时在气泡上方显示图片。 * - `image` 非空时在气泡上方显示图片。
* *
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer], * 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
@@ -55,6 +70,7 @@ fun MessageBubble(message: HistoryMessageDto) {
MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) { MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) } message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
DocumentChipRow(chips = message.documents.map(DocumentChips::of))
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) } message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
bubbles.forEachIndexed { index, bubble -> bubbles.forEachIndexed { index, bubble ->
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex) Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
@@ -67,15 +83,102 @@ fun MessageBubble(message: HistoryMessageDto) {
* *
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、 * 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。 * 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
*/ */
@Composable @Composable
fun PendingBubble(item: PendingMessage) { fun PendingBubble(item: PendingMessage) {
MessageGroup(isUser = true, footer = "发送中…") { MessageGroup(isUser = true, footer = "发送中…") {
DocumentChipRow(chips = item.documents.map(DocumentChips::of))
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) } item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true) if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
} }
} }
/**
* 附件芯片排:横向可滚(最多 5 枚也不挤成两行),一枚一枚展示
* 「名字(中略,保住扩展名)/ 类型 · 体积 · 字数 · 状态」。
*
* [onRemove] 非空即带移除按钮——待发位(输入栏上方)要能反悔,历史气泡里则不给。
*
* 附件可能是空列表(绝大多数消息都是),此时**不占位、不留空白**。
*/
@Composable
fun DocumentChipRow(
chips: List<DocumentChipModel>,
modifier: Modifier = Modifier,
onRemove: ((Int) -> Unit)? = null,
) {
if (chips.isEmpty()) return
Row(
modifier = modifier
.widthIn(max = CHIP_ROW_MAX_WIDTH)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
chips.forEachIndexed { index, chip ->
DocumentChip(model = chip, onRemove = onRemove?.let { remove -> { remove(index) } })
}
}
}
/**
* 一枚附件芯片。
*
* 状态分两处说,**不许互相顶替**(契约 v0.59 §1.2):
* - `status:"unreadable"` = **昔涟读不到这个文件** → 整枚转灰 + 明写「读不到」;
* - `truncated:true` = **注入被预算截断**(正文照样进了上下文)→ 只加一句「注入已截断」。
*
* 未知 `status` 原样显示(开放集合,见 [DocumentChips.statusLabel])。
*/
@Composable
private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
if (model.unreadable) {
MiuixTheme.colorScheme.surfaceContainer
} else {
MiuixTheme.colorScheme.surfaceContainerHigh
},
)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = model.name,
style = MiuixTheme.textStyles.footnote1,
color = if (model.unreadable) {
MiuixTheme.colorScheme.onSurfaceVariantSummary
} else {
MiuixTheme.colorScheme.onSurfaceContainerHigh
},
maxLines = 1,
// 存储名是 32 位十六进制 + 扩展名,长名字必须中略——从尾部截会把扩展名切掉,
// 而扩展名正是这里唯一有语义的部分。
overflow = TextOverflow.MiddleEllipsis,
)
Text(
text = model.detail,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
onRemove?.let { remove ->
IconButton(onClick = remove) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除附件 ${model.name}",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */ /** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
@Composable @Composable
fun StreamingBubble(streaming: StreamingState) { fun StreamingBubble(streaming: StreamingState) {
@@ -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)
}
}
@@ -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)
}
}
@@ -236,4 +236,41 @@ class DocumentDeliveryTest {
fun `the txt file name can be overridden for the share intake`() { fun `the txt file name can be overridden for the share intake`() {
assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName) 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),
)
}
} }
@@ -15,6 +15,15 @@ import top.yeij.cyrene.domain.chat.SendChannel
*/ */
class ShareExtractionTest { class ShareExtractionTest {
/**
* 分享进来的一张图:`ContentResolver.getType` 照例回图片那一类。分流就发生在提取这一步,
* 所以每个与图片有关的用例都必须把它交进来——**默认值是「拿不到类型」**,那按文档路走。
*/
private val imageMime: (String) -> String? = { "image/png" }
/** 分享进来的一个文件:文件管理器对 `.txt` / `.md` / `.log` 的常见报法。 */
private val textMime: (String) -> String? = { "text/plain" }
// —— 文本 —— // —— 文本 ——
@Test @Test
@@ -26,6 +35,7 @@ class ShareExtractionTest {
assertEquals("帮我看一下这段话", share.text) assertEquals("帮我看一下这段话", share.text)
assertTrue(share.images.isEmpty()) assertTrue(share.images.isEmpty())
assertTrue(share.documents.isEmpty())
assertEquals(0, share.droppedImages) assertEquals(0, share.droppedImages)
assertFalse(share.isEmpty) assertFalse(share.isEmpty)
} }
@@ -77,9 +87,11 @@ class ShareExtractionTest {
val share = ShareRules.extract( val share = ShareRules.extract(
action = ShareRules.ACTION_SEND, action = ShareRules.ACTION_SEND,
stream = "content://media/external/images/media/7", stream = "content://media/external/images/media/7",
mimeOf = imageMime,
) )
assertEquals(listOf("content://media/external/images/media/7"), share.images) assertEquals(listOf("content://media/external/images/media/7"), share.images)
assertTrue(share.documents.isEmpty())
assertFalse(share.isEmpty) assertFalse(share.isEmpty)
} }
@@ -89,6 +101,7 @@ class ShareExtractionTest {
action = ShareRules.ACTION_SEND, action = ShareRules.ACTION_SEND,
text = "看这个", text = "看这个",
stream = "content://media/external/images/media/7", stream = "content://media/external/images/media/7",
mimeOf = imageMime,
) )
assertEquals("看这个", share.text) assertEquals("看这个", share.text)
@@ -99,7 +112,11 @@ class ShareExtractionTest {
fun `a multiple image share keeps them in order`() { fun `a multiple image share keeps them in order`() {
val uris = (1..3).map { "content://media/external/images/media/$it" } 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(uris, share.images)
assertEquals(0, share.truncatedImages) assertEquals(0, share.truncatedImages)
@@ -109,7 +126,11 @@ class ShareExtractionTest {
fun `images beyond the cap are reported instead of silently dropped`() { fun `images beyond the cap are reported instead of silently dropped`() {
val uris = (1..7).map { "content://media/external/images/media/$it" } 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(ShareRules.MAX_IMAGES, share.images.size)
assertEquals(uris.take(ShareRules.MAX_IMAGES), share.images) 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/1",
"content://media/external/images/media/2", "content://media/external/images/media/2",
), ),
mimeOf = imageMime,
) )
assertEquals( assertEquals(
@@ -143,7 +165,11 @@ class ShareExtractionTest {
@Test @Test
fun `a single image share ignores a stream that only holds blank space`() { 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.images.isEmpty())
assertTrue(share.isEmpty) assertTrue(share.isEmpty)
@@ -159,6 +185,7 @@ class ShareExtractionTest {
"content://media/external/images/media/9", "content://media/external/images/media/9",
"content://media/external/images/media/8", "content://media/external/images/media/8",
), ),
mimeOf = imageMime,
) )
assertEquals( assertEquals(
@@ -179,11 +206,102 @@ class ShareExtractionTest {
"content://media/external/images/media/1", "content://media/external/images/media/1",
"content://media/external/images/media/2", "content://media/external/images/media/2",
), ),
mimeOf = imageMime,
) )
assertEquals(listOf("content://media/external/images/media/1"), share.images) 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 @Test
@@ -229,4 +347,30 @@ class ShareExtractionTest {
fun `an empty token still yields a usable file name`() { fun `an empty token still yields a usable file name`() {
assertEquals("share-x-0.png", ShareRules.cacheFileName("", 0, "image/png")) 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-"))
}
} }
@@ -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("已上传") })
}
}