From 6e006971102b1a60d542b9932d2845076f868b66 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 11:30:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=88=86=E4=BA=AB=E8=BF=9B?= =?UTF-8?q?=E6=9D=A5=E7=9A=84=E6=B6=88=E6=81=AF=E5=8A=A0=E4=B8=80=E6=9E=9A?= =?UTF-8?q?=E3=80=8C=E7=94=A8=E6=88=B7=E5=88=86=E4=BA=AB=E3=80=8D=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=B1=82=E8=A7=92=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:ACTION_SEND 分享进来的内容在会话里与手打的完全看不出区别,主人要求「可识别」。 与后端定过的红线(devdocs/02 的 F4、ShareRules 的「为什么正文一个字都不加」)要求人类可见 标记只能放在客户端渲染层,所以本次正文、content 与任何上行字段一个字节都没动。 改了什么: - ChatViewModel 的 emitAndDeliver 多一个 localIdPrefix 参数(默认 local-,手打那条路逐字不变), sendShared 的每一条投递都传 share-:多张图会发多条消息,它们都是分享进来的,所以每条都带。 前缀只加在幂等键前面,后面仍是随机 UUID,唯一性不受影响。 - MessageSource.kt 新增两个常量与两个纯函数:isSharedClientMsgId 只认 share- 且要求后面还有尾巴 (恰好 share- 一律 false:本端铸键永远是 share-uuid,没有尾巴的键不是本端铸的,不能替一条 来路不明的消息声明「这是你分享的」);isSharedMessage 在它之上加一道只认 user 侧的闸。 - MessageBubble 的 bubbleFooter 多一个 shared 参数,角标 [用户分享] 紧挨来源角标拼在角标行里, 沿用这行既有的字级与颜色,不新造样式、不用 Material3,也只对用户侧气泡出现。 - 新增 SharedMessageBadgeTest:判据的六类边界取值与角标行里的落位。 为什么用幂等键而不是正文标记:clientMsgId 由服务端原样回填,是客户端读得回来的既有字段, 「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。 --- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 35 +++++- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 33 ++++- .../top/yeij/cyrene/ui/chat/MessageSource.kt | 43 +++++++ .../cyrene/ui/chat/SharedMessageBadgeTest.kt | 117 ++++++++++++++++++ 4 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/SharedMessageBadgeTest.kt 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 eb3782b..994215b 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 @@ -1045,6 +1045,12 @@ class ChatViewModel( * 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。 * 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。 * + * ## 「分享进来的」这条事实写在幂等键上(角标的唯一判据) + * 这里给每一条投递传 `localIdPrefix = "share-"`,于是铸出来的键形如 `share-`, + * 服务端原样回填、客户端读回来后由 [isSharedMessage] 判出「这条是分享进来的」,渲染层据此画那枚 + * [SHARED_MESSAGE_BADGE]。**正文一个字都不动**——往正文里拼「[用户分享]」是与后端定过的红线 + * ([ShareRules] 的「为什么正文一个字都不加」/ `02` 的 F4)。 + * * 三处刻意的取舍: * - **不动草稿**:分享不是用户按的发送键,不能把他正在输入的内容顶掉; * - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟; @@ -1106,9 +1112,25 @@ class ChatViewModel( showShareNotice(SHARE_TEXT_NOT_STAGED) } } - emitAndDeliver(text, share.images.firstOrNull(), files, clearDraft = false, applySendPreference = false) + // 每一条都带 `share-` 前缀(多张图 = 多条消息,它们**都是**分享进来的): + // 角标由此在渲染层长出来,正文照旧一字未改。 + emitAndDeliver( + text, + share.images.firstOrNull(), + files, + clearDraft = false, + applySendPreference = false, + localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX, + ) share.images.drop(1).forEach { - emitAndDeliver("", it, emptyList(), clearDraft = false, applySendPreference = false) + emitAndDeliver( + "", + it, + emptyList(), + clearDraft = false, + applySendPreference = false, + localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX, + ) } } } @@ -1156,6 +1178,12 @@ class ChatViewModel( * @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明: * `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;只有 [sendShared] * 传 false——它是批量动作,批量里逐条声明只会自相干扰(两种声明各自的理由见 [sendShared])。 + * @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认), + * [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在 + * 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动** + * (正文里加标记是与后端定过的红线,见 `ShareRules` 的「为什么正文一个字都不加」/ `02` 的 F4)。 + * 前缀只加在前面,后面仍是随机 UUID,幂等键的唯一性不受影响;键被 [retryKey] 复用时角标跟着 + * 那次投递走,与「复用键 = 同一条消息」的语义一致。 */ private suspend fun emitAndDeliver( text: String, @@ -1163,6 +1191,7 @@ class ChatViewModel( documents: List, clearDraft: Boolean, applySendPreference: Boolean = true, + localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX, ) { if (text.isEmpty() && imageUri == null && documents.isEmpty()) return @@ -1177,7 +1206,7 @@ class ChatViewModel( MessageFingerprint.of(text, imageUri, documents), System.currentTimeMillis(), ) { - "local-${UUID.randomUUID()}" + "$localIdPrefix${UUID.randomUUID()}" } retryKey = decision.memory val localId = decision.localId diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index 7da24a1..32a9afa 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -169,7 +169,12 @@ fun MessageBubble( MessageGroup( isUser = isUser, - footer = bubbleFooter(messageSourceLabel(message, LocalEnumCatalog.current), phase, legBadge), + footer = bubbleFooter( + sourceLabel = messageSourceLabel(message, LocalEnumCatalog.current), + phase = phase, + legBadge = legBadge, + shared = isSharedMessage(message), + ), onOpenMenu = openMenu, ) { message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) } @@ -214,11 +219,22 @@ fun MessageBubble( } } +/** + * 「这条是分享进来的」那枚角标。 + * + * 它只是**一枚脚注**,与来源角标同一行、同一套字级与颜色(`footnote2` + + * `onSurfaceVariantSummary`),刻意不做成可点的小药丸——[LegBadge] 那种样式代表「这里有个入口」, + * 而这一枚只是说明,点它无事发生。 + */ +const val SHARED_MESSAGE_BADGE = "[用户分享]" + /** * 气泡下方那一行角标的文案(纯函数,可单测)。 * - * 三段内容各自独立,用「·」连起来:**并行路小标**(多路时才在)→ **六态说明**(只有 ③④ 成句) - * → **来源角标**(既有规则,见 [messageSourceLabel])。 + * 四段内容各自独立,用「·」连起来:**并行路小标**(多路时才在)→ **六态说明**(只有 ③④ 成句) + * → **分享角标**([SHARED_MESSAGE_BADGE],只有分享进来的那条才在)→ **来源角标**(既有规则, + * 见 [messageSourceLabel])。分享角标紧挨来源角标:两者回答的都是「这条从哪来」, + * 挨着放读起来是一件事的两面(「你分享进来的 · 来自 安卓」)。 * * 为什么 ③④ 要在这里说话:它们**都不是错误、也都不是「已送达」**,而气泡本身在两种态下都长得像 * 一条正常消息。不说一句,用户就分不清「她还没开始理我」与「已经在回答我了」—— @@ -227,8 +243,16 @@ fun MessageBubble( * [sourceLabel] 由调用方从组合里取(`messageSourceLabel(message, LocalEnumCatalog.current)`): * 那个 composition local **只能在可组合函数里读**,所以这里收进来当参数,好让本函数保持纯粹、 * 能被单测直接钉死。 + * + * [shared] 同样由调用方判([isSharedMessage]——它已经含了「只认用户侧」那道 role 闸)。 + * 判据不在这函数里,是为了让它继续只做「把几个既成事实拼成一句话」这一件事。 */ -fun bubbleFooter(sourceLabel: String?, phase: TurnPhase, legBadge: String?): String? { +fun bubbleFooter( + sourceLabel: String?, + phase: TurnPhase, + legBadge: String?, + shared: Boolean = false, +): String? { val parts = buildList { legBadge?.let { add(it) } when (phase) { @@ -236,6 +260,7 @@ fun bubbleFooter(sourceLabel: String?, phase: TurnPhase, legBadge: String?): Str TurnPhase.Answering -> add("已读,正在回答…") else -> Unit } + if (shared) add(SHARED_MESSAGE_BADGE) sourceLabel?.let { add(it) } } return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt index ce3c07a..9c66681 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt @@ -3,6 +3,49 @@ package top.yeij.cyrene.ui.chat import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.domain.enums.EnumCatalog +/** + * 手打消息的幂等键前缀:本端铸出来的键形如 `local-`。 + * + * 铸键处与判据处共用这一个常量([ChatViewModel] 铸、[isSharedClientMsgId] 判),改一处即两处同步。 + */ +const val LOCAL_CLIENT_MSG_ID_PREFIX = "local-" + +/** + * 分享进来的消息的幂等键前缀:`share-`。 + * + * 加前缀**不破坏唯一性**——后面那截仍是随机 UUID,而 `clientMsgId` 本来就只要求「同一会话内非空且唯一」 + * (契约 v0.56 §3.13),前缀只是把「这条是怎么进来的」顺带写进了这个已经存在的字段。 + */ +const val SHARE_CLIENT_MSG_ID_PREFIX = "share-" + +/** + * 一串 `clientMsgId` 是不是分享进来的那条。 + * + * 判据**只有前缀**这一个来源:`clientMsgId` 由服务端**原样回填**(见 `HistoryMessageDto.clientMsgId`), + * 于是「怎么进来的」这件事能从幂等键上读回来,不必往用户正文里拼任何标记——正文里加标记是 + * 与后端定过的红线(`ShareRules` 的「为什么正文一个字都不加」/ `02` 的 F4)。 + * + * 边界取值(都按「宁可少画一枚角标,也不冒充事实」定): + * - `null` / 空串 / 纯空白 ⇒ false:没有键就是没有证据,不猜; + * - `local-…`(手打的那条路)⇒ false; + * - 恰好 `"share-"`(前缀后面没有尾巴)⇒ **false**:本端铸键永远是 `share-`, + * 尾巴为空的键不可能是本端铸出来的,给它画角标等于替一条来路不明的消息声明「这是你分享的」; + * - 大小写敏感、不做 trim:键是机器生成的字面量、服务端原样回填,不是给人读的输入。 + */ +fun isSharedClientMsgId(clientMsgId: String?): Boolean = + clientMsgId?.startsWith(SHARE_CLIENT_MSG_ID_PREFIX) == true && + clientMsgId.removePrefix(SHARE_CLIENT_MSG_ID_PREFIX).isNotBlank() + +/** + * 这条消息是不是本端「分享进来」的那条(气泡角标的判据,前缀规则见 [isSharedClientMsgId])。 + * + * 在判据之上多一道 role 闸:这枚角标说的是「**你**把外面的东西分享给了她」,只有用户侧的气泡才可能 + * 成立。助手那一边不会有这种键,但闸摆在这里更好——万一日后有一行的 role 与键对不上, + * 宁可少画一枚角标,也不要在她的气泡上写「用户分享」。 + */ +fun isSharedMessage(message: HistoryMessageDto): Boolean = + message.role == "user" && isSharedClientMsgId(message.clientMsgId) + /** * 来源角标(S1.3):每条消息默认显示来源。 * - 主人发的 → 「来自 X」(从哪一端发出) diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SharedMessageBadgeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SharedMessageBadgeTest.kt new file mode 100644 index 0000000..51d5a50 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SharedMessageBadgeTest.kt @@ -0,0 +1,117 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.domain.chat.TurnPhase + +/** + * 分享进来的消息那枚角标:判据([isSharedClientMsgId] / [isSharedMessage])与落位([bubbleFooter])。 + * + * **背景**:`ACTION_SEND` 分享进来的消息在今天的历史里与手打的一模一样,主人要求「可识别」。 + * 识别的依据只能是幂等键 `share-`(服务端原样回填),**不是**正文里的任何标记—— + * 往正文里拼「[用户分享]」是与后端定过的红线(`devdocs/02` 的 F4)。 + * + * 这个测试要钉住两件事: + * 1. 判据只认本端铸出来的那种键(`share-` 后面还有尾巴),其余一律不认; + * 2. 角标只出现在该出现的地方:用户侧、且拼进角标行时位置固定(挨着来源角标)。 + */ +class SharedMessageBadgeTest { + + private fun message(role: String = "user", clientMsgId: String? = null) = HistoryMessageDto( + id = "m1", + sessionId = "web", + role = role, + content = "分享过来的正文", + clientMsgId = clientMsgId, + createdAt = 1_791_088_571_785L, + ) + + // —— 判据:认的只有本端铸出来的那种键 —— + + @Test + fun `a share prefixed key is recognised`() { + assertTrue(isSharedClientMsgId("share-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41")) + } + + @Test + fun `a locally minted key is not a share`() { + // 手打那条路铸的是 `local-`,它一个字都不变(前缀只是加在前面,后半仍是同一个 UUID)。 + assertFalse(isSharedClientMsgId("local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41")) + } + + @Test + fun `an absent key is not a share`() { + // 没有键 = 没有证据,不猜(老客户端发的、IM 镜像进来的、v0.56 之前的历史行都是 null)。 + assertFalse(isSharedClientMsgId(null)) + } + + @Test + fun `blank keys are not a share`() { + assertFalse(isSharedClientMsgId("")) + assertFalse(isSharedClientMsgId(" ")) + } + + @Test + fun `a bare prefix without a uuid tail is not a share`() { + // 只有前缀、没有尾巴的键**不是**本端铸出来的(本端永远是 `share-`), + // 认下它等于替一条来路不明的消息声明「这是你分享的」——宁可少画一枚角标。 + assertFalse(isSharedClientMsgId(SHARE_CLIENT_MSG_ID_PREFIX)) + assertFalse(isSharedClientMsgId("share- ")) + } + + @Test + fun `the prefix must be the very beginning and case sensitive`() { + // 键是机器生成的字面量、服务端原样回填,不是给人读的输入:不 trim、不忽略大小写。 + assertFalse(isSharedClientMsgId(" share-abc")) + assertFalse(isSharedClientMsgId("Share-abc")) + assertFalse(isSharedClientMsgId("xshare-abc")) + // 前缀出现在中间也不算:判据是 startsWith。 + assertFalse(isSharedClientMsgId("local-share-abc")) + } + + // —— 判据之上还有一道「只认用户侧」的闸 —— + + @Test + fun `only user side messages get the badge`() { + assertTrue(isSharedMessage(message(clientMsgId = "share-abc"))) + // 助手的消息不会有这种键;万一有一行的 role 与键对不上,也不在她的气泡上写「用户分享」。 + assertFalse(isSharedMessage(message(role = "assistant", clientMsgId = "share-abc"))) + assertFalse(isSharedMessage(message(role = "system", clientMsgId = "share-abc"))) + assertFalse(isSharedMessage(message(clientMsgId = "local-abc"))) + assertFalse(isSharedMessage(message())) + } + + // —— 落位:拼进角标行,紧挨来源角标 —— + + @Test + fun `the badge sits next to the source label`() { + assertEquals("[用户分享] · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Sent, null, shared = true)) + } + + @Test + fun `without the flag the footer is byte for byte what it was`() { + assertEquals("来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Sent, null)) + assertEquals("排队中 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Queued, null, shared = false)) + } + + @Test + fun `the badge survives a message with no source at all`() { + // 来源判不出来(老服务端 / 表里没有的端)时角标照样在:它说的是「怎么进来的」, + // 与「从哪一端」是两件事,不该被后者拖着一起消失。 + assertEquals("[用户分享]", bubbleFooter(null, TurnPhase.Sent, null, shared = true)) + } + + @Test + fun `parallel leg and phase keep their places around the badge`() { + assertEquals( + "并行路 2 · 排队中 · [用户分享] · 来自 安卓", + bubbleFooter("来自 安卓", TurnPhase.Queued, "并行路 2", shared = true), + ) + // 一条既没有来源也没有分享角标、且不在 ③④ 的消息,仍然什么都不显示。 + assertNull(bubbleFooter(null, TurnPhase.Answered, null)) + } +}