W72 纯附件消息也有气泡外侧时间

时间原本挂在「这条消息**最后一个有正文的气泡**」外侧(W69),而纯附件消息(`documents`
或 `image` 非空、`content` 为空)解析出来的那个占位气泡 `body` 也是空,`Bubble` 见空
`body` 什么都不画 ⇒ 那条时间**整条没被画出来**(主人 2026-10-07 验收 W69 时发现的缺口)。

- 判据抽成纯函数 `messageTimeCarrier(message, bubbles)`:先找最后一个有正文的气泡(W69
  原本那一档,一个字都不改);一个都没有时按「谁最后画」往回退(语音形态 → 图片 →
  附件芯片排),落点用 `MessageTimeCarrier` 表示。真的空气泡(`isContentlessUserMessage`)
  仍由 `ContentlessNote` 那条路自己画时间,与 W69 一致,未改。
- 附件芯片排 / 图片 / 语音形态三块改为经 `BubbleWithTime` 渲染:**不是落点时 `time = null`,
  连 `Box` 都不建** ⇒ 与 W69 之前逐像素相同。贴哪一侧、宽度预扣(时间不参与 weight)、
  底对齐全部复用 W69 那一个组件,没有第二份布局逻辑。
- 新增 `MessageTimeCarrierTest` 11 条:纯附件 / 纯图片 / 无转写语音 / 角色无关 /
  多块时的优先序 / 有正文时 W69 口径不变 / 真空消息返回 null;`MessageTimeLabelTest`
  16 条一条未动。
- 假后端补夹具:`/__control/messages` 的 `text=`(**空串** = 这条没有正文)与 `documents=`
  (`extracted` / `listed` / `unreadable`)。此前「纯附件消息」在本机**根本造不出来**
  ——工具不实现 upload 摄取链路,`PATCH` 又要求 `content` 非空——设备验收因此无从做起。
  README 补 §8.3 与端点表。

验收:`assembleDebug` 通过;`testDebugUnitTest` **129 suites / 1636 tests / 0 failures /
0 errors**(基线 128 / 1625)。
This commit is contained in:
AskaEth committed 2026-10-07 19:16:25 +08:00
1 parent f1e894e71f
commit a94e310cd7
4 files changed
+436 -27

No files matched your search

