diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt index 820c2a9..da9eb62 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt @@ -130,7 +130,8 @@ data class ConclusionParagraph(val parts: List) * * 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `

`、 * `` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]: - * 一个 `

` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。 + * 一个 `

` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底 + * (落单的段落标签剥掉,其余按普通文本回落)。 * 空段(`

` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。 * 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。 * `null` / 纯空白输入返回空列表。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt index e18ef66..98dba45 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt @@ -19,7 +19,20 @@ import kotlin.math.min * - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action); * 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。 * 关键词 旁白/内心 → narration,其余 → action。 - * - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。 + * - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `

正文

`:多一个 `

`、 + * 只有一个 `

`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文—— + * 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、 + * 也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字都不动。 + * 这只是客户端的容错,不代表服务端这样发是对的。 + * 判据([stripLooseTags])分两种情形:消息里**已有完整块**时,段落结构已被完整块定死,块内 + * 块外多出来的每个 `

` / `

`、以及块外孤立的旁白标签,都是冗余噪声,一律剥掉(块内的 + * 落单旁白标签例外,见最后一条);消息里**一个完整块都没有**时,按上一条整段回落字面文本, + * 只再剥两类连「被截断的结构」都算不上的东西:落单的 `

`,与后面什么都没有的空开标签。 + * 只认「确定是标签」的形状:`

` / `

` / `

` / `` 等。`1 < 2`、`<3`、 + * `a`、`

`(词表只有小写,与 `MessageEditText` 同一口径)、``) + * 都原样保留——见到 `<` 就吃,才是真正会丢字的那种错。 + * - 未闭合/畸形的其余标签一律回落为普通文本,绝不抛异常。块内没配对的 `` / `` + * 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。 * * `ParsedBubble` 结构(每条气泡渲染一个): * - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块; @@ -37,6 +50,15 @@ import kotlin.math.min * 2) 尾部残缺标签(如 `` / `` 开标签必定在 + * **同一个前缀内**配到闭合标签(否则它就是那个没闭合的,扣留点会落在它前面)。所以 + * 「这个标签是不是落单的」在它被产出的那一刻就已经定死,中间态剥掉、收口时也不用再改口。 + * - 反过来放宽扣留是不行的:把重复的 `

` 当「外层冗余」让前缀继续推进,`

甲

乙` + * 就会在中间态把 `

甲` 当字面文本吐上屏,等 `

` 到了又变成 `甲乙`——字会跳。 + * 剥标签与扣留的标签词表因此必须逐字一致(共用 OPEN_P_TAG_RE / CLOSE_P_TAG_RE / + * OPEN_ROLE_TAG_RE / CLOSE_ROLE_TAG_RE,无大小写、无词表扩展),两边一旦分叉, + * 就会出现「中间态当正文、收口时又剥掉」的跳动。 * - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。 * - 本类无副作用、非线程安全,调用方保证单线程喂入即可。 */ @@ -91,6 +113,10 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n") /** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */ private const val FULL_STAR_MAX_LEN = 40 +/** + * 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix] + * 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。 + */ private val TAG_NAMES = listOf("action", "narration", "p") private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$") private val CLOSE_ROLE_TAG_RE = Regex("^$") @@ -144,29 +170,112 @@ private sealed interface Tok { object Break : Tok } -/** 先切 `

` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */ +/** 先切 `

` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */ private fun tokenize(raw: String): List { val pMatches = P_BLOCK_RE.findAll(raw).toList() val out = ArrayList() if (pMatches.isEmpty()) { - appendAnnotations(raw, out, splitBlankLines = true) + // 一条完整块都没有:`

` 及其后内容按既有契约整段回落字面文本,`` 这类旁白标签 + // 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的 + // 结构」都算不上的东西:落单的 `

`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。 + appendAnnotations( + stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false), + out, + splitBlankLines = true, + ) return out } var last = 0 for (m in pMatches) { val lead = raw.substring(last, m.range.first) - if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false) + if (lead.isNotEmpty()) { + appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false) + } out.add(Tok.Break) val inner = m.groupValues[2] - if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false) + if (inner.isNotEmpty()) { + // 块内只剥 `

` 族:`` / `` 落单时保留字面文本,是既有契约 + // (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。 + appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false) + } out.add(Tok.Break) last = m.range.last + 1 } val trail = raw.substring(last) - if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false) + if (trail.isNotEmpty()) { + appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false) + } return out } +/** + * 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。 + * + * [markupMode] = 这条消息里**已有完整 `

…

` 块**,段落结构已被完整块定死,于是块内/块外 + * 多出来的每一个 `

` / `

` 都是冗余噪声,一律剥掉。 + * 没有完整块时(`parse` 会整段回落字面文本,见 `unclosedParagraphStaysLiteralText`;编辑框也靠 + * 这条保证不静默改字,见 `MessageEditTextTest.没闭合的 p 不抛异常`)只再剥两类不可能是正文的东西: + * 落单的 `

`,以及后面什么都没有的**空**开标签——它们连「被截断的段落」都算不上。 + * + * [stripStrayRoleTags] 只对**块外**文本区为 true:那里的 `` / `` 若没被 [ROLE_RE] + * 认领(即没配成对),就是落单标签,剥掉。已配对的必须留给 [ROLE_RE] 解析成 Action/Narration, + * 提前剥掉等于把旁白降级为正文(`textOutsideParagraphsIsPreserved` 钉住的行为)。 + */ +private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String { + if (text.indexOf('<') < 0) return text + val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList() + val out = StringBuilder(text.length) + var last = 0 + var i = 0 + while (i < text.length) { + val lt = text.indexOf('<', i) + if (lt < 0) break + val gt = text.indexOf('>', lt) + if (gt < 0) break + val tag = text.substring(lt, gt + 1) + val drop = when { + OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1) + CLOSE_P_TAG_RE.matches(tag) -> true + !stripStrayRoleTags -> false + roleRanges.any { lt in it } -> false + else -> OPEN_ROLE_TAG_RE.matches(tag) || CLOSE_ROLE_TAG_RE.matches(tag) + } + if (drop) { + out.append(text, last, lt) + last = gt + 1 + i = last + } else { + i = lt + 1 + } + } + if (last == 0) return text + out.append(text, last, text.length) + return out.toString() +} + +/** [from] 之后是否还有正文:跳过空白与后续的 `

` 族标签后仍非空即为有。 */ +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, splitBlankLines: Boolean) { var last = 0 diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt index 6b3e9ef..dd96635 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt @@ -245,6 +245,109 @@ class ChatContentParserTest { assertTrue(bubbles[0].parts.any { it is ContentPart.Action }) } + // ------------------------------------------------------------------ + // 落单标签容错:宁可剥掉标签,也绝不把标签当正文 + // + // 畸形 markup 来自服务端(客户端只做容错,不代表服务端这样发是对的)。 + // 这些断言是上面「畸形 / 未闭合」那组的另一面,两边必须同时成立。 + // ------------------------------------------------------------------ + + /** 线上实测的一条畸形消息:第三段多一个 `

`,只有一个 `

`。 */ + private val malformedRealMessage = + "

满意地点点头

\n" + + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" + + @Test + fun realMalformedMessageRendersCleanBubbles() { + val bubbles = parse(malformedRealMessage) + assertEquals( + listOf( + "真乖♪ 这可是今天最让人家放心的一句。", + "那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?", + ), + bubbles.map { it.body }, + ) + // 纯旁白块照既有规则依附到下一个有正文的气泡上(head)。 + assertEquals(listOf(ContentPart.Action("满意地点点头")), bubbles[0].head) + assertNoLooseTagInRenderedText(bubbles) + } + + @Test + fun duplicatedOpeningTagIsDropped() { + assertEquals(listOf("正文"), parse("

正文

").map { it.body }) + } + + @Test + fun strayClosingTagIsDropped() { + assertEquals(listOf("正文"), parse("

正文

").map { it.body }) + } + + @Test + fun duplicatedOpeningTagInLaterBlockKeepsBubbleSplit() { + assertEquals(listOf("甲", "乙"), parse("

甲

乙

").map { it.body }) + } + + @Test + fun duplicatedOpeningTagKeepsTheNoteAttachedToTheNextParagraph() { + val bubble = parse("

点头

乙

").single() + assertEquals("乙", bubble.body) + assertEquals(listOf(ContentPart.Action("点头")), bubble.head) + } + + @Test + fun duplicatedOpeningTagWithAttributesIsDropped() { + assertEquals(listOf("正文"), parse("

正文

").map { it.body }) + } + + @Test + fun looseTagsSurroundedByWhitespaceAreDropped() { + assertEquals(listOf("正文"), parse("

\n

正文

\n

").map { it.body }) + } + + @Test + fun leadingAndTrailingLooseTagsLeaveNoEmptyBubble() { + assertEquals(listOf("甲"), parse("

甲

").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 }) + 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("
这是一段 HTML
"), parse("
这是一段 HTML
").map { it.body }) + assertEquals(listOf("这不关 p 标签的事"), parse("这不关 p 标签的事").map { it.body }) + assertEquals(listOf("

大小写不认

"), parse("

大小写不认

").map { it.body }) + assertEquals(listOf("你好甲 1 < 2

乙

").map { it.body }) + assertEquals(listOf("甲乙"), parse("

甲乙

").map { it.body }) + } + // ------------------------------------------------------------------ // 流式增量:最终结果必须与一次性解析一致 // ------------------------------------------------------------------ @@ -257,6 +360,7 @@ class ChatContentParserTest {

你好*弯起眼睛*

这是*强调*文字

*旁白:其实偷偷开心了一下*

+

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

晚安。

""".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() + for (ch in malformedRealMessage) { + val bodies = parser.onDelta(ch.toString()).map { it.body } + assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size) + shown.forEachIndexed { i, body -> + assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body)) + } + shown = bodies + } + } + + @Test + fun streamingRandomChunksOfMalformedMessageMatchOneShot() { + val expected = parse(malformedRealMessage) + val random = Random(20261006) + repeat(25) { + val parser = StreamingContentParser() + var index = 0 + while (index < malformedRealMessage.length) { + val size = 1 + random.nextInt(7) + val end = minOf(index + size, malformedRealMessage.length) + parser.onDelta(malformedRealMessage.substring(index, end)) + index = end + } + assertEquals(expected, parser.finish()) + } + } + + /** + * 重复开标签必须连同它后面的正文一起被扣留到收口。 + * + * 放宽扣留(把重复的 `

` 当「外层冗余」、让前缀继续推进)会把 `甲` 先吐上屏, + * 等 `

` 到了再变成 `甲乙`——用户看到的字会跳,所以这里钉住「中间态一帧都不吐」。 + */ + @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) { + for (bubble in bubbles) { + for (part in bubble.parts) { + assertFalse("标签漏进了正文:${part.text}", part.text.contains("