feat(chat): 分享进来的消息加一枚「用户分享」渲染层角标

背景: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 由服务端原样回填,是客户端读得回来的既有字段,
「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。
This commit is contained in:
AskaEth committed 2026-10-05 11:30:56 +08:00
1 parent 5d4888ab71
commit 6e00697110
4 files changed
+221 -7

No files matched your search

@@ -1045,6 +1045,12 @@ class ChatViewModel(
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
*
* ## 「分享进来的」这条事实写在幂等键上(角标的唯一判据)
* 这里给每一条投递传 `localIdPrefix = "share-"`,于是铸出来的键形如 `share-<uuid>`,
* 服务端原样回填、客户端读回来后由 [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<DraftDocument>,
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
@@ -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(" · ")
@@ -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-<uuid>`。
*
* 铸键处与判据处共用这一个常量([ChatViewModel] 铸、[isSharedClientMsgId] 判),改一处即两处同步。
*/
const val LOCAL_CLIENT_MSG_ID_PREFIX = "local-"
/**
* 分享进来的消息的幂等键前缀:`share-<uuid>`。
*
* 加前缀**不破坏唯一性**——后面那截仍是随机 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-<uuid>`,
* 尾巴为空的键不可能是本端铸出来的,给它画角标等于替一条来路不明的消息声明「这是你分享的」;
* - 大小写敏感、不做 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」(从哪一端发出)