4 Commits
Author SHA1 Message Date
AskaEth 1794e53d1c merge: W24 —— 编辑弹层只显示纯文本(原文含标记时如实告知会被替换,绝不静默丢旁白) 2026-10-04 22:08:08 +08:00
AskaEth 2d382b11cd merge: W23 —— 接入契约 v0.63 的 documents[].reason(只在下发时原样显示,不做整句判断) 2026-10-04 22:08:06 +08:00
AskaEth 66c2291312 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;也正因如此,纯文本路径必须逐字节不变。
2026-10-04 22:07:40 +08:00
AskaEth e5fa1b871f 接入契约 v0.63 的 documents[].reason:把「读不到的原因」原样送到附件预览弹层
后端 902b6ee / 契约 v0.63 给 historyMessage.documents[] 的元素加了可选字段 reason:
只在 status == "unreadable" 时下发(Go omitempty,extracted 时整个键缺席),内容与后端
注入模型清单里的那 10 条是同一份字符串,最长的一条 40+ 字。

改了什么
- DocumentRefDto 加 reason: String? = null。documents[] 在 WS 握手 history、
  message_persisted、GET /messages 三处共用这一个 DTO,所以一处解析三条路全覆盖。
  KDoc 写明三条口径:可选、只在 unreadable 时下发、是后端原话所以客户端原样显示,
  且绝不做整句相等判断(需要分支只允许用稳定子串)。
- DocumentChipModel 加 reason,由 DocumentChips.of(ref) 取 ref.reason 去掉首尾空白后
  非空才留,否则归一成 null——一句空话会顶掉弹层里的通用说明,比没有更坏。
  芯片外观一个字没改:detail / statusLabel 保持现状(unreadable 仍显示「读不到」)。
  理由:那句话太长,塞进一行芯片只会被 Ellipsis 切掉半句,比不显示更糟。
- DocumentPreviewGate.plan 增加 reason 参数:非空白时直接作为正文区那句说明原样返回
  (NotText(reason)),压过本端按 mime / 扩展名的裁决;DocumentTarget.previewPlan 顺着
  chips.reason 把它带进裁决。预览弹层本身一行代码都没改——它渲染的就是 plan.message
  那句话,所以 reason 从 DTO 到用户看见的那句话整条链路上没有任何改写或截断。
- 配套单测 17 条。

为什么这么改
- 服务端那句原因已经断言「无法抽取正文」,它比本端按类型编的通用说明(只知道类型、
  不知道到底为什么)准确;替它改写、归纳或截断就是丢掉服务端唯一一次说清原因的机会。
- 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是
  正文闸要消灭的「看着像文件坏了」。
- reason 缺席时(契约 v0.63 之前的历史消息)产出与改之前逐字相同:兜底那句话、判据、
  文案全都不动。

测试(新增 17 条)
- DocumentDtoTest +5:unreadable 带 reason 逐字解出;extracted 元素没有该键 -> null;
  v0.62 风格的缺键载荷 -> null(不解码失败、不替服务端编一句话);清单外的未知分类原样
  保留;显式 null -> null。
- DocumentChipTest +5:reason 带进模型;句中标点与换行原样、只去首尾空白;空白串 ->
  null;绝不进芯片副行(detail 逐字不变);待发附件恒为 null。
- DocumentPreviewTest +7:reason 逐字成为正文区那句话(含未知分类,不丢不改不换兜底);
  只 trim 一次再原样显示;缺席时与改前逐字相同(拿 refusalMessage 对比);
  空白 reason 不改任何行为;text mime + reason 不再假装有正文。
- assembleDebug 通过;testDebugUnitTest 848 个测试 0 失败 0 错误 0 跳过。

已知代价(已写进 KDoc,供人拍板)
服务端判为读不到、但字节其实下得动的附件(例如超限的纯文本)会失去 App 内预览,
「用其它应用打开」与「保存到下载目录」两条出口照旧完整可用——与「未知 mime 判为非文本」
是同一笔不对称账。
2026-10-04 21:57:14 +08:00
11 changed files with 602 additions and 19 deletions

