feat(chat): 编辑框只显示纯文本 —— 真有标记时才剥,且弹层明说会替换掉什么

主人拍板:编辑单条消息时输入框里只放纯文本,不再让用户对着 <p> / <action> / <narration> 改字。
红线是「不许静默丢东西」,所以这次不是「把标记一剥了事」:

- 新增纯 Kotlin 模块 domain/chat/MessageEditText.kt(无 Android / Compose 依赖)。
  原文不含已知标签时逐字节恒等:不 trim、不折叠连续空行、不换换行符;含标签时用
  domain/parse(项目里唯一的解析器,不另写正则,免得两套口径分叉)按原始片段顺序拼出可见纯文本,
  正文与旁白/动作一并保留,气泡之间空一行;一个气泡都解析不出就回落成原文(宁可不剥)。
  hasMarkup 只在「保存真的会替换掉什么」时为 true。
- MessageActionState.Edit 新增 markupReplaced;startEdit 只算一次 editableMessageOf
  (不放进 state.update 的 lambda 里),draft 拿纯文本、markupReplaced 拿原文带不带标记。
- MessageEditDialog:markupReplaced 为 true 时在计数行下方多一行小字,写明「保存后这条消息只剩
  你写的这段文字,原来的旁白与动作会被替换掉」;为 false 时一行都不多显示。计数与保存禁用逻辑不变。
- 保存提交的就是 draft 本身,不把标记猜着装回去:任何回填都要先猜「旁白该插回哪一句之间」,
  猜错就是静默移动她说过的话,用户还看不出发生了什么(理由写在 submitEdit 与弹层的 KDoc 里)。
- 新增 MessageEditTextTest(21 例):纯文本恒等(空串、首尾空白、连续三个换行、CRLF、
  以及 1 < 2 / <div> / <P> / <p2> 这类「有尖括号但不是标记」的普通文本)、线上真实标记串、
  纯旁白、带属性的 p 标签、head/tail 旁白的先后顺序、气泡间只空一行、畸形未闭合不丢字。

为什么这仍然值得做:主会话 64 条 user 消息全是纯文本,所以对今天的真实数据这是一条恒等变换——
它是防御性口径,不是修一个正在发生的 bug;也正因如此,纯文本路径必须逐字节不变。
This commit is contained in:
AskaEth committed 2026-10-04 22:07:40 +08:00
1 parent e4915a6904
commit 66c2291312
4 files changed
+333 -11

No files matched your search

