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
` 一路回溯到串尾——正文里 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` 及其后内容按既有契约整段回落字面文本,` ` 后面还有没有正文」按位置记表:正文里 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 }) } + // ------------------------------------------------------------------ + // 复杂度回归:一大段落单标签不许把 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