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 7e697c1..77705e1 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt @@ -8,8 +8,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.StateFlow import top.yeij.cyrene.domain.chat.DocumentAttachments +import top.yeij.cyrene.domain.chat.DocumentDelivery +import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.share.PendingShareHolder import top.yeij.cyrene.domain.share.ShareRules import top.yeij.cyrene.domain.share.SharedContent @@ -49,8 +52,8 @@ import java.util.UUID * 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以 * 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到 * 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。 - * 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,见 [ShareRules]), - * 另有系统的 cache 回收兜底。 + * 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument] + * 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。 */ object ShareIntake { @@ -132,6 +135,52 @@ object ShareIntake { private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME) + /** + * 把一段**分享进来的超长正文**落成 cache 里的 `.txt`,返回可当附件用的待发文档;写不进去回 `null`。 + * + * ## 为什么走本对象 + * 落成附件的那份字节必须有个**本端的本地地址**才能进既有发送链(芯片要显示它、上传要按 URI + * 现读字节、发送失败回滚后用户还可能要点开看):`DraftDocument` 只带引用不带字节。而写进 + * 「分享缓存」这个目录、跟着同一条清理规则(进程启动整目录删一次)是最省事也最一致的做法—— + * 与外部分享进来的文件**同一处落地、同一条生命周期**,不另造一个没人回收的目录。 + * 本函数只负责「落盘 + 报一个可用的引用」,**不判断**要不要转(那是 + * [DocumentDelivery.planSharedText] 的事)。 + * + * 文件名用 [ShareRules.documentCacheFileName] 生成(**只由本端 token 与序号拼路径**, + * 外部字符串一个字符都进不了文件名),但对**服务端**报的名字是 + * [DocumentDelivery.TEXT_DOCUMENT_FILE_NAME] ——服务端只取扩展名(白名单里的 `.txt`), + * 其余部分会被内容寻址丢弃。 + * + * @return 待发文档(含 `file://` 地址、显示名与**真实字节数**);任何一步失败都回 `null`, + * 由调用方按「内容一字不丢」的原则退回内联正文(见 `ChatViewModel.sendShared`)。 + */ + suspend fun stageTextAsDocument(context: Context, text: String): DraftDocument? = withContext(Dispatchers.IO) { + val staged = runCatching { DocumentDelivery.textAsFile(text) }.getOrNull() ?: return@withContext null + val dir = shareDir(context.applicationContext).apply { mkdirs() } + val target = File( + dir, + ShareRules.documentCacheFileName(UUID.randomUUID().toString(), 0, staged.fileName), + ) + try { + target.writeBytes(staged.bytes) + val size = target.length() + if (size > 0L) { + DraftDocument( + uri = Uri.fromFile(target).toString(), + displayName = staged.fileName, + sizeBytes = size, + ) + } else { + target.delete() + null + } + } catch (_: Exception) { + // 半截文件不能留下:它会被当成一份真附件去读,报出的错会指向错的地方。 + target.delete() + null + } + } + /** * 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数; * 失败回 `null`。 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 c8c14ec..4d72ea3 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 @@ -49,6 +49,33 @@ enum class DocumentRejection { TooManyAttachments, } +/** + * 「**从别的 App 分享进来**的长正文」该怎么送(纯函数,见 [DocumentDelivery.planSharedText])。 + * + * 只用于分享那条路。用户自己在输入框里打的字**永远**是 [Inline]——「不做长正文自动转文件」是 + * 已拍板的产品定案(`devdocs/05` 的「三条待拍板项的定案」第 3 条),本枚举不改变它一个字。 + */ +enum class SharedTextPlan { + + /** 正文照正文发(没到 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT],或附件位不够,见下一档)。 */ + Inline, + + /** + * 正文落成一份 `.txt` 附件:上传 `POST /api/v1/documents` 之后随 `chat.documents` 发, + * **正文那一栏留空**。 + */ + AsDocument, + + /** + * 正文长到该走文件,但**附件位已经被分享进来的文件占满** ⇒ 只能按正文发。 + * + * 调用方**必须**如实告知(分享那条路从不静默改形态)。取这一档而不是「把 .txt 塞进去、 + * 挤掉一个用户真的分享进来的文件」:那些文件是用户**明确**要发的东西,正文换形态是**为了 + * 少占篇幅**的便利——为了便利牺牲用户指名要发的内容,是不划算的交换。 + */ + InlineAttachmentsFull, +} + /** * 「该不该走文件」的判定与 `chat.documents` 载荷构造(纯函数,VM 与测试共用)。 * @@ -160,6 +187,29 @@ object DocumentDelivery { fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile = DocumentFile(fileName = fileName, bytes = text.toByteArray(Charsets.UTF_8)) + /** + * **分享路径**专用:这一轮的正文该怎么送([SharedTextPlan])。 + * + * 为什么分享这条路要转、而输入框那条不转(两件事**不是**同一件): + * 分享进来的长正文往往是**别的平台把卡片/文章摊平之后的一整段字**(主人原话:「有的时候部分平台 + * 发送到非官方支持卡片的平台会将卡片内容转化为比较长的字符」)——它不是用户抱着键盘敲出来的 + * 一句话,而是一份**搬运进来的内容**,以文本消息发出去会把会话拉得很长。用户自己打的字则相反: + * 那是他要说的话,悄悄换成附件只会让它在历史里搜不到、气泡变成一枚芯片(`devdocs/05` 的定案)。 + * + * 判据两步,都只有纯值输入: + * 1. 正文没到 [textNeedsFile] ⇒ [SharedTextPlan.Inline]; + * 2. 到了、但附件位已经满([MAX_ATTACHMENTS_PER_TURN])⇒ [SharedTextPlan.InlineAttachmentsFull] + * (**如实告知**由调用方负责);否则 ⇒ [SharedTextPlan.AsDocument]。 + * + * @param text 分享进来的正文(调用方已 trim)。 + * @param attachmentCount 这一轮**已经**要挂的附件份数(分享进来的文件,不含 .txt 本身)。 + */ + fun planSharedText(text: String, attachmentCount: Int): SharedTextPlan = when { + !textNeedsFile(text) -> SharedTextPlan.Inline + attachmentCount >= MAX_ATTACHMENTS_PER_TURN -> SharedTextPlan.InlineAttachmentsFull + else -> SharedTextPlan.AsDocument + } + /** * 由**已上传**的服务端存储名构造 `chat.documents` 载荷。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImageHostRebase.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImageHostRebase.kt new file mode 100644 index 0000000..8222577 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ImageHostRebase.kt @@ -0,0 +1,113 @@ +package top.yeij.cyrene.domain.chat + +/** + * 「保留 path、替换 host」:把历史图片里**绑死在上传那一刻的 host 上**的绝对地址,换到当前 baseUrl。 + * + * ## 它修的是什么(`devdocs/02` 的 `F6①` + 回执十三 §2) + * `POST /api/v1/upload` 的 `url` 由服务端按**请求时**的 `r.Host`(+ `X-Forwarded-Proto` / TLS) + * 拼成绝对地址,并且**原样落库**(读侧不重写)。于是换一个服务器地址(自部署换机器、`adb reverse` + * 换个端口、域名从 IP 换成域名)之后,旧消息里的图片地址指向的还是**当时那个 host**,本端也就取不回 + * 那些图。 + * + * 后端确认「读取侧三条路」里**只有主模型直收图**(`multimodal=true`)会真的去 GET 那串绝对 URL, + * 并明确认可本端自己做这一层:「你可以先在客户端做『保留 path、替换 host』」。本对象的形态就是 + * 那句原话的逐字落地:**只换 host,不碰 path**。 + * + * ## 保守到什么程度(不认识的一律原样返回) + * 只认这一种形态,其余**一个字符都不改**: + * + * ``` + * http(s)://<任意 host[:port]>/api/v1/files/ + * ``` + * + * 具体判据(全部满足才替换,否则原样返回): + * - 以 `http://` 或 `https://` 开头(大小写不敏感);**其它一切**都不是这一层的事——`data:` 内联图、 + * `content://` / `file://` 本地引用、相对路径、裸文件名,全都原样返回,不做任何猜测 + * (与 [ImageSource.of] 的同一条纪律:形态不认识的,交给它原来那条路); + * - 路径**恰好**是 `/api/v1/files/` 再加**一段**名字:名字非空、不含 `/`、不含 `%`、不含 `?` / `#`, + * 也不是 `.` / `..`。多一段、带查询串或片段、带百分号编码的,都判为「不是我们认识的那个地址」 + * ——第三方 CDN 的图与别人签过名的 URL 因此一个都不会被改; + * - [baseUrl] 本身能解出 `scheme://authority`(空串、没有 host 的都解不出,于是原样返回)。 + * + * 替换后用的是 **baseUrl 的 scheme + authority**(所以 `http` → `https`、端口变更都跟着走), + * **不用** baseUrl 里的路径:服务端自己拼这条地址时只取 `r.Host`,读侧 `GET /api/v1/files/{name}` + * 也挂在根路径上,所以部署形态本来就没有路径前缀这一说。 + * + * ## 逆作用:指向不同数据时会 404,那是**诚实的失败** + * 如果两个 host **指向的不是同一份存储**(换的是另一台机器、数据没迁过去),替换之后取到的就是 + * `404`。这仍然比现状好:不替换的话,那个地址**永远**取不回(而且是「连不上/超时」这种更含糊的 + * 失败),替换之后至少是一个说得清的结果,而图片那条路本来就会把失败如实报出来(见 + * [imageLoadFailureMessage])。**绝不**因此退回「不替换」——那等于把「换地址后旧图还能看」这件事 + * 彻底放弃。这条取舍是刻意的:宁可诚实地失败,也不要安静地永远失败。 + * + * ## 不做的事 + * 不改落库数据、不改上传、不碰 `documents`,也**不**在这一层做任何 IO:它只在渲染前过一遍字符串。 + * 纯函数,可单测(`ImageHostRebaseTest`)。 + */ +object ImageHostRebase { + + /** 服务端上传产物的读侧路径(`api-client` §1 行 3:`GET /api/v1/files/{name}`)。 */ + private const val FILES_PREFIX = "/api/v1/files/" + + private const val HTTP_SCHEME = "http://" + private const val HTTPS_SCHEME = "https://" + + /** + * 把 [raw] 里同源的图片地址换到 [baseUrl] 的 host 上;不认识的形态原样返回(判据见类型 KDoc)。 + * + * 幂等:已经指向当前 host 的地址走完也逐字节不变。 + * + * @param raw 历史消息里那份 `image` 字符串(或任何要交给渲染的图片地址)。 + * @param baseUrl 当前服务器地址(`AuthManager.currentBaseUrl`);空串 = 不替换。 + */ + fun apply(raw: String, baseUrl: String): String { + // 1. raw 必须是 http(s) 绝对地址,且能切出 authority 与 path。 + val rest = raw.absoluteRest() ?: return raw + val slash = rest.indexOf('/') + if (slash <= 0) return raw + // 2. 路径必须是「恰好一段文件名的 /api/v1/files/…」。 + if (!isSameOriginFilePath(rest.substring(slash))) return raw + // 3. baseUrl 必须能给出 scheme://authority。 + val origin = originOf(baseUrl) ?: return raw + return origin + rest.substring(slash) + } + + /** + * `http(s)://` 之后的部分(`/`);不是 http(s) 绝对地址就回 `null`。 + * + * 大小写不敏感,但**原样保留**后面那段:本函数只判断、不重写。 + */ + private fun String.absoluteRest(): String? = when { + startsWith(HTTP_SCHEME, ignoreCase = true) -> substring(HTTP_SCHEME.length) + startsWith(HTTPS_SCHEME, ignoreCase = true) -> substring(HTTPS_SCHEME.length) + else -> null + } + + /** + * `path` 是不是「恰好 `/api/v1/files/<一段名字>`」。 + * + * 判据取最小容忍度,理由与 [ImageNames.fromServerUrl] 的同一条:这个字符串来自**别人给的**历史 + * 数据,凡是看不懂的形态就不该被我们改写(改写它等于替用户的图片猜一个新地址)。 + */ + private fun isSameOriginFilePath(path: String): Boolean { + if (!path.startsWith(FILES_PREFIX)) return false + val name = path.removePrefix(FILES_PREFIX) + if (name.isEmpty() || name == "." || name == "..") return false + if (name.any { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }) { + return false + } + return name.isNotBlank() + } + + /** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */ + private fun originOf(baseUrl: String): String? { + val rest = baseUrl.absoluteRest() ?: return null + // authority 在第一个 `/`、`?`、`#` 之前结束(正常 baseUrl 只有尾部那个 `/`; + // 多出来的那两样不该被当成 host 的一部分)。 + val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it } + val authority = rest.substring(0, end) + if (authority.isBlank()) return null + val scheme = baseUrl.take(baseUrl.length - rest.length) + return scheme + authority + } +} 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 83c7f34..ecb2a16 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 @@ -64,6 +64,7 @@ import top.yeij.cyrene.data.repository.DocumentCacheStore import top.yeij.cyrene.data.repository.ImageCacheStore import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.domain.chat.DocumentChips +import top.yeij.cyrene.domain.chat.DocumentDelivery import top.yeij.cyrene.domain.chat.DocumentOpen import top.yeij.cyrene.domain.chat.DocumentOpenPlan import top.yeij.cyrene.domain.chat.DocumentOpenTypes @@ -72,6 +73,7 @@ import top.yeij.cyrene.domain.chat.DocumentSource import top.yeij.cyrene.domain.chat.DocumentTarget import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.chat.ImageSource +import top.yeij.cyrene.domain.chat.SharedTextPlan import top.yeij.cyrene.domain.chat.documentLoadFailureMessage import top.yeij.cyrene.domain.share.ShareRules import top.yeij.cyrene.domain.share.SharedContent @@ -441,6 +443,11 @@ fun ChatScreen( when (item) { is ChatDisplayItem.Committed -> MessageBubble( message = item.message, + // `F6①`:历史图片里那份绝对地址是按**上传时**的 host 拼的、 + // 且服务端原样落库,换服务器地址后旧图就取不回。渲染前把它换到 + // 当前 baseUrl(判据与取舍全在 ImageHostRebase 里,这里只喂地址)。 + // 只在组合期读一次:换服务器必然换会话/重进页面,不需要额外订阅。 + imageBaseUrl = container.authManager.currentBaseUrl, onDocumentClick = { ref -> previewNotice = null previewTarget = DocumentTarget.of(ref) @@ -833,6 +840,13 @@ private fun shareNoticeFor(share: SharedContent): String? { if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送") if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送") if (share.isEmpty) add("分享的内容是空的,没有可发送的内容") + // 超长正文本该落成 .txt 附件,但附件位已经被分享进来的文件占满 ⇒ 只能按正文发。 + // 这是**形态变了**(不是内容少了),所以单独成句且说清为什么(见 DocumentDelivery.planSharedText)。 + if (DocumentDelivery.planSharedText(share.text, share.documents.size) == + SharedTextPlan.InlineAttachmentsFull + ) { + add("正文较长、本想作为文件发送,但一个回合的 ${DocumentDelivery.MAX_ATTACHMENTS_PER_TURN} 个附件位已占满,只能按正文发送") + } } 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 51dac9b..5df67fb 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 @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.app.ShareIntake import top.yeij.cyrene.core.error.CyreneError import top.yeij.cyrene.core.prefs.AppPrefs import top.yeij.cyrene.core.prefs.InterruptMode @@ -50,6 +51,7 @@ import top.yeij.cyrene.domain.chat.ImageDelivery import top.yeij.cyrene.domain.chat.MessageFingerprint import top.yeij.cyrene.domain.chat.RetryKeyMemory import top.yeij.cyrene.domain.chat.SendChannel +import top.yeij.cyrene.domain.chat.SharedTextPlan import top.yeij.cyrene.domain.chat.editableMessageOf import top.yeij.cyrene.domain.chat.interruptScopeForSend import top.yeij.cyrene.domain.chat.readBounded @@ -554,6 +556,23 @@ class ChatViewModel( private var oldestLoadedAt: Long? = null private var streamParser: StreamingContentParser? = null + /** + * 本回合是否已经收到过**结构化正文帧**(`message.start` / `message.delta`)。 + * + * 只服务 [WsEvent.StreamChunk] 那一条兜底分支:契约 §3.1 的回退路径里 `delta` 是**整段** `res.Text` + * 而不是增量(`02` 回执十三 §1 后端确认),所以它**不能**与同回合的结构化帧叠加——两者都到, + * 同一段正文就会被追加成重影。 + * + * 判据是「这一回合里见过结构化帧没有」,**不是**「解析器里有没有内容」:兜底帧自己也会往同一个 + * 解析器里写,用解析器状态判会把第二条兜底帧误判成结构化帧,反而把兜底内容整帧丢掉。 + * + * 归零的时机 = **一次回合的起与止**:[WsEvent.MessageStart] 的 `index == 0`(新回合开始)、 + * [WsEvent.StreamEnd](收口)与 [onPersisted] 的 assistant 分支(落库取代流式气泡)。 + * 归零晚一步的代价是**下一轮**的兜底帧被判成「与结构化帧并存」而整帧丢弃——那正是这条分支 + * 存在的意义(未知帧不丢内容)的反面。 + */ + private var structuredTextFramesThisTurn = false + /** * 掉线那一刻的 `after` 游标(契约 §1 行 6):为 null = 当时没有需要补的洞。 * @@ -885,6 +904,22 @@ class ChatViewModel( * 「分享超过一张时才不打断」得先跟人解释清为什么是一张的分界。 * 用户真的想打断,发送键那条路([send])的口径一字未动。 * + * ## 超长正文走文件(主人 2026-10-05 拍板) + * 分享进来的正文超过 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT] 时,**落成一份 `.txt`** + * 走附件那条路(上传 `POST /api/v1/documents` → 随 `chat.documents` 发),正文那一栏留空。 + * 理由见 [DocumentDelivery.planSharedText]:别人分享过来的常常是「卡片被摊平之后的一整段字」, + * 以文本消息发出会把会话拉得很长。 + * + * ⚠️ **只管这一条路**:用户自己在输入框里打的字**逐字节不动**——「不做长正文自动转文件」是已拍板 + * 的产品定案(`devdocs/05` 的「三条待拍板项的定案」第 3 条),与上面这件事**不是**同一件。 + * + * 组合规则(最小惊讶原则):那份 `.txt` 就落在**正文原来所在的那一条**上——附件本来就是与正文 + * 同一条消息上的数组,所以「正文变附件」不改变这一条消息的位置与配对关系。图片那条规则也不变: + * 第一条带第一张图(外加分享的文件与那份 `.txt`),其余每条一张图。 + * 附件位已满(分享已带 [DocumentDelivery.MAX_ATTACHMENTS_PER_TURN] 份文件)时**不做转换**: + * 正文照正文发,并由渲染层的提示说清(`shareNoticeFor`)——**绝不**为了塞下 `.txt` 挤掉用户 + * 明确要发的文件。落盘失败同理:退回内联正文(内容一字不丢)并说一句。 + * * @param notice 如实告知(截断 / 读不到 / 空内容),由渲染层组织文案;null = 没什么要说的 */ fun sendShared(share: SharedContent, notice: String? = null) { @@ -895,7 +930,19 @@ class ChatViewModel( } if (body.isEmpty() && share.images.isEmpty() && documents.isEmpty()) return viewModelScope.launch { - emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false, allowInterrupt = false) + var text = body + var files = documents + if (DocumentDelivery.planSharedText(body, documents.size) == SharedTextPlan.AsDocument) { + val staged = ShareIntake.stageTextAsDocument(container.appContext, body) + if (staged != null) { + text = "" + files = documents + staged + } else { + // 落盘失败**不静默**:内容照发,但要说明它没能换成文件(说清现状,不改事实)。 + showShareNotice(SHARE_TEXT_NOT_STAGED) + } + } + emitAndDeliver(text, share.images.firstOrNull(), files, clearDraft = false, allowInterrupt = false) share.images.drop(1).forEach { emitAndDeliver("", it, emptyList(), clearDraft = false, allowInterrupt = false) } @@ -1439,14 +1486,17 @@ class ChatViewModel( is WsEvent.MessageStart -> { if (event.index == 0) { streamParser = StreamingContentParser() + structuredTextFramesThisTurn = false freshTurnIfFinished() } + structuredTextFramesThisTurn = true val parser = streamParser ?: StreamingContentParser().also { streamParser = it } val bubbles = parser.onDelta("") updateStreaming { it.copy(bubbles = bubbles, active = true) } } is WsEvent.MessageDelta -> { + structuredTextFramesThisTurn = true val parser = streamParser ?: StreamingContentParser().also { streamParser = it } val bubbles = parser.onDelta(event.delta) updateStreaming { it.copy(bubbles = bubbles, active = true) } @@ -1459,20 +1509,39 @@ class ChatViewModel( * 只在这两帧里**——这里不接它,它会落进 `onEvent` 末尾的 `else -> Unit` 被静默丢掉: * 屏幕上那一轮回答是空的,而且没有任何信号说明正文丢了。 * - * 「与结构化帧**互斥**」的依据就是上面那句话本身:回退路径的语义是「结构化流帧没能承载 - * 这次事件」,所以同一轮里 `stream_chunk` 与 `message.*` 不会并存。因此这一帧可以照 - * [WsEvent.MessageDelta] 的同一形态喂进同一个 `streamParser`,不必去分辨「它属于哪条流」。 + * ## 它的语义是「整段」,不是「增量」(2026-10-05 按后端回执十三 §1 改) + * 后端确认三件事:① 生产里**已无生产者**(唯一的整段广播被 `Deps.Streaming=true` 挡死, + * 另一处只是未知 `StreamKind` 的兜底);② `delta` 就是**整段** `res.Text`;③ **不会**与 + * 结构化帧并存。所以 W26 那版「照 [WsEvent.MessageDelta] 追加进同一个气泡」是**错的**: + * 它真到达时会把同一段内容**追加成重影**。现在按同一份回执给的口径分成两支: * - * 一处必须注意的差别:`stream_chunk` **不带 `index`**(那是 [WsEvent.MessageStart] 的字段), - * 所以这里不读它——它只是「当前这条流」的增量;它也不负责开一轮(回退路径里没有 - * `message.start`),解析器因此按需创建,与上面 `MessageDelta` 分支逐字一致。 + * - 同一回合内**已经收到过结构化帧**([structuredTextFramesThisTurn])⇒ **整帧忽略**: + * 它是兜底,不能与结构化帧叠加; + * - 否则 ⇒ [StreamingContentParser.reset] 之后**整段喂进去**,即用这一整段**替换**当前 + * 流式气泡的正文(不是追加)。多条兜底帧先后到达时后到的那段覆盖前一段——「整段」 + * 这个语义本身就意味着它不是可以相加的增量。 + * + * ## 为什么保留这条分支 + * 后端明确不建议删:它是「未知帧不丢内容」的兼容路径(将来真出现未知 `StreamKind` 时, + * 那一轮的正文只有这一个出口)。删掉它,静默丢正文的风险面只会更大。 + * + * ## 顺手补上的一处边界:回退路径没有 `message.start` + * [WsEvent.MessageStart] 的 `index == 0` 是既有代码里唯一会 [freshTurnIfFinished] 的地方, + * 而回退路径没有这一帧 ⇒ 上一轮的思维链 / 工具卡会串到这一轮来。后端确认回退路径**无 + * 触发源**(所以这不是一个能复现的 bug),这里只是把这块补成与 `message.start` 逐字相同的 + * 收口。它跑不到第二次:第一帧之后 `active` 已经是 true,「已结束才清」的判据自然不再成立。 * * 会话归属照旧由 `onEvent` 开头那道闸收敛(本帧带 `sessionId`,见 [isForSession])。 */ is WsEvent.StreamChunk -> { - val parser = streamParser ?: StreamingContentParser().also { streamParser = it } - val bubbles = parser.onDelta(event.delta) - updateStreaming { it.copy(bubbles = bubbles, active = true) } + if (!structuredTextFramesThisTurn) { + freshTurnIfFinished() + val parser = streamParser ?: StreamingContentParser().also { streamParser = it } + // reset 而不是追加:`delta` 是整段,见上面「它的语义是整段」一段。 + parser.reset() + val bubbles = parser.onDelta(event.delta) + updateStreaming { it.copy(bubbles = bubbles, active = true) } + } } // —— 思维链 —— @@ -1519,6 +1588,8 @@ class ChatViewModel( is WsEvent.StreamEnd -> { val bubbles = streamParser?.finish().orEmpty() + // 这一轮到此收口:下一轮的兜底帧要按「没见过结构化帧」重新判(见字段 KDoc)。 + structuredTextFramesThisTurn = false updateStreaming { it.copy(bubbles = bubbles, thinkingActive = false, active = false) } @@ -1653,7 +1724,11 @@ class ChatViewModel( null } val persisted = if (streamedThinking != null) msg.copy(thinking = streamedThinking) else msg - if (clearStreaming) streamParser = null + if (clearStreaming) { + streamParser = null + // 落库正文已经取代了流式临时气泡,这一轮的兜底判据跟着一起收口(见字段 KDoc)。 + structuredTextFramesThisTurn = false + } _state.update { state -> state.copy( @@ -1817,6 +1892,14 @@ private const val SESSION_NOT_FOUND_CODE = "session_not_found" /** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */ private const val SHARE_NOTICE_MILLIS = 8 * 1000L +/** + * 超长分享正文**没能落成文件**时的如实告知(见 [ChatViewModel.sendShared])。 + * + * 措辞只说本端做了的两件事:内容**照发**了(所以不慌),只是没换成文件(所以篇幅照旧)。 + * 不猜原因(缓存写不进去有十几种可能,用户能做的是「知道它照发了」)。 + */ +private const val SHARE_TEXT_NOT_STAGED = "这段内容没能存成文件,已按正文发送" + /** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */ private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L 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 7495a91..13298dd 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 @@ -38,6 +38,7 @@ import top.yeij.cyrene.data.repository.canEditMessage import top.yeij.cyrene.domain.chat.DocumentChipModel import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DraftDocument +import top.yeij.cyrene.domain.chat.ImageHostRebase import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ParsedBubble @@ -68,6 +69,34 @@ private const val MESSAGE_ACTION_DESCRIPTION = "消息操作" /** 长按气泡的读屏名字:说清按下去会发生什么(不长按就永远不知道)。 */ private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作菜单" +/** + * 「这条消息什么都没传过来」时写在气泡里的那句话。 + * + * 只陈述**本端确定的事实**(内容没有到达本端),**不猜原因**:不说「对方删了」、也不说「这个渠道 + * 不支持」——那两句我们都没有证据(真正的根因在服务端,已登记 `devdocs/02` 的「IM 渠道的文件没有挂到 + * 消息的 documents 上」那一条)。措辞见 [ContentlessNote]。 + */ +private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传过来" + +/** + * 这条消息**什么都没有**:正文是空的、没有图、没有附件,也没有思维链。 + * + * 判据刻意**窄**,只有 `user` 消息才可能成立: + * - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白); + * - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说), + * 顺手改会波及所有助手气泡,不在本次范围内。 + * + * 为什么需要它:微信等 IM 渠道进来的文件**没有挂到消息的 `documents` 上**(`documents` 键整个缺席、 + * `content` 也是空),而昔涟那边确实读到了内容。客户端拿到手的真是一条什么都没有的消息,此前渲染成 + * **空气泡**——看的人只能猜。这一条判据就是「要如实说一句」的那个入口。 + */ +fun isContentlessUserMessage(message: HistoryMessageDto): Boolean = + message.role == "user" && + message.content.isBlank() && + message.image.isNullOrBlank() && + message.documents.isEmpty() && + message.thinking.isNullOrBlank() + /** * 一条落库消息渲染成一个或多个气泡: * - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `

` 切分, @@ -75,7 +104,10 @@ private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作 * - 其它 mode 原样整条渲染,不解析标签; * - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片; * - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画); - * - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick])。 + * - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过 + * [ImageHostRebase](见 [imageBaseUrl]); + * - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明 + * ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。 * * 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer], * 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。 @@ -92,12 +124,16 @@ private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作 * 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]), * 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。 * + * @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在 + * 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的 + * `r.Host` 拼、且原样落库 ⇒ 换地址后旧图取不回)。空串 = 不替换([ImageHostRebase] 会原样返回)。 * @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。 * @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。 */ @Composable fun MessageBubble( message: HistoryMessageDto, + imageBaseUrl: String = "", onDocumentClick: (DocumentRefDto) -> Unit = {}, onImageClick: (String) -> Unit = {}, onEdit: (HistoryMessageDto) -> Unit = {}, @@ -131,9 +167,15 @@ fun MessageBubble( alignEnd = isUser, onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) }, ) - message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) } - bubbles.forEachIndexed { index, bubble -> - Bubble(bubble, isUser, isLast = index == bubbles.lastIndex, onLongPress = openMenu) + message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) } + ?.let { ChatImage(it, onImageClick) } + if (isContentlessUserMessage(message)) { + // 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。 + ContentlessNote(onLongPress = openMenu) + } else { + bubbles.forEachIndexed { index, bubble -> + Bubble(bubble, isUser, isLast = index == bubbles.lastIndex, onLongPress = openMenu) + } } } @@ -393,6 +435,48 @@ private fun AnnotationLine(parts: List) { } } +/** + * 「这条消息的内容没有被传过来」——**在气泡里**如实说一句(不是顶部横幅:那句话说的是这一条 + * 消息,挂在页面上就等于在说整页;而且横幅会自己消失,空气泡不会)。 + * + * ## 为什么必须是灰底灰字 + * 它是**说明**,不是内容:正经气泡的配色(用户青色 / 助手灰底)会让这句话读起来像「他说的某一句话」, + * 而事实恰恰是「这一条里什么都没有」。灰底 + 文件里既有的灰态小字([MiuixTheme.textStyles.footnote1] + * + `onSurfaceVariantSummary`,与旁白那行同源)在两种角色下都读得清,也一眼看得出与正文不是一回事。 + * + * ## 长按仍然开动作菜单 + * 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。 + * 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。 + */ +@Composable +private fun ContentlessNote(onLongPress: (() -> Unit)? = null) { + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .clip(BubbleShape) + .background(MiuixTheme.colorScheme.surfaceContainer) + .then( + if (onLongPress == null) { + Modifier + } else { + Modifier.combinedClickable( + onLongClickLabel = MESSAGE_ACTION_LONG_PRESS_LABEL, + onLongClick = onLongPress, + onClick = {}, + ) + }, + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text( + text = CONTENTLESS_MESSAGE_NOTE, + style = MiuixTheme.textStyles.footnote1, + fontStyle = FontStyle.Italic, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } +} + @Composable private fun BubbleBody(text: String, isUser: Boolean, onLongPress: (() -> Unit)? = null) { Box( @@ -436,7 +520,8 @@ private fun BubbleBody(text: String, isUser: Boolean, onLongPress: (() -> Unit)? /** * 气泡里的图片(缩略)。 * - * 点了就进全屏查看——[onClick] 收到的是**原始**那一份字符串(`data:` / `http(s)` / `content://`), + * 点了就进全屏查看——[onClick] 收到的是**已经替换过 host** 的那一份字符串([ImageHostRebase]: + * `data:` 与本地引用原样、只有服务端同源图片地址会被换到当前 baseUrl), * 来源判定交给 `ImageSource.of`,这里不猜。点击区就是这张图本身(`clip` 之后可点, * 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。 * 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 acd02e5..32a5778 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 @@ -273,4 +273,64 @@ class DocumentDeliveryTest { DocumentDelivery.planForCount("", DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1), ) } + + // —— 分享路径:超长正文走文件(`planSharedText`)—— + + /** + * 主人 2026-10-05 拍板:「分享进来的超长内容走文件」。 + * + * 判据是**两条**:正文过不过 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT]、以及附件位 + * ([DocumentDelivery.MAX_ATTACHMENTS_PER_TURN])还剩不剩。两处都用现成常量,本测试也照抄常量, + * 不重复写死数字——阈值改了这里跟着走,判据才算真的被钉住。 + */ + private fun longText() = "长".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1) + + @Test + fun `a short shared text stays inline no matter how many attachments came with it`() { + assertEquals(SharedTextPlan.Inline, DocumentDelivery.planSharedText("看看这个", 0)) + assertEquals( + SharedTextPlan.Inline, + DocumentDelivery.planSharedText("看看这个", DocumentDelivery.MAX_ATTACHMENTS_PER_TURN), + ) + } + + @Test + fun `the rune threshold is inclusive on the share path too`() { + val atLimit = "长".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT) + + assertEquals(SharedTextPlan.Inline, DocumentDelivery.planSharedText(atLimit, 0)) + } + + @Test + fun `an over-limit shared text becomes a document while there is room`() { + assertEquals(SharedTextPlan.AsDocument, DocumentDelivery.planSharedText(longText(), 0)) + assertEquals(SharedTextPlan.AsDocument, DocumentDelivery.planSharedText(longText(), 1)) + // 位还剩一个:正文落成的那份 .txt 正好占掉它。 + assertEquals( + SharedTextPlan.AsDocument, + DocumentDelivery.planSharedText(longText(), DocumentDelivery.MAX_ATTACHMENTS_PER_TURN - 1), + ) + } + + @Test + fun `an over-limit shared text falls back to inline when every attachment slot is taken`() { + // 附件是**用户明确要发的东西**,正文换形态只是为了少占篇幅的便利——不为便利挤掉前者。 + // 这一档要求调用方如实告知(见 ChatScreen 的 shareNoticeFor)。 + assertEquals( + SharedTextPlan.InlineAttachmentsFull, + DocumentDelivery.planSharedText(longText(), DocumentDelivery.MAX_ATTACHMENTS_PER_TURN), + ) + assertEquals(SharedTextPlan.InlineAttachmentsFull, DocumentDelivery.planSharedText(longText(), 9)) + } + + @Test + fun `the text-as-document plan is reachable through the shared constants only`() { + // 端到端地把两个常量钉在一起:`plan` 那条矩阵说「超限 ⇒ 该走文件」,分享这条说 + // 「有位 ⇒ 真的走文件」。两边用的是同一个 TEXT_AS_DOCUMENT_RUNE_LIMIT。 + assertEquals( + DocumentPlan.AsDocuments(textAsDocument = true), + DocumentDelivery.plan(longText(), emptyList()), + ) + assertEquals(SharedTextPlan.AsDocument, DocumentDelivery.planSharedText(longText(), 0)) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImageHostRebaseTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImageHostRebaseTest.kt new file mode 100644 index 0000000..c200c25 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ImageHostRebaseTest.kt @@ -0,0 +1,193 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * `F6①`:历史图片的「保留 path、替换 host」。 + * + * **形状来源**:`devdocs/02` 的 `F6①` + **回执十三 §2**(后端确认 `POST /api/v1/upload` 的 `url` + * 按**请求时**的 `r.Host` 拼成绝对地址并原样落库,且明确认可本端做这一层)。 + * + * 这个测试要钉住两件事: + * 1. **同源绝对地址被换到当前 baseUrl**(换服务器地址后旧图仍能显示); + * 2. **其余一切形态逐字节原样返回**——第三方 CDN、`data:`、相对路径、裸文件名、带查询串 / + * 百分号编码 / 多一段路径的,一个字符都不许改。猜错一条就是替用户的图片编了一个新地址, + * 而且失败的样子会是「图没了」。 + */ +class ImageHostRebaseTest { + + private val base = "https://cyrene.example.com:8443" + + // —— 该替换的:只有同源文件路径这一种形态 —— + + @Test + fun `a same-origin file url is moved to the current host`() { + assertEquals( + "$base/api/v1/files/6c1f0a2b3d4e5f60718293a4b5c6d7e8.png", + ImageHostRebase.apply( + "http://127.0.0.1:10942/api/v1/files/6c1f0a2b3d4e5f60718293a4b5c6d7e8.png", + base, + ), + ) + } + + @Test + fun `the path is kept verbatim - only scheme and authority come from the base url`() { + // 名字里的每个字符都要原样活下来:本端不编码、不解码、不小写化。 + val name = "AbC123.txt.png" + assertEquals( + "$base/api/v1/files/$name", + ImageHostRebase.apply("http://10.0.0.5:8080/api/v1/files/$name", base), + ) + } + + @Test + fun `a base url with a trailing slash or a path prefix only contributes scheme and authority`() { + // 服务端自己拼这条地址时只取 r.Host,读侧 GET /api/v1/files/{name} 也挂在根路径上—— + // 所以 baseUrl 里多余的那一段路径不该被搬进图片地址。 + val url = "http://old-host:10942/api/v1/files/abc.png" + + assertEquals("https://cyrene.example.com/api/v1/files/abc.png", ImageHostRebase.apply(url, "https://cyrene.example.com/")) + assertEquals("https://cyrene.example.com/api/v1/files/abc.png", ImageHostRebase.apply(url, "https://cyrene.example.com/prefix/")) + assertEquals("https://cyrene.example.com/api/v1/files/abc.png", ImageHostRebase.apply(url, "https://cyrene.example.com?x=1")) + } + + @Test + fun `scheme and port follow the base url - http to https and back`() { + val url = "https://old.example.com/api/v1/files/abc.png" + + assertEquals( + "http://192.168.1.7:8080/api/v1/files/abc.png", + ImageHostRebase.apply(url, "http://192.168.1.7:8080"), + ) + assertEquals( + "https://192.168.1.7:443/api/v1/files/abc.png", + ImageHostRebase.apply("http://192.168.1.7:80/api/v1/files/abc.png", "https://192.168.1.7:443"), + ) + } + + @Test + fun `a host carrying a port is replaced as a whole`() { + assertEquals( + "http://host-with-port:70000/api/v1/files/a.png", + ImageHostRebase.apply("http://localhost:10942/api/v1/files/a.png", "http://host-with-port:70000"), + ) + } + + @Test + fun `the scheme match is case insensitive while the path stays exact`() { + assertEquals( + "$base/api/v1/files/a.png", + ImageHostRebase.apply("HTTP://Old-Host/api/v1/files/a.png", base), + ) + assertEquals( + "$base/api/v1/files/a.png", + ImageHostRebase.apply("HttPs://Old-Host/api/v1/files/a.png", base), + ) + // 路径这一段是**大小写敏感**的:`/api/v1/Files/` 不是服务端那条读侧路径,原样返回。 + val upperPath = "http://old/api/v1/Files/a.png" + assertEquals(upperPath, ImageHostRebase.apply(upperPath, base)) + } + + @Test + fun `replacing is idempotent`() { + val once = ImageHostRebase.apply("http://old/api/v1/files/a.png", base) + + assertEquals(once, ImageHostRebase.apply(once, base)) + } + + // —— 原样返回的:任何其它形态,一个字都不改 —— + + @Test + fun `a third-party absolute url is returned verbatim`() { + val cdn = "https://cdn.example.net/uploads/2026/a.png" + + assertEquals(cdn, ImageHostRebase.apply(cdn, base)) + } + + @Test + fun `another api path on the same host is returned verbatim`() { + // 只有 /api/v1/files/ 是上传产物的读侧路径;别的路径不归这一层管(它可能是任何东西)。 + assertEquals("http://old/api/v1/documents/a.png", ImageHostRebase.apply("http://old/api/v1/documents/a.png", base)) + assertEquals("http://old/files/a.png", ImageHostRebase.apply("http://old/files/a.png", base)) + assertEquals("http://old/api/v2/files/a.png", ImageHostRebase.apply("http://old/api/v2/files/a.png", base)) + } + + @Test + fun `a file path with more than one segment is returned verbatim`() { + // 服务端产物名是**单段**的 `<32hex>.`;多一段说明这不是我们认识的那个地址。 + val url = "http://old/api/v1/files/nested/a.png" + + assertEquals(url, ImageHostRebase.apply(url, base)) + } + + @Test + fun `a query string or a fragment is left alone`() { + // 带查询串 / 片段说明它不是服务端自己拼出来的那一份(那串地址没有这两样), + // 猜它可能是签名参数还是缓存参数都不该是本端替用户做的决定。 + val withQuery = "http://old/api/v1/files/a.png?v=2" + val withFragment = "http://old/api/v1/files/a.png#top" + + assertEquals(withQuery, ImageHostRebase.apply(withQuery, base)) + assertEquals(withFragment, ImageHostRebase.apply(withFragment, base)) + } + + @Test + fun `percent-encoded or relative-looking names are returned verbatim`() { + // 与 ImageNames.fromServerUrl 同一条纪律:带 % 的段说明它压根不是本端认识的那个名字。 + listOf( + "http://old/api/v1/files/%2e%2e%2fetc.png", + "http://old/api/v1/files/a%20b.png", + ).forEach { url -> assertEquals(url, ImageHostRebase.apply(url, base)) } + } + + @Test + fun `an empty or dotted name is returned verbatim`() { + listOf( + "http://old/api/v1/files/", + "http://old/api/v1/files/.", + "http://old/api/v1/files/..", + ).forEach { url -> assertEquals(url, ImageHostRebase.apply(url, base)) } + } + + @Test + fun `inline data uris and local references are returned verbatim`() { + val data = "data:image/png;base64,iVBORw0KGgo=" + val content = "content://media/external/images/media/42" + val file = "file:///data/user/0/top.yeij.cyrene/cache/share-intake/share-x-0.png" + + assertEquals(data, ImageHostRebase.apply(data, base)) + assertEquals(content, ImageHostRebase.apply(content, base)) + assertEquals(file, ImageHostRebase.apply(file, base)) + } + + @Test + fun `paths without a scheme and bare file names are returned verbatim`() { + listOf( + "/api/v1/files/a.png", + "api/v1/files/a.png", + "a.png", + "", + " ", + ).forEach { raw -> assertEquals(raw, ImageHostRebase.apply(raw, base)) } + } + + @Test + fun `an unusable base url disables the rewrite instead of guessing one`() { + val url = "http://old:10942/api/v1/files/a.png" + + // baseUrl 用不了时**原样返回 raw**(返回一个半截的 baseUrl 只会把图变成另一个错地址)。 + listOf("", " ", "cyrene.example.com", "ftp://cyrene.example.com", "http://", "https:///x").forEach { bad -> + assertEquals(url, ImageHostRebase.apply(url, bad)) + } + } + + @Test + fun `an absolute url without a host is returned verbatim`() { + // `http:///api/v1/files/a.png`:authority 是空的 ⇒ 原样返回(不把它拼成一个新地址)。 + val url = "http:///api/v1/files/a.png" + + assertEquals(url, ImageHostRebase.apply(url, base)) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ContentlessMessageTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ContentlessMessageTest.kt new file mode 100644 index 0000000..f8d7360 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ContentlessMessageTest.kt @@ -0,0 +1,109 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.DocumentRefDto +import top.yeij.cyrene.data.api.dto.HistoryMessageDto + +/** + * 「这条消息什么都没传过来」的判据(纯函数,`user` 消息的空气泡修复)。 + * + * **背景**:主人报的 bug —— 微信发来的文件,客户端**看不到预览**。根因在服务端(那条用户消息上 + * `documents` 键整个缺席、`content` 也是空;而模型侧确实读到了内容,已登记 `devdocs/02`), + * 但客户端拿到的是一条**什么都没有**的消息,此前渲染成一个**空气泡**,看的人只能猜。 + * + * 这个测试要钉住两件事: + * 1. 真的什么都没有时成立(空 / 纯空白); + * 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链, + * 以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上「内容没传过来」的帽子。 + */ +class ContentlessMessageTest { + + private fun message( + role: String = "user", + content: String = "", + image: String? = null, + documents: List = emptyList(), + thinking: String? = null, + ) = HistoryMessageDto( + id = "m1", + sessionId = "web", + role = role, + content = content, + image = image, + documents = documents, + thinking = thinking, + createdAt = 1_791_088_571_785L, + ) + + private val document = DocumentRefDto( + name = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt", + mime = "text/plain", + size = 10, + chars = 10, + status = "extracted", + ) + + // —— 成立:真的什么都没有 —— + + @Test + fun `a user message with nothing at all is contentless`() { + // 这正是微信文件那条的现场:content 空、documents 键缺席(解析成空列表)、没有 image。 + assertTrue(isContentlessUserMessage(message())) + } + + @Test + fun `whitespace only content still counts as nothing`() { + assertTrue(isContentlessUserMessage(message(content = " "))) + assertTrue(isContentlessUserMessage(message(content = "\n\t "))) + } + + @Test + fun `a blank or empty image string is not content`() { + assertTrue(isContentlessUserMessage(message(image = ""))) + assertTrue(isContentlessUserMessage(message(image = " "))) + } + + // —— 不成立:凡有一点点内容就一个字都不说 —— + + @Test + fun `any real content disables the note`() { + assertFalse(isContentlessUserMessage(message(content = "你好"))) + assertFalse(isContentlessUserMessage(message(content = "a"))) + // 只有标点也算内容:它是用户真的发过来的东西。 + assertFalse(isContentlessUserMessage(message(content = "。"))) + } + + @Test + fun `an image disables the note`() { + assertFalse(isContentlessUserMessage(message(image = "http://host/api/v1/files/a.png"))) + assertFalse(isContentlessUserMessage(message(image = "data:image/png;base64,iVBORw0KGgo="))) + } + + @Test + fun `documents disable the note`() { + assertFalse(isContentlessUserMessage(message(documents = listOf(document)))) + } + + @Test + fun `a thinking chain disables the note`() { + // 思维链也是渲染在这个分组里的内容(它会显示在气泡上方),那种消息不算「什么都没有」。 + assertFalse(isContentlessUserMessage(message(thinking = "我在想…"))) + // 而**纯空白**的思维链不是内容:判据与 content 一样按 isBlank 看待。 + assertTrue(isContentlessUserMessage(message(thinking = " "))) + } + + @Test + fun `assistant messages never get the note`() { + // 她的空消息是另一个话题(可能还在流式、也可能有意不说),顺手改会波及所有助手气泡。 + assertFalse(isContentlessUserMessage(message(role = "assistant"))) + assertFalse(isContentlessUserMessage(message(role = "assistant", content = " "))) + } + + @Test + fun `unknown roles never get the note`() { + assertFalse(isContentlessUserMessage(message(role = "system"))) + assertFalse(isContentlessUserMessage(message(role = ""))) + } +}