@@ -0,0 +1,97 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.domain.parse.parse
/*
* MessageEditText —— 「一条消息在编辑框里长什么样」的纯逻辑(无 Android / Compose 依赖)。
*
* 口径:编辑框只显示**纯文本**,但绝不静默丢东西。两件事必须同时成立:
* 1. 原文本来就是纯文本 ⇒ 显示与保存都**逐字节恒等**(`hasMarkup == false`);
* 2. 原文真的带标记 ⇒ 显示剥掉标记的可见文本,并由弹层**明说**保存会把那些标记换成用户写的这段字
* (`hasMarkup == true`,见 `ui/chat/MessageEditDialog.kt`)。
* 所以「剥标记」这条路只对**真有标记**的内容生效,对今天真实的主会话数据(user 消息全是纯文本)
* 是一条恒等变换——它是防御,不是修一个正在发生的 bug。这也正是不能顺手把纯文本 trim /
* 折叠空行 / 统一换行符的原因:那会把恒等变成「悄悄地改」。
*
* 剥标记**只用** `domain/parse` 这一个解析器([parse]),不另写正则:两套口径必然分叉,
* 屏幕上读到的与她实际改的就会是两段不同的文本。拼纯文本的做法与 `ui/chat/SessionSearchModel.kt`
* 的 `plainSearchText` 同源(同一份 `parse` 结果、同一句「按片段顺序取文本」),
* 只是彼此的用途不同:搜索片段折成一行、这里保持段落,所以两者的拼接方式各自写在自己的文件里。
*
* 为什么保存时**不**把标记装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
* 而这不是本端有权替用户决定的事——猜错就是静默移动她说过的话的位置,且用户看不出发生了什么。
* 提交的就是编辑框里那串纯文本本身,标记的归宿由弹层那一行小字**提前说清**。
*/
/**
* 一条消息在编辑框里的样子(纯数据)。
*
* 两个字段是一体的:`text` 是用户看得到、也真的要动手改的那串字,`hasMarkup` 说的是
* 「保存会不会因此丢掉别的东西」——弹层靠它决定要不要讲那一句话。
*/
data class EditableMessage(
/** 编辑框显示、也就是用户实际编辑的**纯文本**。 */
val text: String,
/**
* 原文里**确实带标记**(`<p>` / `<action>` / `<narration>`)⇒ 保存会把那些标记换成 `text`。
* false = 原文就是纯文本,显示与保存都是**恒等**的。
*/
val hasMarkup: Boolean,
)
/**
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签):
* `<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
*
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
*/
private val KNOWN_TAG_RE = Regex(
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
)
/** 气泡之间的分隔:一个空行——屏幕上读到的两段之间就是这么空着的。 */
private const val BUBBLE_SEPARATOR = "\n\n"
/**
* 算出这条消息在编辑框里的样子(纯函数,无副作用)。
*
* 三分支,覆盖「标记只在真的有标记时才动手」这条红线:
* - **原文不含任何已知标签** ⇒ 原样返回,且**一个字符都不动**(不 trim、不折叠连续空行、不换换行符);
* - **含标签且解析出了气泡** ⇒ `text` = 可见纯文本(见下);
* - **含标签但一个气泡都解析不出**(例如整条就是一个没闭合的畸形标签)⇒ `text` 回落成原文:
* 宁可原样留着,也不赌一把去删掉屏幕上说不清是什么的东西。
*
* `hasMarkup` 只在「解析**真的**换掉了什么」时为 true,也就是 `text != content`:
* 它承诺的是「保存后这条消息和你现在看到的不一样」,而这个承诺必须句句属实——
* 畸形标签、`<p> </p>` 这类解析器按普通文本处理的输入走的是恒等路径,
* 那就该如实说「什么都没被替换」,而不是先吓用户一下。
*/
fun editableMessageOf(content: String): EditableMessage {
if (!KNOWN_TAG_RE.containsMatchIn(content)) return EditableMessage(content, hasMarkup = false)
val text = visibleTextOf(content)
return EditableMessage(text, hasMarkup = text != content)
}
/**
* 标记 → 可见纯文本:每个气泡按**原始片段顺序**取文本(正文与旁白/动作一视同仁,都不丢),
* 气泡之间一个空行,最后整体 trim。
*
* 按 `parts` 顺序拼而不是按 head/body/tail 拼:`parts` 就是原文顺序,[parse] 的 head 只是
* 「该把这块画在气泡上方」的渲染提示,用它重排会改掉用户读到的次序。
*
* 每个气泡**自己也 trim**(然后才是气泡之间的空行):气泡在屏幕上本就是独立的一块,
* 块内的首尾空白渲染上不显示;不 trim 的话 `</p>\n\n<p>` 这种 Web 形态的块间空行会与
* 分隔符叠加成四个换行,读起来就不是屏幕上那两段了。这里去掉的只可能是空白,
* 一个可见字符都不会掉。
*
* 一个气泡都解析不出时**原样返回 `content`**:那是「宁可不剥」的落点,不是错误。
*/
private fun visibleTextOf(content: String): String {
val bubbles = parse(content)
if (bubbles.isEmpty()) return content
return bubbles
.joinToString(BUBBLE_SEPARATOR) { bubble -> bubble.parts.joinToString("") { it.text }.trim() }
.trim()
}
@@ -47,6 +47,7 @@ import top.yeij.cyrene.domain.chat.ImageDelivery
import top.yeij.cyrene.domain.chat.MessageFingerprint
import top.yeij.cyrene.domain.chat.RetryKeyMemory
import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.chat.editableMessageOf
import top.yeij.cyrene.domain.chat.readBounded
import top.yeij.cyrene.domain.chat.toDocumentFailure
import top.yeij.cyrene.domain.parse.ParsedBubble
@@ -356,12 +357,18 @@ sealed interface MessageActionState {
val error: String?
/**
* 编辑弹层。[draft] 初值是**服务端存的原始 `content`**(含 `<p>` / `<action>` 这类标记)——
* 编辑的就是那个字符串本身,见 [MessageEditDialog] 的说明。
* 编辑弹层。[draft] 是**纯文本**:[editableMessageOf] 把服务端存的那串 `content` 里的
* `<p>` / `<action>` / `<narration>` 剥掉,剩下的就是用户看得到、也真的要动手改的字
* (原文本来就是纯文本时**逐字节不变**)。
*
* [markupReplaced] 把这件事的**后果**一并带给弹层:原文真带标记时,保存就是拿用户写的这段字
* 换掉那些标记,弹层必须据此**明说**——见 [MessageEditDialog] 的说明。
*/
data class Edit(
override val messageId: String,
val draft: String,
/** 原文带标记 ⇒ 保存会把那些标记换成用户写的纯文本(弹层据此**明说**这件事)。 */
val markupReplaced: Boolean = false,
override val inFlight: Boolean = false,
override val error: String? = null,
) : MessageActionState
@@ -675,13 +682,23 @@ class ChatViewModel(
* 打开编辑弹层。**只对 `user` 消息开放**(口径见 [canEditMessage],UI 隐藏入口 + 这里再挡一次,
* 判据是同一份纯函数)。
*
* 初值是**服务端存的原始 `content`**(可能带 `<p>` / `<action>` 这类标记)——那是唯一真源,
* 编辑它才是「改这条消息」;先剥标记再回写会静默丢掉旁白/动作。
* 初值走 [editableMessageOf]:编辑框里是**纯文本**(剥掉 `<p>` / `<action>` / `<narration>`),
* 原文本来就是纯文本时逐字节不变。原文真带标记的话,那件事**不能不说**——所以
* [MessageActionState.Edit.markupReplaced] 一起带上,由弹层明说「保存后只剩你写的这段字」。
*
* 只算一次:`_state.update` 的 lambda 可能被重跑(CAS 重试),别把纯函数调用塞进它里面。
*/
fun startEdit(message: HistoryMessageDto) {
if (!canEditMessage(message)) return
val editable = editableMessageOf(message.content)
_state.update {
it.copy(messageAction = MessageActionState.Edit(message.id, draft = message.content))
it.copy(
messageAction = MessageActionState.Edit(
messageId = message.id,
draft = editable.text,
markupReplaced = editable.hasMarkup,
),
)
}
}
@@ -709,6 +726,11 @@ class ChatViewModel(
* 提交编辑:先校验([validateMessageEdit],空 / 超 4000 rune 的服务端必拒,本端不发这一趟),
* 再 `PATCH /api/v1/messages/{id}`。
*
* **发出去的就是 `action.draft` 本身**(弹层里那串纯文本,原样、不 trim)。
* **不把剥掉的标记装回去**:任何回填都要先猜「旁白/动作该插回哪一句之间」,猜错就是静默移动
* 她说过的话,而用户看不出发生了什么——这个猜测本端没有资格替用户做。标记的归宿由弹层在提交前
* **明说**([MessageActionState.Edit.markupReplaced]),不是提交后才发现。
*
* 成功与失败都不在这里直接改消息列表:成功走 [applyMessageEdited](与广播同一条出口),
* 失败走 [applyMessageActionFailure](弹层还开着就留在弹层里,用户改完能直接重试)。
*/
@@ -84,11 +84,19 @@ fun MessageActionMenu(
/**
* 编辑弹层:一个多行输入框 + 码点计数 + 取消 / 保存。
*
* ## 编辑的是「服务端存的那个字符串」
* 初值就是消息的原始 `content`。后端的正文里可能有 Web 渲染链路的标记(`<p>` / `<action>` /
* `<narration>`),它们在气泡里被 `domain/parse` 解析成正文与旁白——但**存储里就是一个字符串**,
* 编辑它才是「改这条消息」。先剥标记再回写会静默丢掉旁白与动作(而且丢掉的正是用户没打算删的部分)。
* 代价是编辑框里会看到标记本身;这是刻意的取舍,不在这里做「只显示纯文本」的假象。
* ## 编辑框里是纯文本,但绝不静默丢东西
* 初值是 `domain/chat/MessageEditText.kt` 的 `editableMessageOf` 算出来的**纯文本**:后端的正文里
* 可能有 Web 渲染链路的标记(`<p>` / `<action>` / `<narration>`),它们在气泡里被 `domain/parse`
* 解析成正文与旁白——让用户对着尖括号改字不是「改这条消息」该有的样子。
* 原文本来就是纯文本时**逐字节不变**(不 trim、不折叠空行),所以「剥标记」这条路径只在真有标记时才生效。
*
* 剥标记是有代价的:保存回去的是这段纯文本,原来的旁白/动作就没了。所以这件事**必须明说**——
* [MessageActionState.Edit.markupReplaced] 为 true 时,输入框下面会多一行小字,把「保存后这条消息
* 只剩你写的这段文字、原来的旁白与动作会被替换掉」讲清楚;为 false(原文没有标记)时**一行都不多显示**。
*
* 反过来,保存时也**不**把标记猜着装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
* 猜错就是静默移动她说过的话,且用户看不出发生了什么。提交的就是用户写的这段字本身
* ([ChatViewModel.submitEdit]),不猜是刻意的。
*
* ## 计数为什么按码点
* 服务端上限是 4000 **rune**([MESSAGE_EDIT_MAX_RUNES]),所以这里也用 [runeCount]:
@@ -99,7 +107,8 @@ fun MessageActionMenu(
* [MessageActionState.Edit.error] 非空时显示在按钮上方:这条消息原地重试即可,输入不丢。
* (弹层被关掉之后才失败的,落到页面顶部那行横幅,见 [ChatUiState.applyMessageActionFailure]。)
*
* @param state 弹层的草稿 / 进行态 / 失败原因(状态在 VM 里,弹层自己不持有提交结果)。
* @param state 弹层的草稿(纯文本)/ 原文带不带标记 / 进行态 / 失败原因(状态在 VM 里,
* 弹层自己不持有提交结果)。
*/
@Composable
fun MessageEditDialog(
@@ -140,6 +149,15 @@ fun MessageEditDialog(
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
if (state.markupReplaced) {
Spacer(Modifier.height(6.dp))
// 明说「保存会替换掉什么」:原文的旁白/动作标记不会再出现在这条消息里。
Text(
text = "原文带旁白/动作标记:保存后这条消息只剩你写的这段文字,原来的旁白与动作会被替换掉。",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
if (state.error != null) {
Spacer(Modifier.height(8.dp))
Text(
@@ -0,0 +1,185 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 编辑框初值:什么时候逐字节恒等,什么时候剥标记,剥完还剩下什么。
*
* 红线是「不许静默丢东西」,所以两个方向都要钉住:
* - **纯文本一个字符都不许动**——哪怕带连续空行、CRLF、全角空格,哪怕里面有 `<`(`1 < 2`、`<div>`
* 都是普通文本,见到尖括号就剥是把用户打的字当标签吃掉);
* - **真有标记时也不许把旁白/动作丢掉**——旁白是她说的话的一部分,剥掉标签不等于可以丢内容。
*
* 判据本身在 [editableMessageOf],剥标记完全交给 `domain/parse`(项目里唯一的解析器),
* 这里不重复实现它的规则。
*/
class MessageEditTextTest {
// ------------------------------------------------------------------
// 一、纯文本:逐字节恒等
// ------------------------------------------------------------------
/** 纯文本路径的唯一断言:`hasMarkup` 为 false,且文本与原文**逐字符**相等。 */
private fun assertUntouched(content: String) {
val editable = editableMessageOf(content)
assertFalse("「$content」不该被判成带标记", editable.hasMarkup)
assertEquals("「$content」必须逐字符原样", content, editable.text)
}
@Test
fun `普通中英文纯文本逐字节恒等`() {
assertUntouched("你好呀,今天过得怎么样?Hello, world — 我在这儿。")
}
@Test
fun `空串就是空串`() {
assertEquals(EditableMessage(text = "", hasMarkup = false), editableMessageOf(""))
}
@Test
fun `首尾空白与各种空白字符原样保留`() {
// 用户打的就是这些字:不 trim,也不把全角空格 / 不换行空格 / 零宽空格 / 制表符换成别的。
assertUntouched("\t 第一行\u3000\u00A0\u200B\n 第二行 \n")
}
@Test
fun `连续三个换行不折叠`() {
// 解析器的空行切分是**渲染**的规则,编辑框不欠它什么:多打的空行照旧是用户的东西。
assertUntouched("第一段\n\n\n\n第二段")
}
@Test
fun `换行符不统一 —— CRLF 与单独的 CR 都原样保留`() {
assertUntouched("第一行\r\n第二行\r第三行\n")
}
@Test
fun `小于号不是标记 —— 数学符号与普通标签都算普通文本`() {
// 判据只认解析器真正认识的那三个标签。见到 `<` 就当标记,就会把用户打的数学符号吃掉。
listOf(
"1 < 2 且 3 > 2",
"if (a < b) { c }",
"a<b>c",
"<3",
"<div>这是一段 HTML</div>",
"</span>",
"<p2>这不关 p 标签的事</p2>",
"<P>大小写不认(解析器只认小写 p)</P>",
).forEach(::assertUntouched)
}
@Test
fun `裸的星号旁白不触发剥标记`() {
// 星号式旁白(*…* / *动作:…*)不是本模块认的标记:编辑框原样显示,保存也原样发。
assertUntouched("*弯起眼睛笑了*我今天想你了")
assertUntouched("*动作:她歪了歪头*")
}
// ------------------------------------------------------------------
// 二、真有标记:剥掉标签,内容一件不少
// ------------------------------------------------------------------
@Test
fun `线上真实形状 —— 旁白与正文都在,且不再有尖括号`() {
val content = "<p><action>把整卷纸条从桌头铺到桌尾,最后终于摸到了尾巴</action>" +
"读到头也读到尾啦,亲爱的♪</p>\n\n<p>完整长度 624046 字符。</p>"
val editable = editableMessageOf(content)
assertTrue(editable.hasMarkup)
assertEquals(
"把整卷纸条从桌头铺到桌尾,最后终于摸到了尾巴读到头也读到尾啦,亲爱的♪\n\n完整长度 624046 字符。",
editable.text,
)
assertFalse("剥完不该再剩标签", editable.text.contains('<') || editable.text.contains('>'))
// 旁白(动作)与正文都得在:旁白是她说的话的一部分,不是可以顺手丢掉的东西。
assertTrue(editable.text.contains("把整卷纸条从桌头铺到桌尾,最后终于摸到了尾巴"))
assertTrue(editable.text.contains("读到头也读到尾啦,亲爱的♪"))
assertTrue(editable.text.contains("完整长度 624046 字符。"))
}
@Test
fun `纯旁白消息 —— 旁白那句话本身留下`() {
val editable = editableMessageOf("<action>弯起眼睛笑了</action>")
assertTrue(editable.hasMarkup)
assertEquals("弯起眼睛笑了", editable.text)
}
@Test
fun `narration 标签同样算标记`() {
val editable = editableMessageOf("<narration>内心:她也在想你</narration>")
assertTrue(editable.hasMarkup)
assertEquals("内心:她也在想你", editable.text)
}
@Test
fun `两个气泡之间只空一行`() {
assertEquals("第一段\n\n第二段", editableMessageOf("<p>第一段</p>\n\n<p>第二段</p>").text)
}
@Test
fun `带属性的 p 标签也认 —— 容差与解析器一致`() {
val editable = editableMessageOf("<p class=\"line\">你好呀</p>")
assertTrue(editable.hasMarkup)
assertEquals("你好呀", editable.text)
}
@Test
fun `气泡内的换行保留`() {
assertEquals("第一行\n第二行", editableMessageOf("<p>第一行\n第二行</p>").text)
}
@Test
fun `气泡内的 emoji 不被拆坏`() {
assertEquals("你好🎀再见", editableMessageOf("<p>你好🎀再见</p>").text)
}
@Test
fun `head 旁白排在正文之前 —— 按原文顺序,不按渲染分块重排`() {
val editable = editableMessageOf("<p><action>她歪了歪头</action>你好呀</p>")
assertTrue(editable.hasMarkup)
assertEquals("她歪了歪头你好呀", editable.text)
}
@Test
fun `tail 旁白也留下`() {
assertEquals("你好呀她挥了挥手", editableMessageOf("<p>你好呀<action>她挥了挥手</action></p>").text)
}
@Test
fun `标签外散落的正文不丢`() {
assertEquals(
"开头这几句没包在标签里\n\n第二段",
editableMessageOf("开头这几句没包在标签里\n\n<p>第二段</p>").text,
)
}
@Test
fun `气泡内的星号旁白随标签一起剥掉 —— 口径来自解析器`() {
// 有标记时怎么剥由 parse 说了算:它认 *笑* 是旁白,所以编辑框里留下旁白那句话、没有星号。
assertEquals("hi 笑", editableMessageOf("<p>hi *笑*</p>").text)
}
// ------------------------------------------------------------------
// 三、畸形:宁可不剥,也不许丢字
// ------------------------------------------------------------------
@Test
fun `没闭合的 p 不抛异常 —— 解析器按普通文本处理,这里就恒等`() {
assertUntouched("<p>没闭合就结束")
}
@Test
fun `没闭合的 action 同样原样留着`() {
assertUntouched("<action>她笑了,可是没写结尾")
}
@Test
fun `只有一对空标签的消息恒等 —— 解析不出气泡就不剥`() {
// parse("<p></p>") 是空列表:宁可把 `content` 原样留着,也不赌一把把整条消息删空。
assertUntouched("<p></p>")
}
}