No files matched your search

@@ -25,6 +25,8 @@ import kotlinx.serialization.Serializable
* 3. 下行/历史元素只回**引用与统计,绝不回正文**(吸取 `G3`「base64 原样落库、每页历史重传」
* 的教训)。`status:"unreadable"` 才是「读不到这个文件」的可观测信号,**不是** `truncated`
* (`truncated` 只说明注入正文被预算截断,正文照样进得了上下文)。
* **契约 v0.63(后端 `902b6ee`)在此之上加了可选 [DocumentRefDto.reason]**:读不到时随
* `status:"unreadable"` 一起下发的**服务端原话**,客户端原样显示(详见该字段的 KDoc)。
*
* ## 客户端必须两种都吃的一件事
* 「0 个附件」这一形状在后端**尚未定死**(回执五原文:期望「不出现或空数组」,S3.5 回报里定死)。
@@ -58,8 +60,8 @@ data class DocumentUploadResponse(
* 下行/历史则带上全部统计字段。
*
* 字段可出现性(后端 `history.DocumentRef` 的 `omitempty` 口径,逐字段核对过):
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` 为 Go 的 `omitempty`
* —— 零值(0 / false / "")时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` / `reason` 为 Go 的 `omitempty`
* —— 零值(0 / false / "" / nil)时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
*/
@Serializable
data class DocumentRefDto(
@@ -80,4 +82,18 @@ data class DocumentRefDto(
* 未知值**原样保留**(不丢弃、不报错、不占位符),由客户端决定如何显示。
*/
val status: String = "",
/**
* **读不到的原因**(契约 v0.63 新增,后端 `902b6ee`):`status == "unreadable"` 时下发的
* 那句**服务端原话**;`status == "extracted"` 时**整键缺席**,因此是可选字段。
*
* 两句纪律:
* 1. **原样显示**。[reason] 与后端**注入模型清单里的那 10 条是同一份字符串**(逐字相同,
* 最长的一条 40+ 字),客户端**不替服务端改写、不截断、不归纳**成自己的话——
* 本端能编的只有像 [top.yeij.cyrene.domain.chat.DocumentPreviewGate.refusalMessage]
* 那样「按类型泛泛说一句」,比这里的原因粗得多;
* 2. **绝不做整句相等判断**。分类若真的需要分支,只允许用**稳定子串**(如「需要 OCR」)——
* 整句比对会在后端改一个字时静默失效,且失败的样子是「显示成兜底那句话」,
* 看起来完全正常。
*/
val reason: String? = null,
)
@@ -27,6 +27,15 @@ data class DocumentChipModel(
val truncatedLabel: String?,
/** `status == "unreadable"`:这是唯一一个「昔涟读不到」的信号,UI 据此给灰态。 */
val unreadable: Boolean,
/**
* 服务端「读不到」的**原话**(`documents[].reason`,契约 v0.63;只在 `unreadable` 时下发)。
* 已去掉首尾空白,空白串与缺席一律归一成 `null`。
*
* ⚠️ **刻意不进 [detail]**:这句话有 40+ 字,塞进芯片那一行只会被 `Ellipsis` 切掉半句,
* 比不显示更糟。它要出现的地方是**用户点开附件后的预览弹层**,见
* [DocumentTarget.previewPlan]。
*/
val reason: String? = null,
) {
/** 芯片副行:`MD · 12 KB · 8,000 字 · 已读入`(缺哪项少哪项,全缺就是空串)。 */
val detail: String
@@ -54,6 +63,9 @@ object DocumentChips {
statusLabel = statusLabel(ref.status),
truncatedLabel = if (ref.truncated) TRUNCATED else null,
unreadable = DocumentStatus.isUnreadable(ref.status),
// 首尾空白去掉;只剩空白(或本就缺席)时归一成 null——「有一句空话」比没有更坏:
// 弹层会拿它顶掉那句通用说明,于是用户看到一块什么都没有的正文区。
reason = ref.reason?.trim()?.takeIf { it.isNotEmpty() },
)
/** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */
@@ -65,6 +77,8 @@ object DocumentChips {
statusLabel = null,
truncatedLabel = null,
unreadable = false,
// 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。
reason = null,
)
/** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */
@@ -60,10 +60,13 @@ data class DocumentTarget(
* `mime`,本地待发附件**没有**服务端 `mime`(那时还没上传过),只能按显示名兜底。
* 本地那条**刻意不问** `ContentResolver.getType`——平台给 `.rs` 这类文件常报
* `application/octet-stream` 或空,拿它当判据会把一批纯文本文件挡在预览之外。
*
* 服务端下发的「读不到的原因」([DocumentChipModel.reason],契约 v0.63)在这里**顺路带进
* 裁决**:它是这句话唯一的去处(芯片那一行放不下 40+ 字),详见 [DocumentPreviewGate.plan]。
*/
fun previewPlan(): DocumentPreviewPlan = when (val origin = source) {
is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name)
is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName)
is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name, chips.reason)
is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName, chips.reason)
}
/**
@@ -161,6 +164,9 @@ sealed interface DocumentPreviewPlan {
/**
* 非文本类(PDF 这类二进制内容):**绝不把字节当 UTF-8 渲染**(那会是一整屏乱码),
* 也**不假装加载失败**(这不是失败,字节取回来了,只是本端不预览它)。
*
* [message] 有两种来源,都是**原样显示**:本端按类型写的那句([DocumentPreviewGate.refusalMessage]),
* 或服务端下发的「读不到的原因」原话(契约 v0.63 的 `documents[].reason`)。
*/
data class NotText(val message: String) : DocumentPreviewPlan
}
@@ -175,6 +181,8 @@ sealed interface DocumentPreviewPlan {
* 实际是本端拿错了渲染方式。
*
* ## 判据(优先服务端,退而扩展名)
* 0. `reason` 非空白 ⇒ **直接就是正文区那句说明,原样显示**(契约 v0.63:服务端说读不到时
* 下发的原话,比本端按类型编的那句准确)。它压过下面三条,代价见 [plan] 的 KDoc;
* 1. `mime` 非空 ⇒ **只看服务端那一次字节嗅探**(契约 §1.2:嗅探是唯一裁决权):
* 以 `text/` 开头的判为文本,其余(`application/pdf` / `application/octet-stream` / 图片型 …)
* 一律判为**非文本**;
@@ -194,8 +202,23 @@ object DocumentPreviewGate {
/**
* @param mime 服务端 DTO 里的 `mime`(嗅探所得基类型);待发附件传空串。
* @param name 服务端存储名或待发附件的显示名——**只在 [mime] 为空时才看它**。
* @param reason 服务端下发的「读不到的原因」原话(`documents[].reason`,契约 v0.63;
* 已由 [top.yeij.cyrene.domain.chat.DocumentChips] 去掉首尾空白并在空白时归一成 `null`),
* 缺席 = 服务端没给,或这条引用本来就不是 `unreadable`。
*
* [reason] 非空白时**它直接就是正文区那句说明,原样显示**,不再走本端按 [mime] / 扩展名的
* 裁决——理由是这句话已经断言了「无法抽取正文」:
* - 它比本端那句通用说明([refusalMessage]:只知道类型,不知道到底为什么)**准确**,
* 替它改写或归纳就是丢掉服务端唯一一次说清原因的机会;
* - 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是这道
* 闸要消灭的「看着像文件坏了」。
*
* 代价说清楚:这条会让**服务端判为读不到、但字节其实下得动**的附件失去 App 内预览
* (例如超限的纯文本)。两条出口(用其它应用打开 / 保存到下载目录)照旧完整可用,
* 与下面「未知 mime 判为非文本」是同一笔不对称账。
*/
fun plan(mime: String, name: String): DocumentPreviewPlan {
fun plan(mime: String, name: String, reason: String? = null): DocumentPreviewPlan {
if (!reason.isNullOrBlank()) return DocumentPreviewPlan.NotText(reason)
val base = baseType(mime)
if (base.isEmpty()) return extensionPlan(name)
return if (base.startsWith(TEXT_PREFIX)) DocumentPreviewPlan.Text else refuse(base)
@@ -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](弹层还开着就留在弹层里,用户改完能直接重试)。
*/
@@ -70,6 +70,11 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false
* 只留 [DocumentPreviewPlan.NotText.message] 那句人话:**这既不是加载失败**(字节是取回来的,
* 两条出口照旧可用),也不是「文件是空的」。
*
* 那句话有两种来源,本弹层**一视同仁地原样渲染**:本端按类型写的那句,以及服务端说「读不到」
* 时下发的**原因原话**(契约 v0.63 的 `documents[].reason`,判定见
* [top.yeij.cyrene.domain.chat.DocumentPreviewGate.plan])——本层不做任何改写或截断,
* 它拿到的 `message` 就是要显示的那句话。
*
* ## 正文为什么是 `LazyColumn` + 分块
* 上限是 5000 行 / 256 KB,整段塞进一个 `Text` 会让打开弹层的那一刻卡在排版上。切成
* [top.yeij.cyrene.domain.chat.DocumentPreviewLimits.LINES_PER_CHUNK] 行一块交给 `LazyColumn`,
@@ -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(
@@ -22,6 +22,8 @@ import top.yeij.cyrene.domain.chat.DocumentStatus
* `5b2b385`(S3.5 文档多值化:`history.go` 的 `historyMessage.Documents` 带 `omitempty`、
* `history.DocumentRef` 的 `omitempty` 口径、`history_document_test.go` 钉住的元素字段集
* `chars,mime,name,size,status,truncated` 与它用的真实取值)。
* **契约 v0.63(后端 `902b6ee`)在此之上加了 `reason`**(只在 `status == "unreadable"` 时下发的
* 服务端原话):那几条用例同样按「缺键 → `null`、有值 → 逐字原样」钉住,**不做整句相等判断**。
* **没有 v0.59 的真实抓包(形状按 api-client §1.2 与后端实现逐条核对)**:这里的 JSON 是按上述实现形状逐字节手写的(唯一确认过的真实取值
* 来自后端自己的测试夹具)。
*
@@ -172,6 +174,69 @@ class DocumentDtoTest {
assertEquals("", doc.status)
}
// —— 契约 v0.63(后端 `902b6ee`):`documents[].reason` ——
/**
* 后端注入模型清单里那 10 条中的一条,**真实串就是这么长**:逐字照抄,不做任何简化。
* 这里的用例一律拿它整串比对,就是为了钉住「客户端不改写、不截断」。
*/
private val ocrReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
@Test
fun `an unreadable reference carries the server reason verbatim`() {
val doc = message(
withDocuments(
"""[{"name":"$name","mime":"application/pdf","size":12345,"status":"unreadable",""" +
""""reason":"$ocrReason"}]""",
),
).documents.single()
assertEquals(DocumentStatus.UNREADABLE, doc.status)
assertEquals(ocrReason, doc.reason)
}
@Test
fun `an extracted reference has no reason key at all`() {
// 服务端只在 status == "unreadable" 时填 `reason`(Go omitempty):extracted 的元素里
// 整个键不存在 ⇒ 解出来必须是 `null`,**不是空串**——空串会顶掉弹层里那句通用说明。
val doc = message(
withDocuments(
"""[{"name":"$name","mime":"text/plain","size":7,"chars":7,"status":"extracted"}]""",
),
).documents.single()
assertNull(doc.reason)
}
@Test
fun `a v0_62 style payload without the reason key still decodes`() {
// 契约 v0.63 之前的历史消息里没有这个键:解码不许失败,也不许替服务端编一句话。
val doc = message(withDocuments("""[{"name":"$name","status":"unreadable"}]""")).documents.single()
assertNull(doc.reason)
}
@Test
fun `a reason outside the ten known ones is preserved verbatim`() {
// 分类可以开放,客户端**不做整句相等判断**:没见过的那句照样原样留着。
val reason = "无法抽取正文:这条分类客户端从没见过"
val doc = message(
withDocuments("""[{"name":"$name","status":"unreadable","reason":"$reason"}]"""),
).documents.single()
assertEquals(reason, doc.reason)
}
@Test
fun `an explicit null reason decodes as null`() {
val doc = message(
withDocuments("""[{"name":"$name","status":"unreadable","reason":null}]"""),
).documents.single()
assertNull(doc.reason)
}
// —— chat 请求:带附件与不带附件 ——
@Test
@@ -14,7 +14,10 @@ import top.yeij.cyrene.data.api.dto.DocumentRefDto
* 1. **状态与截断不许互相顶替**——`unreadable` 才是「读不到」,`truncated` 只说明注入被预算
* 截断(正文照样进了上下文),两句文案必须分开;
* 2. **未知 `status` 原样显示**(开放集合:不丢弃、不报错、不占位符);
* 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来。
* 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来;
* 4. 服务端下发的「读不到的原因」(契约 v0.63 的 `documents[].reason`)**原样**带进模型
* (只去首尾空白;空白串归一成 `null`,因为一句空话会顶掉弹层里的通用说明),
* 且**绝不进芯片副行**——40+ 字在那里只会被切掉半句。
*/
class DocumentChipTest {
@@ -25,6 +28,7 @@ class DocumentChipTest {
chars: Int? = 8_000,
truncated: Boolean = false,
status: String = DocumentStatus.EXTRACTED,
reason: String? = null,
) = DocumentRefDto(
name = name,
mime = mime,
@@ -32,6 +36,7 @@ class DocumentChipTest {
chars = chars,
truncated = truncated,
status = status,
reason = reason,
)
// —— 类型 ——
@@ -122,6 +127,56 @@ class DocumentChipTest {
assertEquals("MD · 12.1 KB", chip.detail)
}
// —— 服务端下发的「读不到的原因」(契约 v0.63)——
@Test
fun `the server reason rides along in the model`() {
val reason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
assertEquals(reason, DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, reason = reason)).reason)
}
@Test
fun `the reason keeps its own punctuation and newlines`() {
// 「原样」就是原样:只去首尾空白,句中标点与换行一个字都不动(这条串不是给本端归纳的)。
val reason = "无法抽取正文:第一行(括注);第二行\n 带全角空格的细节"
assertEquals(
reason,
DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, reason = "\n $reason \n")).reason,
)
}
@Test
fun `a blank reason is nothing, not an empty sentence`() {
// 空串会顶掉弹层里那句通用说明,用户看到的正文区就成了空的——比没有原因更坏。
assertNull(DocumentChips.of(ref(reason = null)).reason)
assertNull(DocumentChips.of(ref(reason = "")).reason)
assertNull(DocumentChips.of(ref(reason = " ")).reason)
assertNull(DocumentChips.of(ref(reason = "\n\t\n")).reason)
}
@Test
fun `the reason never leaks into the chip line`() {
// 40+ 字塞进芯片那一行只会被 Ellipsis 切掉半句,比不显示更糟:`detail` 逐字不变。
val chip = DocumentChips.of(
ref(
chars = null,
status = DocumentStatus.UNREADABLE,
reason = "无法抽取正文:PDF 已加密(当前不支持)",
),
)
assertEquals("MD · 12.1 KB · 读不到", chip.detail)
assertTrue(chip.unreadable)
}
@Test
fun `a draft attachment has no reason at all`() {
// 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。
assertNull(DocumentChips.of(DraftDocument(uri = "content://doc/1", displayName = "报告.md")).reason)
}
// —— 副行拼接 ——
@Test
@@ -21,14 +21,19 @@ import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
* 3. 没有任何 App 能开时**如实告知**并指路保存,**不静默失败**;
* 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名;
* 5. App 内**正文**只预览文本类(v0.61 的 `.pdf` 因此不会被当 UTF-8 渲染成一屏乱码),
* 且非文本那句人话**不许读起来像失败**。
* 且非文本那句人话**不许读起来像失败**;
* 6. 服务端说「读不到」时下发的**原因原话**(契约 v0.63 的 `documents[].reason`)优先于本端
* 按类型编的那句,且**逐字**显示(没见过的原因也照原样,不做整句相等判断);它缺席时
* 产出与加入该字段之前逐字相同。
*/
class DocumentPreviewTest {
private fun ref(
name: String = "0123456789abcdef0123456789abcdef.md",
mime: String = "text/plain",
) = DocumentRefDto(name = name, mime = mime)
status: String = "",
reason: String? = null,
) = DocumentRefDto(name = name, mime = mime, status = status, reason = reason)
private fun filler(size: Int) = ByteArray(size) { 'a'.code.toByte() }
@@ -293,6 +298,84 @@ class DocumentPreviewTest {
assertTrue(messages.toString(), messages.none { it.contains("失败") || it.contains("空文件") || it.contains("是空的") })
}
// —— 服务端下发的「读不到的原因」(契约 v0.63:`documents[].reason`)——
/**
* 后端那 10 条里的一条,**逐字照抄**(真实串就是这么长)。
* 用整串相等断言是为了钉住「不改写、不截断、不归纳」——**不是**拿它当实现里的判据。
*/
private val ocrReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
@Test
fun `the server reason becomes the sentence in the body area, verbatim`() {
val plan = DocumentTarget.of(
ref(
name = "0123456789abcdef0123456789abcdef.pdf",
mime = "application/pdf",
status = DocumentStatus.UNREADABLE,
reason = ocrReason,
),
).previewPlan()
assertEquals(DocumentPreviewPlan.NotText(ocrReason), plan)
}
@Test
fun `a reason outside the ten known ones is shown as it came`() {
// 客户端**不做整句相等判断**:没见过的原因照样原样进正文区,不丢、不改、不换兜底。
val reason = "无法抽取正文:这条分类客户端从没见过(契约同版的第 11 种)"
val plan = DocumentTarget.of(ref(status = DocumentStatus.UNREADABLE, reason = reason)).previewPlan()
assertEquals(DocumentPreviewPlan.NotText(reason), plan)
}
@Test
fun `the reason is trimmed once and then shown as it is`() {
// 首尾空白由 DocumentChips 去掉(免得弹层里那句话顶着一截空行),句中标点原样。
val reason = "无法抽取正文:PDF 损坏或无法解析;请重传(原文如此)"
val plan = DocumentTarget.of(
ref(status = DocumentStatus.UNREADABLE, reason = " $reason "),
).previewPlan()
assertEquals(DocumentPreviewPlan.NotText(reason), plan)
}
@Test
fun `without a reason the body area keeps the very same sentence as before`() {
// 契约 v0.63 之前的历史消息不发 reason:这条路的产出必须与加入该字段之前逐字相同。
val plan = DocumentPreviewGate.plan("application/pdf", "0123456789abcdef0123456789abcdef.pdf")
assertEquals(DocumentPreviewPlan.NotText(DocumentPreviewGate.refusalMessage("PDF")), plan)
}
@Test
fun `a blank reason changes nothing at all`() {
val blank = DocumentPreviewGate.plan("application/pdf", "a.pdf", " ")
assertEquals(DocumentPreviewGate.plan("application/pdf", "a.pdf"), blank)
// 尤为重要的是别把它当成一句话显示出来:那会得到一块什么都没有的正文区。
assertTrue((blank as DocumentPreviewPlan.NotText).message.isNotBlank())
assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("text/plain", "a.md", "\n"))
}
@Test
fun `a text mime with a reason no longer pretends to be a body`() {
// 服务端已经说了「无法抽取正文」,本端再把字节当 UTF-8 渲染就是同一类「看着像文件坏了」。
val reason = "无法抽取正文:读取或解析失败"
assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("text/plain", "a.md", reason))
}
@Test
fun `the reason takes precedence over the extension fallback used by drafts`() {
val reason = "无法抽取正文:本期不解析 Office 文档"
assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("", "报告.pdf", reason))
assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("", "笔记.md", reason))
}
// —— 没有 App 能开 ——
@Test
@@ -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>")
}
}