Compare commits
2
Commits
f1e894e71f
...
c1ebd208b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ebd208b1 | ||
|
|
a94e310cd7 |
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))
|
||||
}
|
||||
}
|
||||
@@ -389,6 +389,52 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%
|
||||
> 要让「服务端历史」成为唯一真相,得清 App 数据重登(`adb shell pm clear top.yeij.cyrene`)——
|
||||
> 这一步会连登录态一起清掉,动手前先想清楚。
|
||||
|
||||
### 8.3 纯附件 / 无正文消息(`documents=` + `text=` 留空)
|
||||
|
||||
**它解决什么问题**:**纯附件消息**(`documents` 非空、`content` 为空)这条形状以前**灌不出来** ——
|
||||
本工具不实现 upload / 摄取链路(§11),`PATCH /api/v1/messages/{id}` 又要求 `content` 非空。
|
||||
于是「没有正文的消息」这一类(纯附件 / 纯图片 / 没转出字的语音)在本机一条都造不出,
|
||||
W72 修的那条「气泡外侧的时间」也就无从验收。
|
||||
|
||||
两个参数一起用就能造出来:
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|---|---|---|
|
||||
| `text` | **空串**(`text=`) | 与「不带 `text`」是**两件事**:不带 ⇒ 沿用默认正文 `seed #N`;`text=` ⇒ 这条 `content` 就是 `""`。带非空值时照旧(支持 `{n}` 占位) |
|
||||
| `documents` | `extracted` / `listed` / `unreadable` / `none`(默认) | 往这条消息的 `documents` 键打**一枚**芯片。三档见下(`none` = 不打这个键,后端本来就是空即省略) |
|
||||
|
||||
| `documents=` | `status` | 设备上那枚芯片(文案出处 `DocumentChips.statusLabel`) |
|
||||
|---|---|---|
|
||||
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
|
||||
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
|
||||
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`;`reason` 那句原话留给附件预览弹层 |
|
||||
|
||||
```bash
|
||||
B=http://127.0.0.1:18080
|
||||
|
||||
# 1) 纯附件消息:content 为空 + 一枚附件芯片
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=extracted"
|
||||
|
||||
# 2) 纯图片消息:同一个招牌的另一半(content 为空,只有 image 键)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&image_file=D:/tmp/w70/with_exif.jpg"
|
||||
|
||||
# 3) 没转出字的语音(正文为空、只有语音形态)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&voice=empty"
|
||||
|
||||
# 4) 确认那条真的没有正文、也没被塞一个默认正文
|
||||
curl -s "$B/api/v1/sessions/web/messages?limit=1"
|
||||
|
||||
# 5) 对照:真的空气泡(三样都没有)——它走的是 ContentlessNote 那条路,不是本夹具的重点
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=none"
|
||||
```
|
||||
|
||||
灌完之后**重进聊天页**(或 `POST /__control/ws-close` 踢一次重连触发补拉)就能看到。
|
||||
`documents` 与 `voice` 不同:真后端**也会**在握手 `history` 回放与 `message_persisted` 里带上它
|
||||
(同一个 DTO,`MessageDto.kt:13` 那一段),所以这里回放出来没有「比真后端更容易」的问题。
|
||||
|
||||
> ⚠️ 这两个参数**只是往消息上打那两个键**,不是摄取链路:文件没有真的被读、正文没有真的进上下文,
|
||||
> 所以附件预览弹层里「服务端读到的那段正文」在本机永远是空的。
|
||||
|
||||
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
|
||||
|
||||
| 端点 | 备注 |
|
||||
@@ -412,7 +458,7 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%
|
||||
|
||||
| 端点 | 作用 |
|
||||
|---|---|
|
||||
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=` | 免费灌 N 条(`text` 支持 `{n}` 占位);`voice=` 灌一条**语音消息**(见 8.1);`image_file=` 灌一条**带图的消息**(见 8.2) |
|
||||
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=&documents=` | 免费灌 N 条(`text` 支持 `{n}` 占位;**`text=` 空串 = 这条没有正文**);`voice=` 灌一条**语音消息**(见 8.1);`image_file=` 灌一条**带图的消息**(见 8.2);`documents=` 灌一条**带附件的消息**(见 8.3) |
|
||||
| `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
|
||||
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
|
||||
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
|
||||
@@ -467,7 +513,9 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%
|
||||
- **`platform` 过滤**是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传 `null`
|
||||
(`MessageRepository.kt:42,99`),所以这条路径没被真正走过。
|
||||
- **`q` 搜索**是 ASCII 大小写不敏感的子串匹配;`%` / `_` / `\` 不做通配(我根本不解析通配符)。
|
||||
- **不实现**:admin 面全部端点、`documents` / `upload` 的**摄取链路**、`voice_input` 的落库、
|
||||
- **不实现**:admin 面全部端点、`documents` / `upload` 的**摄取链路**(`POST /upload` 与「文件真的被读、
|
||||
正文真的进上下文」那一段;`/__control/messages` 的 `documents=` **只是往消息上打那个键**,见 8.3)、
|
||||
`voice_input` 的落库、
|
||||
`mode_switch` 的语义、`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧
|
||||
**只记录、不处置**。因此 App 里管理员页面、附件上传这类路径在这个假后端上跑不通。
|
||||
⚠️ `GET /api/v1/media/{name}` **实现了**(语音播放那一半,见 8.1),但**不校验 Bearer**
|
||||
|
||||
@@ -223,6 +223,49 @@ def image_fixture(path: str) -> str:
|
||||
return "data:{};base64,{}".format(mime, base64.b64encode(raw).decode("ascii"))
|
||||
|
||||
|
||||
# —— 附件夹具(`documents` DTO 键)———————————————————————————————
|
||||
#
|
||||
# 为什么要造它:**纯附件消息**(`documents` 非空、`content` 为空)是「气泡外侧那枚时间」新补的那一档,
|
||||
# 而它在库里**没有别的造法** —— 本工具不实现 upload / 摄取链路(见 README §11),
|
||||
# `PATCH /api/v1/messages/{id}` 又要求 `content` 非空 ⇒ 一条这样的消息以前根本灌不出来。
|
||||
#
|
||||
# 形状按客户端解出来的写(`HistoryMessageDto.documents` → `DocumentRefDto.kt:67`)。与真后端的
|
||||
# 一致处:**空 / 缺失就整键省略**(后端 `omitempty`),客户端把那两种形状都归一成空列表。
|
||||
DOCUMENT_FIXTURE_NAME = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt"
|
||||
|
||||
DOCUMENT_FIXTURE_REASON = "这个夹具不实现真正的摄取,所以没有可注入的正文"
|
||||
|
||||
|
||||
def documents_fixture(kind: str):
|
||||
"""按控制端点给的种类造一个 `documents` 键的值;`none` ⇒ 不打这个键(返回 None)。
|
||||
|
||||
`kind` 三档对应芯片上看得见的那三种样子(文案出处 `DocumentChips.statusLabel`):
|
||||
|
||||
| kind | `status` | 设备上那枚芯片 |
|
||||
|---|---|---|
|
||||
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
|
||||
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
|
||||
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`,`reason` 那句原话留给预览弹层 |
|
||||
|
||||
只给**一枚**芯片:造这一档要验的是「有附件、没有正文时那枚时间挂在哪一块」,不是芯片排的换行。
|
||||
"""
|
||||
if kind in (None, "", "none"):
|
||||
return None
|
||||
if kind not in ("extracted", "listed", "unreadable"):
|
||||
raise KeyError(kind)
|
||||
payload = {
|
||||
"name": DOCUMENT_FIXTURE_NAME,
|
||||
"mime": "text/plain",
|
||||
"size": 12,
|
||||
"status": kind,
|
||||
}
|
||||
if kind == "extracted":
|
||||
payload["chars"] = 10
|
||||
if kind == "unreadable":
|
||||
payload["reason"] = DOCUMENT_FIXTURE_REASON
|
||||
return [payload]
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
@@ -1564,12 +1607,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except (OSError, ValueError) as error:
|
||||
self._fail(400, "bad_request", "image_file unusable: {}".format(error))
|
||||
return
|
||||
try:
|
||||
documents = documents_fixture(self._param(query, "documents", None))
|
||||
except KeyError:
|
||||
self._fail(400, "bad_request", "documents must be one of extracted/listed/unreadable/none")
|
||||
return
|
||||
if session_id not in self.store.sessions:
|
||||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||||
return
|
||||
first_id = last_id = None
|
||||
for index in range(1, count + 1):
|
||||
content = template.format(n=index) if template else "{} #{}".format(prefix, index)
|
||||
# `text=` **空串**与「不带 text」是两件事:前者是「就要一条没有正文的消息」
|
||||
# (纯附件 / 纯图 / 无转写的语音都靠它造),后者走原来的 `seed #N` 默认正文。
|
||||
content = template.format(n=index) if template is not None else "{} #{}".format(prefix, index)
|
||||
message = self.store.add_message(
|
||||
session_id,
|
||||
role,
|
||||
@@ -1577,6 +1627,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
platform=platform,
|
||||
voice=voice,
|
||||
image=image,
|
||||
documents=documents,
|
||||
)
|
||||
first_id = first_id or message["id"]
|
||||
last_id = message["id"]
|
||||
|
||||
Reference in new issue
Block a user