merge: W29 —— stream_chunk 改整段语义 + 空壳消息如实说一句 + 分享超长走文件 + 图片 URL 兜 host

This commit is contained in:
AskaEth committed 2026-10-05 08:15:27 +08:00
commit ac3f63f8c8
9 files changed
+774 -18

No files matched your search

@@ -8,8 +8,11 @@ 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.withContext
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import top.yeij.cyrene.domain.chat.DocumentAttachments 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.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
@@ -49,8 +52,8 @@ import java.util.UUID
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以 * 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到 * 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。 * 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,见 [ShareRules]), * 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument]
* 另有系统的 cache 回收兜底。 * 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。
*/ */
object ShareIntake { object ShareIntake {
@@ -132,6 +135,52 @@ 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 里的 `.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://` 地址 + 显示名 + 真实字节数; * 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数;
* 失败回 `null`。 * 失败回 `null`。
@@ -49,6 +49,33 @@ enum class DocumentRejection {
TooManyAttachments, 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 与测试共用)。 * 「该不该走文件」的判定与 `chat.documents` 载荷构造(纯函数,VM 与测试共用)。
* *
@@ -160,6 +187,29 @@ object DocumentDelivery {
fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile = fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile =
DocumentFile(fileName = fileName, bytes = text.toByteArray(Charsets.UTF_8)) 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` 载荷。 * 由**已上传**的服务端存储名构造 `chat.documents` 载荷。
* *
@@ -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/<name>
* ```
*
* 具体判据(全部满足才替换,否则原样返回):
* - 以 `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)://` 之后的部分(`<authority>/<path>`);不是 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
}
}
@@ -64,6 +64,7 @@ import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.repository.ImageCacheStore import top.yeij.cyrene.data.repository.ImageCacheStore
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.DocumentChips
import top.yeij.cyrene.domain.chat.DocumentDelivery
import top.yeij.cyrene.domain.chat.DocumentOpen import top.yeij.cyrene.domain.chat.DocumentOpen
import top.yeij.cyrene.domain.chat.DocumentOpenPlan import top.yeij.cyrene.domain.chat.DocumentOpenPlan
import top.yeij.cyrene.domain.chat.DocumentOpenTypes 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.DocumentTarget
import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageSource 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.chat.documentLoadFailureMessage
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
@@ -441,6 +443,11 @@ fun ChatScreen(
when (item) { when (item) {
is ChatDisplayItem.Committed -> MessageBubble( is ChatDisplayItem.Committed -> MessageBubble(
message = item.message, message = item.message,
// `F6①`:历史图片里那份绝对地址是按**上传时**的 host 拼的、
// 且服务端原样落库,换服务器地址后旧图就取不回。渲染前把它换到
// 当前 baseUrl(判据与取舍全在 ImageHostRebase 里,这里只喂地址)。
// 只在组合期读一次:换服务器必然换会话/重进页面,不需要额外订阅。
imageBaseUrl = container.authManager.currentBaseUrl,
onDocumentClick = { ref -> onDocumentClick = { ref ->
previewNotice = null previewNotice = null
previewTarget = DocumentTarget.of(ref) previewTarget = DocumentTarget.of(ref)
@@ -833,6 +840,13 @@ private fun shareNoticeFor(share: SharedContent): String? {
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送") if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送") if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
if (share.isEmpty) add("分享的内容是空的,没有可发送的内容") 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(";") return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
} }
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.core.error.CyreneError import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.core.prefs.AppPrefs import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.InterruptMode 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.MessageFingerprint
import top.yeij.cyrene.domain.chat.RetryKeyMemory import top.yeij.cyrene.domain.chat.RetryKeyMemory
import top.yeij.cyrene.domain.chat.SendChannel 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.editableMessageOf
import top.yeij.cyrene.domain.chat.interruptScopeForSend import top.yeij.cyrene.domain.chat.interruptScopeForSend
import top.yeij.cyrene.domain.chat.readBounded import top.yeij.cyrene.domain.chat.readBounded
@@ -554,6 +556,23 @@ class ChatViewModel(
private var oldestLoadedAt: Long? = null private var oldestLoadedAt: Long? = null
private var streamParser: StreamingContentParser? = 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 = 当时没有需要补的洞。 * 掉线那一刻的 `after` 游标(契约 §1 行 6):为 null = 当时没有需要补的洞。
* *
@@ -885,6 +904,22 @@ class ChatViewModel(
* 「分享超过一张时才不打断」得先跟人解释清为什么是一张的分界。 * 「分享超过一张时才不打断」得先跟人解释清为什么是一张的分界。
* 用户真的想打断,发送键那条路([send])的口径一字未动。 * 用户真的想打断,发送键那条路([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 = 没什么要说的 * @param notice 如实告知(截断 / 读不到 / 空内容),由渲染层组织文案;null = 没什么要说的
*/ */
fun sendShared(share: SharedContent, notice: String? = null) { fun sendShared(share: SharedContent, notice: String? = null) {
@@ -895,7 +930,19 @@ class ChatViewModel(
} }
if (body.isEmpty() && share.images.isEmpty() && documents.isEmpty()) return if (body.isEmpty() && share.images.isEmpty() && documents.isEmpty()) return
viewModelScope.launch { 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 { share.images.drop(1).forEach {
emitAndDeliver("", it, emptyList(), clearDraft = false, allowInterrupt = false) emitAndDeliver("", it, emptyList(), clearDraft = false, allowInterrupt = false)
} }
@@ -1439,14 +1486,17 @@ class ChatViewModel(
is WsEvent.MessageStart -> { is WsEvent.MessageStart -> {
if (event.index == 0) { if (event.index == 0) {
streamParser = StreamingContentParser() streamParser = StreamingContentParser()
structuredTextFramesThisTurn = false
freshTurnIfFinished() freshTurnIfFinished()
} }
structuredTextFramesThisTurn = true
val parser = streamParser ?: StreamingContentParser().also { streamParser = it } val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta("") val bubbles = parser.onDelta("")
updateStreaming { it.copy(bubbles = bubbles, active = true) } updateStreaming { it.copy(bubbles = bubbles, active = true) }
} }
is WsEvent.MessageDelta -> { is WsEvent.MessageDelta -> {
structuredTextFramesThisTurn = true
val parser = streamParser ?: StreamingContentParser().also { streamParser = it } val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta(event.delta) val bubbles = parser.onDelta(event.delta)
updateStreaming { it.copy(bubbles = bubbles, active = true) } updateStreaming { it.copy(bubbles = bubbles, active = true) }
@@ -1459,20 +1509,39 @@ class ChatViewModel(
* 只在这两帧里**——这里不接它,它会落进 `onEvent` 末尾的 `else -> Unit` 被静默丢掉: * 只在这两帧里**——这里不接它,它会落进 `onEvent` 末尾的 `else -> Unit` 被静默丢掉:
* 屏幕上那一轮回答是空的,而且没有任何信号说明正文丢了。 * 屏幕上那一轮回答是空的,而且没有任何信号说明正文丢了。
* *
* 「与结构化帧**互斥**」的依据就是上面那句话本身:回退路径的语义是「结构化流帧没能承载 * ## 它的语义是「整段」,不是「增量」(2026-10-05 按后端回执十三 §1 改)
* 这次事件」,所以同一轮里 `stream_chunk` 与 `message.*` 不会并存。因此这一帧可以照 * 后端确认三件事:① 生产里**已无生产者**(唯一的整段广播被 `Deps.Streaming=true` 挡死,
* [WsEvent.MessageDelta] 的同一形态喂进同一个 `streamParser`,不必去分辨「它属于哪条流」。 * 另一处只是未知 `StreamKind` 的兜底);② `delta` 就是**整段** `res.Text`;③ **不会**与
* 结构化帧并存。所以 W26 那版「照 [WsEvent.MessageDelta] 追加进同一个气泡」是**错的**:
* 它真到达时会把同一段内容**追加成重影**。现在按同一份回执给的口径分成两支:
* *
* 一处必须注意的差别:`stream_chunk` **不带 `index`**(那是 [WsEvent.MessageStart] 的字段), * - 同一回合内**已经收到过结构化帧**([structuredTextFramesThisTurn])⇒ **整帧忽略**:
* 所以这里不读它——它只是「当前这条流」的增量;它也不负责开一轮(回退路径里没有 * 它是兜底,不能与结构化帧叠加;
* `message.start`),解析器因此按需创建,与上面 `MessageDelta` 分支逐字一致。 * - 否则 ⇒ [StreamingContentParser.reset] 之后**整段喂进去**,即用这一整段**替换**当前
* 流式气泡的正文(不是追加)。多条兜底帧先后到达时后到的那段覆盖前一段——「整段」
* 这个语义本身就意味着它不是可以相加的增量。
*
* ## 为什么保留这条分支
* 后端明确不建议删:它是「未知帧不丢内容」的兼容路径(将来真出现未知 `StreamKind` 时,
* 那一轮的正文只有这一个出口)。删掉它,静默丢正文的风险面只会更大。
*
* ## 顺手补上的一处边界:回退路径没有 `message.start`
* [WsEvent.MessageStart] 的 `index == 0` 是既有代码里唯一会 [freshTurnIfFinished] 的地方,
* 而回退路径没有这一帧 ⇒ 上一轮的思维链 / 工具卡会串到这一轮来。后端确认回退路径**无
* 触发源**(所以这不是一个能复现的 bug),这里只是把这块补成与 `message.start` 逐字相同的
* 收口。它跑不到第二次:第一帧之后 `active` 已经是 true,「已结束才清」的判据自然不再成立。
* *
* 会话归属照旧由 `onEvent` 开头那道闸收敛(本帧带 `sessionId`,见 [isForSession])。 * 会话归属照旧由 `onEvent` 开头那道闸收敛(本帧带 `sessionId`,见 [isForSession])。
*/ */
is WsEvent.StreamChunk -> { is WsEvent.StreamChunk -> {
val parser = streamParser ?: StreamingContentParser().also { streamParser = it } if (!structuredTextFramesThisTurn) {
val bubbles = parser.onDelta(event.delta) freshTurnIfFinished()
updateStreaming { it.copy(bubbles = bubbles, active = true) } 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 -> { is WsEvent.StreamEnd -> {
val bubbles = streamParser?.finish().orEmpty() val bubbles = streamParser?.finish().orEmpty()
// 这一轮到此收口:下一轮的兜底帧要按「没见过结构化帧」重新判(见字段 KDoc)。
structuredTextFramesThisTurn = false
updateStreaming { updateStreaming {
it.copy(bubbles = bubbles, thinkingActive = false, active = false) it.copy(bubbles = bubbles, thinkingActive = false, active = false)
} }
@@ -1653,7 +1724,11 @@ class ChatViewModel(
null null
} }
val persisted = if (streamedThinking != null) msg.copy(thinking = streamedThinking) else msg 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.update { state ->
state.copy( state.copy(
@@ -1817,6 +1892,14 @@ private const val SESSION_NOT_FOUND_CODE = "session_not_found"
/** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */ /** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */
private const val SHARE_NOTICE_MILLIS = 8 * 1000L 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 private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
@@ -38,6 +38,7 @@ import top.yeij.cyrene.data.repository.canEditMessage
import top.yeij.cyrene.domain.chat.DocumentChipModel import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument 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.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
@@ -68,6 +69,34 @@ private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
/** 长按气泡的读屏名字:说清按下去会发生什么(不长按就永远不知道)。 */ /** 长按气泡的读屏名字:说清按下去会发生什么(不长按就永远不知道)。 */
private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作菜单" 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)时按 `<p>` 切分, * - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
@@ -75,7 +104,10 @@ private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作
* - 其它 mode 原样整条渲染,不解析标签; * - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片; * - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画); * - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick])。 * - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
* [ImageHostRebase](见 [imageBaseUrl]);
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
* *
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer], * 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。 * 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
@@ -92,12 +124,16 @@ private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作
* 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]), * 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]),
* 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。 * 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。
* *
* @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在
* 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的
* `r.Host` 拼、且原样落库 ⇒ 换地址后旧图取不回)。空串 = 不替换([ImageHostRebase] 会原样返回)。
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。 * @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
* @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。 * @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。
*/ */
@Composable @Composable
fun MessageBubble( fun MessageBubble(
message: HistoryMessageDto, message: HistoryMessageDto,
imageBaseUrl: String = "",
onDocumentClick: (DocumentRefDto) -> Unit = {}, onDocumentClick: (DocumentRefDto) -> Unit = {},
onImageClick: (String) -> Unit = {}, onImageClick: (String) -> Unit = {},
onEdit: (HistoryMessageDto) -> Unit = {}, onEdit: (HistoryMessageDto) -> Unit = {},
@@ -131,9 +167,15 @@ fun MessageBubble(
alignEnd = isUser, alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) }, onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
) )
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) } message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
bubbles.forEachIndexed { index, bubble -> ?.let { ChatImage(it, onImageClick) }
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex, onLongPress = openMenu) 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<ContentPart>) {
} }
} }
/**
* 「这条消息的内容没有被传过来」——**在气泡里**如实说一句(不是顶部横幅:那句话说的是这一条
* 消息,挂在页面上就等于在说整页;而且横幅会自己消失,空气泡不会)。
*
* ## 为什么必须是灰底灰字
* 它是**说明**,不是内容:正经气泡的配色(用户青色 / 助手灰底)会让这句话读起来像「他说的某一句话」,
* 而事实恰恰是「这一条里什么都没有」。灰底 + 文件里既有的灰态小字([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 @Composable
private fun BubbleBody(text: String, isUser: Boolean, onLongPress: (() -> Unit)? = null) { private fun BubbleBody(text: String, isUser: Boolean, onLongPress: (() -> Unit)? = null) {
Box( 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` 之后可点, * 来源判定交给 `ImageSource.of`,这里不猜。点击区就是这张图本身(`clip` 之后可点,
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。 * 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
* *
@@ -273,4 +273,64 @@ class DocumentDeliveryTest {
DocumentDelivery.planForCount("", DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1), 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))
}
} }
@@ -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>.<ext>`;多一段说明这不是我们认识的那个地址。
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))
}
}
@@ -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<DocumentRefDto> = 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 = "")))
}
}