merge: W29 —— stream_chunk 改整段语义 + 空壳消息如实说一句 + 分享超长走文件 + 图片 URL 兜 host
This commit is contained in:
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.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`。
|
||||
|
||||
@@ -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` 载荷。
|
||||
*
|
||||
|
||||
@@ -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.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(";")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)时按 `<p>` 切分,
|
||||
@@ -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<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
|
||||
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`。
|
||||
*
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 = "")))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user