@@ -148,6 +148,12 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 它是与气泡下方那行角标(来源 / 六态 / 「⋯」)**并列的另一块**:那块说的是「这条从哪来、能对它
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
*
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音形态)
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
* (W72 补的就是它)。
*
* 只有落库消息有它:流式气泡([StreamingBubble])与待发气泡([PendingBubble],[PendingMessage]
* 本端就没有时间戳这个概念)走的都是「不显示」那一档。
*
@@ -225,9 +231,11 @@ fun MessageBubble(
createdAt = message.createdAt,
nowMillis = System.currentTimeMillis(),
)
// 时间挂在**最后一个有正文的气泡**上(而不是「列表最后一项」):解析出的末段偶尔只有旁白
// (`<action>`/`<narration>`),那一项不画正文框,挂上去时间就没有落脚点。
val timeCarrier = bubbles.indexOfLast { it.body.isNotEmpty() }
// 时间挂在哪一块:判据全在纯函数里([messageTimeCarrier],可单测)。原来是「最后一个有正文的
// 气泡」(而不是「列表最后一项」——解析出的末段偶尔只有旁白,那一项不画正文框,挂上去时间就
// 没有落脚点),于是**纯附件消息**(有 documents 或 image、`content` 为空 ⇒ 一个正文气泡都没有)
// 那一行根本没被画出来。
val timeCarrier = messageTimeCarrier(message, bubbles)
MessageGroup(
isUser = isUser,
@@ -240,25 +248,49 @@ fun MessageBubble(
onOpenMenu = openMenu,
) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
DocumentChipRow(
chips = message.documents.map(DocumentChips::of),
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
// 气泡都没有时**,它们中的一个就是时间的落脚点。不是那一个时 `time = null`,
// [BubbleWithTime] 连 `Box` 都不建 ⇒ 与 W69 之前逐像素相同。
if (message.documents.isNotEmpty()) {
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Attachments },
isUser = isUser,
) { side ->
DocumentChipRow(
chips = message.documents.map(DocumentChips::of),
modifier = side,
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
}
}
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
?.let { ChatImage(it, onImageClick) }
?.let { image ->
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Image },
isUser = isUser,
) { side ->
ChatImage(model = image, onClick = onImageClick, modifier = side)
}
}
message.voice?.let { voice ->
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
VoiceMessageRow(
voice = voice,
messageId = message.id,
playback = voicePlayback,
note = voiceNote(voice.status, message.content),
alignEnd = isUser,
onToggle = { onVoiceToggle(message) },
)
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
isUser = isUser,
) { side ->
VoiceMessageRow(
voice = voice,
messageId = message.id,
playback = voicePlayback,
note = voiceNote(voice.status, message.content),
alignEnd = isUser,
onToggle = { onVoiceToggle(message) },
modifier = side,
)
}
}
if (isContentlessUserMessage(message)) {
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
@@ -266,6 +298,9 @@ fun MessageBubble(
ContentlessNote(onLongPress = openMenu, modifier = side)
}
} else {
// 时间挂在**最后一个有正文的气泡**外侧。`null` = 这条的落点在附件块上(或这条一个字都不画),
// 此时正文气泡一个都不带时间——时间挂在这条消息上一次,不跟着每个气泡重复。
val carrierIndex = (timeCarrier as? MessageTimeCarrier.TextBubble)?.index
bubbles.forEachIndexed { index, bubble ->
Bubble(
bubble = bubble,
@@ -274,9 +309,7 @@ fun MessageBubble(
showCursor = gray,
onLongPress = openMenu,
gray = gray,
// 时间挂在**最后一个有正文的气泡**外侧(`-1` = 这条一个都没有,不可能发生,
// 但真发生也只是不画时间,不会把时间画到空气泡旁边去)。
sideTime = if (index == timeCarrier) outsideTime else null,
sideTime = outsideTime.takeIf { index == carrierIndex },
)
}
}
@@ -449,6 +482,75 @@ private val MessageDateOnlyFormat = DateTimeFormatter.ofPattern("yyyy'年'M'月'
/** 「昨天」那两个字:只与时分连用(`昨天 09:05`),不会单独成句。 */
private const val YESTERDAY_PREFIX = "昨天"
/**
* 这条消息的时间**落在哪一块**上([messageTimeCarrier] 的返回值)。
*
* 之所以要给它一个名字,是因为「时间挂在正文气泡上」与「时间挂在附件块上」在 [MessageBubble] 里
* 是**两处不同的调用点**(前者由 [Bubble] 经 `sideTime` 代画,后者各自包一层 [BubbleWithTime]),
* 而某条消息只能落一个地方。判据本身是纯函数,所以「纯附件消息也有时间」这件事能被单测直接钉住,
* 不必去跑一个 Compose 测试。
*/
sealed interface MessageTimeCarrier {
/** 正文气泡:`bubbles` 里的下标([MessageBubble] 解析出来的第几个气泡)。 */
data class TextBubble(val index: Int) : MessageTimeCarrier
/** 附件芯片排(`documents` 非空才会画)。 */
data object Attachments : MessageTimeCarrier
/** 图片(`image` 非空才会画)。 */
data object Image : MessageTimeCarrier
/** 语音形态(`voice` 键在才会画)。 */
data object Voice : MessageTimeCarrier
}
/**
* 这条消息外侧那枚时间该挂在哪一块上(纯函数,可单测);`null` = 什么都不挂。
*
* ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西
*
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音形态 → 正文气泡。
* 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。
*
* 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音形态 → 图片 → 附件芯片排。这一档就是
* **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡
* `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来**
* (主人 2026-10-07 验收时发现的缺口)。
* 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。
*
* ## 谁**不**走这一档
* - **真的空气泡**([isContentlessUserMessage] 成立):上面三块按判据一定都不在,所以这里返回
* `null`;但那种消息**仍然有时间**——[MessageBubble] 把 [ContentlessNote] 自己包了一层
* [BubbleWithTime]。两处刻意分开:那块的落脚点由 `ContentlessNote` 那条路自己交代,不必让这个
* 函数认识它(它只答「附件块和正文气泡里挂哪个」)。
* - **思维链卡片**:它永远画在最上面,不可能是「最后一块」(有别的块时被别的块盖过,没别的块时
* 这条消息的真问题也不是时间)。不把它算进来 ⇒ 只有思维链的助手消息与 W69 之前逐字相同。
* - **流式 / 待发气泡**:它们根本不调这个函数(见 [BubbleWithTime] 的 KDoc)。
*
* 与角色无关:她侧(`assistant`)也会带附件与图,同一条规则照用。贴哪一侧由 [BubbleWithTime]
* 按 `isUser` 定,不在这里判——这一层只回答「挂在哪一块」,不回答「挂在它的哪一边」。
*
* @param bubbles [MessageBubble] 已经解析好的气泡列表(本函数不去 `parse` 一遍:那一份判据在
* `isChat` 上,重复算一次会多出「两份解析结果不一致」的可能)。
*/
fun messageTimeCarrier(
message: HistoryMessageDto,
bubbles: List<ParsedBubble>,
): MessageTimeCarrier? {
val lastBody = bubbles.indexOfLast { it.body.isNotEmpty() }
if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody)
// 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。
return when {
message.voice != null -> MessageTimeCarrier.Voice
!message.image.isNullOrBlank() -> MessageTimeCarrier.Image
message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments
else -> null
}
}
/**
* 乐观发送中的用户消息(还没落库)。
*
@@ -768,8 +870,10 @@ private fun BubbleWithTime(
/**
* 一条消息在气泡层渲染成一个 / 多个气泡。
*
* [sideTime] 只由**携带时间的那个气泡**(最后一个有正文的,见 [MessageBubble])收到,
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。
* [sideTime] 只由**携带时间的那个气泡**收到([MessageTimeCarrier.TextBubble] 指的那一个),
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。`body` 为空的气泡
* (比如纯附件消息解析出来的那个占位气泡)一个像素都不画,**连 [BubbleWithTime] 都不进**:
* 这也是纯附件消息会整条没有时间的原因(判据见 [messageTimeCarrier])。
*/
@Composable
private fun Bubble(
@@ -1035,13 +1139,17 @@ private fun inlineBody(
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
*
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
* 它排在这条链的**最前面**:那一层留白在 `widthIn` / `clip` / `clickable` 之外,所以图片自己的
* 尺寸上限、圆角与点击区都与加它之前逐像素 / 逐行为相同(与 [BubbleBody] 同一条纪律)。
*/
@Composable
private fun ChatImage(model: String, onClick: (String) -> Unit) {
private fun ChatImage(model: String, onClick: (String) -> Unit, modifier: Modifier = Modifier) {
AsyncImage(
model = model,
contentDescription = "查看图片",
modifier = Modifier
modifier = modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp))
.clickable { onClick(model) },
@@ -0,0 +1,202 @@
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.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.VoiceDto
import top.yeij.cyrene.domain.parse.ParsedBubble
/**
* 「这条消息外侧那枚时间挂在哪一块上」的判据(纯函数 [messageTimeCarrier])。
*
* **背景**:主人 2026-10-07 验收 W69 时报的缺口 —— 纯文字消息都有时间,**纯附件消息边上没有**。
* 根因在 W69 的落脚点写法上:时间挂在「这条消息**最后一个有正文的气泡**」外侧,而纯附件消息
* (`documents` 非空 / `image` 非空、`content` 为空)解析出来的唯一那个气泡 `body` 也是空,
* [Bubble] 见空 `body` 什么都不画 ⇒ 那一行**根本没被画出来**。
*
* 这个测试要钉住三件事:
* 1. **一个正文气泡都没有时,时间落到最后画出来的那个附件块上**(附件芯片排 / 图片 / 语音形态),
* 而且顺序与渲染顺序一致(谁最后画谁拿)——这就是「纯附件消息也有时间」;
* 2. **有正文气泡时一个字都不许变**(W69 的口径:最后一个有正文的气泡,末段的纯旁白气泡不算);
* 3. **真的空气泡**([isContentlessUserMessage])在这里返回 `null`,但那种消息**仍然有时间**
* —— 那一处由 [MessageBubble] 给 [ContentlessNote] 单独包一层 [BubbleWithTime],是另一条路。
*
* 「贴左还是贴右」不在这里测:那是 [BubbleWithTime] 按 `isUser` 定的,本函数只回答「挂在哪一块」。
*/
class MessageTimeCarrierTest {
private fun message(
role: String = "user",
content: String = "",
image: String? = null,
documents: List<DocumentRefDto> = emptyList(),
thinking: String? = null,
voice: VoiceDto? = null,
createdAt: Long = 1_791_088_571_785L,
) = HistoryMessageDto(
id = "m1",
sessionId = "web",
role = role,
content = content,
image = image,
documents = documents,
thinking = thinking,
voice = voice,
createdAt = createdAt,
)
private val document = DocumentRefDto(
name = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt",
mime = "text/plain",
size = 10,
chars = 10,
status = "extracted",
)
/** 解析出来的一个正文气泡(`plainBubble` 那份形状)。 */
private fun body(text: String) = ParsedBubble(
body = text,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
)
/**
* 纯附件消息的 `bubbles`:`content` 为空 ⇒ 解析器一个都没给 ⇒ `MessageBubble` 兜一个占位气泡
* (`plainBubble("")`)。它 `body` 为空,所以 [Bubble] 一个像素都不画 —— 缺口就在这一步。
*/
private val placeholderBubbles = listOf(body(""))
private val voiceWithoutTranscript = VoiceDto(source = "asr", status = "empty")
// —— 一个正文气泡都没有:落到最后画出来的那个附件块上 ——
@Test
fun `an attachments only message hangs the time on the chip row`() {
val message = message(content = "", documents = listOf(document))
assertEquals(
MessageTimeCarrier.Attachments,
messageTimeCarrier(message, placeholderBubbles),
)
}
@Test
fun `an image only message hangs the time on the image`() {
val message = message(content = "", image = "http://host/api/v1/files/a.png")
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(message, placeholderBubbles))
}
@Test
fun `a voice message with nothing transcribed hangs the time on the voice row`() {
// `status = empty` ⇒ 正文为空,而这条消息**不是什么都没有**(`isContentlessUserMessage`
// 特意把语音排除掉)。它的可视块只有语音形态,时间就该落在那里。
val message = message(content = "", voice = voiceWithoutTranscript)
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(message, placeholderBubbles))
assertFalse(isContentlessUserMessage(message))
}
@Test
fun `when several blocks are there the last one drawn wins`() {
// 渲染顺序是「附件芯片排 → 图片 → 语音形态」,所以时间的落点也按这个顺序往回退。
val chipsAndImage = message(
content = "",
documents = listOf(document),
image = "http://host/api/v1/files/a.png",
)
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(chipsAndImage, placeholderBubbles))
val allThree = message(
content = "",
documents = listOf(document),
image = "http://host/api/v1/files/a.png",
voice = voiceWithoutTranscript,
)
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(allThree, placeholderBubbles))
}
@Test
fun `her attachments get the time too`() {
// 判据与角色无关:她也会发只有图 / 只有附件的一条。
val message = message(role = "assistant", content = "", documents = listOf(document))
assertEquals(
MessageTimeCarrier.Attachments,
messageTimeCarrier(message, placeholderBubbles),
)
}
// —— 有正文气泡时:W69 的口径一个字都不变 ——
@Test
fun `a text bubble always wins over the blocks above it`() {
val message = message(
content = "甲",
documents = listOf(document),
image = "http://host/api/v1/files/a.png",
)
assertEquals(MessageTimeCarrier.TextBubble(0), messageTimeCarrier(message, listOf(body("甲"))))
}
@Test
fun `the last non empty bubble carries the time`() {
val bubbles = listOf(body("甲"), body("乙"))
assertEquals(
MessageTimeCarrier.TextBubble(1),
messageTimeCarrier(message(content = "甲<p>乙</p>"), bubbles),
)
}
@Test
fun `a trailing annotation only bubble never carries the time`() {
// 末段只有旁白(`<action>`/`<narration>`)时不画正文框:时间退回上一个有正文的气泡。
val bubbles = listOf(body("甲"), body(""))
assertEquals(
MessageTimeCarrier.TextBubble(0),
messageTimeCarrier(message(content = "甲<action>她抬头</action>"), bubbles),
)
}
// —— 真的什么都没有:这里返回 null,时间由 ContentlessNote 那条路自己画 ——
@Test
fun `a truly empty user message has no block to hang the time on`() {
val message = message(content = " ")
// 判据与 `isContentlessUserMessage` 是互补的:那条路**仍然**有时间(`BubbleWithTime`
// 包着 `ContentlessNote`),所以这里返回 null 不等于「这条消息没有时间」。
assertTrue(isContentlessUserMessage(message))
assertNull(messageTimeCarrier(message, placeholderBubbles))
}
@Test
fun `blank image strings and empty documents are not blocks`() {
val message = message(content = "", image = " ", documents = emptyList())
assertNull(messageTimeCarrier(message, placeholderBubbles))
}
// —— 与时间戳缺失的关系:两件事分开判 ——
@Test
fun `the carrier ignores the timestamp and the label ignores the carrier`() {
// 「`createdAt <= 0` ⇒ 整条不画」是 [messageTimeLabel] 那一档的事:落点照旧算出来,
// 只是文案为 null ⇒ `BubbleWithTime` 连 `Box` 都不建。两处刻意分开判,谁都不替谁兜底。
val message = message(content = "", documents = listOf(document), createdAt = 0L)
assertEquals(
MessageTimeCarrier.Attachments,
messageTimeCarrier(message, placeholderBubbles),
)
assertNull(messageTimeLabel(createdAt = 0L, nowMillis = 1_791_088_571_785L))
}
}