fix(W29): stream_chunk 语义订正、空壳消息如实说明、分享长正文走文件、图片 URL 兜住 host

一、stream_chunk 由「增量」订正为「整段」(后端回执十三 §1)
   后端确认三件事:生产里已无生产者、delta 是整段 res.Text、不会与结构化帧并存。
   W26 那版照 message.delta 把 delta 追加进同一气泡是错的——真到达会把同一段内容追加成重影。
   改法:同一回合内已经收到过结构化帧(message.start / message.delta)就整帧忽略;否则先用
   StreamingContentParser.reset 清缓冲,再把这一整段喂进去,即用它整段替换当前流式气泡的正文。
   兜底分支保留(后端明确不建议删:那是未知帧不丢内容的兼容路径)。顺带把「回退路径没有
   message.start、所以 freshTurnIfFinished 不触发」这条边界在同一个分支里补上(一行级别的收口)。

二、空壳消息不再渲染成空气泡
   主人报的 bug 根因在服务端(微信发来的文件没有挂到消息的 documents 上,content 也是空),
   已登记后端;但客户端拿到的确实是一条什么都没有的消息,此前渲染成空气泡,用户只能猜。
   新增纯函数 isContentlessUserMessage(role 是 user 且 content 空、无 image、无 documents、
   无 thinking 才成立,任何一项有内容都不成立,assistant 一律不成立),成立时在气泡位置改为
   一句如实说明「这条消息的内容没有被传过来」:灰底灰字(沿用文件里既有的灰态小字),措辞只陈述
   本端确定的事实,不猜原因。判据放渲染层(MessageBubble),因为它纯属显示,服务端说什么就存什么,
   不在组装层改 DTO。

三、分享进来的超长正文走文件(主人已拍板)
   分享的文本超过现成的 TEXT_AS_DOCUMENT_RUNE_LIMIT 时,落成一份 .txt:写进分享缓存目录、
   上传 POST /documents、随 chat.documents 发,正文那一栏留空。判据抽成纯函数
   DocumentDelivery.planSharedText(正文是否超阈值 + 附件位是否还有空)。
   附件位被分享进来的文件占满(一个回合 5 份)时不做转换:正文照正文发,并在提示里说清
   「本想作为文件发送,但附件位已占满」——绝不为了塞下 .txt 挤掉用户明确要发的文件。
   落盘失败同样退回内联并说一句。组合与图片的关系:那份 .txt 就落在正文原来所在的那一条上
   (第一条带图,其余每条一张)。
   明确不动的:用户自己在输入框里打的字那条路,长正文照旧按正文发(devdocs/05 的定案第 3 条),
   这两件事不是同一件。

四、历史图片的同源绝对地址按当前 baseUrl 兜住 host(F6①)
   POST /upload 的 url 是按请求时的 r.Host 拼的绝对地址且原样落库,换服务器地址后旧图取不回;
   后端明确认可客户端做「保留 path、替换 host」。新增纯函数 ImageHostRebase.apply:只认
   http(s)://任意host/api/v1/files/一段名字 这一种形态,替换 scheme 与 authority、path 逐字节保留;
   第三方 CDN、data:、content:// 与 file://、相对路径、裸文件名、带查询串或百分号编码、多一段路径的
   一律原样返回。渲染历史图片前在 ChatScreen 传入 AuthManager.currentBaseUrl,全屏查看拿到的也是
   替换后的地址。逆作用写进 KDoc:换了指向不同数据的 host 会 404,那是诚实的失败,比永远取不回好。
   不改落库数据、不改上传、不碰 documents。

测试:新增 ImageHostRebaseTest 与 ContentlessMessageTest,DocumentDeliveryTest 补分享长正文的判据;
stream_chunk 那条分支是 VM 里的状态机分支,没有硬抽成纯函数(不为测试拧弯架构),改动写在 KDoc 里。
This commit is contained in:
AskaEth committed 2026-10-05 08:14:33 +08:00
1 parent 7b97440973
commit be1c372d99
9 files changed
+774 -18

No files matched your search

@@ -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 = "")))
}
}