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:
1 parent
7b97440973
commit
be1c372d99
9 files changed
+774
-18
No files matched your search
@@ -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