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 98dba45..d78ede5 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 @@ -172,7 +172,18 @@ private sealed interface Tok { /** 先切 `

` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */ private fun tokenize(raw: String): List { - val pMatches = P_BLOCK_RE.findAll(raw).toList() + // 完整块必然以 `

` 收尾,所以最后一个 `

` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话, + // 每个 `` 一路回溯到串尾——正文里 N 个落单标签时 + // 这里就是 O(N²):实测 20000 个要 8.2 秒(同一输入下 `stripLooseTags` 那边还要 23 秒,见 + // [ContentAfterQuery])。切掉后同一输入 1 毫秒内出结果,且匹配结果逐字不变(每个匹配都整体落在 + // 被切下的前缀里,正则的尝试顺序也没变)。 + val lastClose = raw.lastIndexOf("

") + val pMatches = + if (lastClose < 0) { + emptyList() + } else { + P_BLOCK_RE.findAll(raw.substring(0, lastClose + "

".length)).toList() + } val out = ArrayList() if (pMatches.isEmpty()) { // 一条完整块都没有:`

` 及其后内容按既有契约整段回落字面文本,`` 这类旁白标签 @@ -224,6 +235,9 @@ private fun tokenize(raw: String): List { 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() + // 「这个落单 `

` 后面还有没有正文」按位置记表:正文里 N 个落单标签,现扫是 O(N²)(见 + // [ContentAfterQuery] 的 KDoc)。常规消息(没有落单 `

` 开标签)一次都不建表。 + val contentAfter = ContentAfterQuery(text) val out = StringBuilder(text.length) var last = 0 var i = 0 @@ -234,7 +248,7 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags 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) + OPEN_P_TAG_RE.matches(tag) -> markupMode || !contentAfter.hasContentAfter(gt + 1) CLOSE_P_TAG_RE.matches(tag) -> true !stripStrayRoleTags -> false roleRanges.any { lt in it } -> false @@ -253,27 +267,78 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags 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 +/** + * [stripLooseTags] 判落单 `

` 用的「[from] 之后还有没有正文」查询:空白跳过,`

` / `

` + * 整段跳过,其余字符(别的标签、落单的 `<`)一律算有正文——跳过空白与后续 `

` 族标签后仍非空 + * 即为有。判据与原实现逐字一致,只多了记忆化。 + * + * **为什么要记忆化**:这条判据原本每次查询都一路现扫到串尾,而 [stripLooseTags] 对**每一个** + * 落单 `

` 都要问一次,正文里 N 个落单标签就是 O(N²)——20000 个落单 `

` 的输入实测这条判据 + * 要 23 秒(同一次 `parse` 合计 31 秒),而 `parse` 跑在主线程上(`ui/chat/MessageBubble.kt` 的 + * `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。 + * + * **为什么可以只算一次**:答案只取决于**位置**——同一个位置再问一次,走的一定是同一条链、到的 + * 一定是同一个终点。所以走链时把沿途每个位置的答案落表,之后 O(1) 命中;每个位置至多被算一次, + * N 个落单标签整体就是线性的(同一输入实测这条判据从 23 秒降到毫秒级)。 + * + * **为什么不改变语义**:判定用的就是原来那段扫描本身([next] 逐分支对应原循环),没有另立一套 + * 「什么算 `

` 标签」的判据——仍是同一组 [OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE]。落表的答案是同一条 + * 扫描推出来的,因此与逐次现扫逐字相同;连「起点落在标签内部」这种查询也一致(原扫描看到的是 + * 标签里的字符、判有正文,[next] 在这里同样返回 null)。 + * + * 惰性:没遇到落单 `

` 开标签的常规消息一次也不建表,一个字节都不多花。 + */ +private class ContentAfterQuery(private val text: String) { + /** 每个位置的答案:`0` = 还没算、[NOISE_ONLY] = 后面只剩空白与 `

` 族标签、[HAS_CONTENT] = 有正文。 */ + private val answers: ByteArray by lazy(LazyThreadSafetyMode.NONE) { + ByteArray(text.length + 1).also { it[text.length] = NOISE_ONLY } + } + + /** 本次走链途经的位置,用来把 [answers] 一次落表。 */ + private val path: IntArray by lazy(LazyThreadSafetyMode.NONE) { IntArray(text.length) } + + /** 与原先「每次现扫到串尾」的判定逐字等价。 */ + fun hasContentAfter(from: Int): Boolean { + var walked = 0 + var i = from + while (true) { + val known = answers[i] + if (known != UNKNOWN) return finish(walked, known) + path[walked++] = i + val step = next(i) + if (step == null) return finish(walked, HAS_CONTENT) + i = step } - if (text[i] == '<') { + } + + /** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */ + private fun finish(walked: Int, answer: Byte): Boolean { + for (k in 0 until walked) answers[path[k]] = answer + return answer == HAS_CONTENT + } + + /** + * 原扫描的一步:空白 → 下一位;`

` 族标签 → 标签之后;其余(别的标签、落单的 `<`)→ `null`, + * 即「有正文」,扫描到此为止。走到 `text.length` 时由 [answers] 里预置的答案收口。 + */ + private fun next(i: Int): Int? { + val c = text[i] + if (c.isWhitespace()) return i + 1 + if (c == '<') { 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 - } + if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) return gt + 1 } } - return true + return null + } + + private companion object { + const val UNKNOWN: Byte = 0 + const val NOISE_ONLY: Byte = 1 + const val HAS_CONTENT: Byte = 2 } - return false } /** 在文本区内按左到右顺序切出 Text / Action / Narration。 */ 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 dd96635..78822ec 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 @@ -325,6 +325,14 @@ class ChatContentParserTest { assertEquals(listOf("甲", "乙"), parse("

甲

乙").map { it.body }) } + /** 剥不剥只看它**后面**还有没有内容:后面有正文的留字面,后面什么都没有的才剥。 */ + @Test + fun aLooseTagIsKeptWheneverThereIsStillContentAfterIt() { + assertEquals(listOf("

甲

乙"), parse("

甲

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

甲"), parse("

甲

").map { it.body }) + assertEquals(listOf("

甲"), parse("

甲

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

甲

").map { it.body }) @@ -509,6 +517,42 @@ class ChatContentParserTest { assertEquals(listOf("新"), parser.onDelta("

新

").map { it.body }) } + // ------------------------------------------------------------------ + // 复杂度回归:一大段落单标签不许把 parse 拖成 O(N²) + // + // ⚠️ 这条测的是**复杂度**,不是精确耗时:阈值宽到 20000 个落单标签给 2 秒,只用来区分 + // 「线性」与「平方」。修复前实测:2000 个 854ms、8000 个 4254ms、20000 个 26405ms(每翻 + // 一倍涨约 4 倍,典型平方);修复后 20000 个是十几毫秒。所以阈值别卡紧(CI 机器可能更慢), + // 这里量到的数也别当性能指标读——要盯的是「有没有退回 O(N²)」。 + // + // 这个形状同时压住两处(修复前各占一部分,都在 `ChatContentParser` 里): + // - 落单标签的「后面还有没有正文」判据(`ContentAfterQuery`,修复前约 23 秒); + // - 完整块搜索 `P_BLOCK_RE.findAll`(没有 `

` 时每个 `".repeat(count), // 一串纯落单开标签 + " \n

".repeat(count), // 标签之间夹空白:跳过空白后仍是「后面什么都没有」 + ) + for (input in inputs) { + val startedAt = System.nanoTime() + val bubbles = parse(input) + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + assertTrue("落单标签应当被剥净,实际留下:${bubbles.map { it.body }}", bubbles.isEmpty()) + assertTrue( + "${input.length} 字符($count 个落单标签)用了 ${elapsedMs}ms," + + "超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?", + elapsedMs < looseTagBudgetMs, + ) + } + } + /** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */ private fun assertNoLooseTagInRenderedText(bubbles: List) { for (bubble in bubbles) {