From 464e91621e3724f701b5c24f1fc063e239228ac8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 21:25:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(parse):=20=E5=AE=B9=E9=94=99=E8=90=BD?= =?UTF-8?q?=E5=8D=95=E7=9A=84=E6=AE=B5=E8=90=BD=E6=A0=87=E7=AD=BE=EF=BC=8C?= =?UTF-8?q?=E7=BB=9D=E4=B8=8D=E6=8A=8A=20`
`=20=E5=BD=93=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机实测:后端产出的某条消息第三段是 `
那现在…?
`(两个开标签、一个闭标签)。 P_BLOCK_RE 是非贪婪的,先把外层 `` 配到那个 `
`,内层那个裸 `` 就成了「块内容」;
而 appendAnnotations 只认 ` `,于是它作为普通文本
落进气泡正文,屏幕上直接显示 ` 那现在…`。
改了什么:新增 stripLooseTags,在切出 ` ` 块之后按区剥掉「落单标签」——
- 消息里已有完整 ` … ` / ` ` 整段回落字面文本」的契约(编辑框靠它保证不
静默改字),只再剥两类连「被截断的结构」都算不上的东西:落单的 ` ` / ` ` 当外层冗余)反而会让 ` 甲 乙` 在中间态把 ` 甲` 当字面文本吐上屏,
等 ` ` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
+ * 一个 ` ` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底
+ * (落单的段落标签剥掉,其余按普通文本回落)。
* 空段(` 正文 `、
+ * 只有一个 ` ` / `
` / `
` / `
` / ``(词表只有小写,与 `MessageEditText` 同一口径)、`
`)
+ * 都原样保留——见到 `<` 就吃,才是真正会丢字的那种错。
+ * - 未闭合/畸形的其余标签一律回落为普通文本,绝不抛异常。块内没配对的 ` ` 当「外层冗余」让前缀继续推进,` 甲 乙`
+ * 就会在中间态把 ` 甲` 当字面文本吐上屏,等 ` ` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
+/** 先切 ` ` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List ` 及其后内容按既有契约整段回落字面文本,` ` 族:` … ` / ` ` 族标签后仍非空即为有。 */
+private fun hasContentAfter(text: String, from: Int): Boolean {
+ var i = from
+ while (i < text.length) {
+ if (text[i].isWhitespace()) {
+ i++
+ continue
+ }
+ if (text[i] == '<') {
+ val gt = text.indexOf('>', i)
+ if (gt >= 0) {
+ val tag = text.substring(i, gt + 1)
+ if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) {
+ i = gt + 1
+ continue
+ }
+ }
+ }
+ return true
+ }
+ return false
+}
+
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
private fun appendAnnotations(text: String, out: MutableList `,只有一个 ` 真乖♪ 这可是今天最让人家放心的一句。 那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆? 正文 正文 甲 乙 乙 正文 \n 正文
甲
").map { it.body }) + } + + @Test + fun loneParagraphTagsProduceNoBubble() { + assertTrue(parse("").isEmpty()) + assertTrue(parse("
").isEmpty()) + assertTrue(parse("").isEmpty()) + assertTrue(parse("
").isEmpty()) + assertTrue(parse("").isEmpty()) + } + + @Test + fun unclosedTagAfterACompleteBlockIsDroppedNotShown() { + // 段落结构已被前一个完整块定死,尾部这个没闭合的 `
` 只可能是标记。 + assertEquals(listOf("甲", "乙"), parse("
甲
乙").map { it.body }) + } + + @Test + fun strayRoleTagOutsideParagraphIsDropped() { + assertEquals(listOf("甲"), parse("
甲
甲
").map { it.body }) + } + + @Test + fun plainAngleBracketsAreNeverEaten() { + assertEquals(listOf("1 < 2 且 3 > 2"), parse("1 < 2 且 3 > 2").map { it.body }) + assertEquals(listOf("<3 是想你的意思"), parse("<3 是想你的意思").map { it.body }) + assertEquals(listOf("ac"), parse("ac").map { it.body }) + assertEquals(listOf("大小写不认
"), parse("大小写不认
").map { it.body }) + assertEquals(listOf("你好甲 1 < 2
乙
").map { it.body }) + assertEquals(listOf("甲甲
你好*弯起眼睛*
这是*强调*文字
*旁白:其实偷偷开心了一下*
+那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?
晚安。
""".trimIndent() @@ -315,6 +419,63 @@ class ChatContentParserTest { assertEquals(listOf("第一", "第二"), second.map { it.body }) } + /** 逐字符喂实测畸形消息:中间态任何一帧都不许把标签当正文吐上屏。 */ + @Test + fun streamingRealMalformedMessageNeverLeaksTheTag() { + val parser = StreamingContentParser() + for (ch in malformedRealMessage) { + assertNoLooseTagInRenderedText(parser.onDelta(ch.toString())) + } + assertEquals(parse(malformedRealMessage), parser.finish()) + } + + /** 中间态只许「加」,不许改口:已经显示过的正文不能因为后续 delta 变样(那才是跳动)。 */ + @Test + fun streamingRealMalformedMessageOnlyGrowsWhatItAlreadyShowed() { + val parser = StreamingContentParser() + var shown = emptyList` 当「外层冗余」、让前缀继续推进)会把 `甲` 先吐上屏, + * 等 `
` 到了再变成 `甲乙`——用户看到的字会跳,所以这里钉住「中间态一帧都不吐」。 + */ + @Test + fun streamingWithholdsDuplicateOpeningTagUntilItsPartnerArrives() { + val parser = StreamingContentParser() + for (chunk in listOf("甲", "
乙", "
")) { + assertNoLooseTagInRenderedText(parser.onDelta(chunk)) + } + assertEquals(listOf("甲乙"), parser.finish().map { it.body }) + } + @Test fun streamingUnclosedActionFallsBackOnFinish() { val parser = StreamingContentParser() @@ -347,4 +508,13 @@ class ChatContentParserTest { assertTrue(parser.onDelta("").isEmpty()) assertEquals(listOf("新"), parser.onDelta("新
").map { it.body }) } + + /** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */ + private fun assertNoLooseTagInRenderedText(bubbles: List