diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt new file mode 100644 index 0000000..e65a8c0 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt @@ -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, + /** + * 原文里**确实带标记**(`
` / ` `(可带属性)/ ` ` 在这里也是普通文本。
+ */
+private val KNOWN_TAG_RE = Regex(
+ " ]*)?>| ` 这种 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()
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt
index 6a6dd84..8cac68e 100644
--- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt
@@ -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) 完整长度 624046 字符。 第一段 第二段 你好呀 第一行\n第二行 你好🎀再见 你好呀 第二段 hi *笑* 没闭合就结束")
+ }
+
+ @Test
+ fun `没闭合的 action 同样原样留着`() {
+ assertUntouched("