2 Commits
Author SHA1 Message Date
AskaEth c1ebd208b1 merge: W72 —— 纯附件/图片/语音形态也显示时间(判据抽成 messageTimeCarrier;原本只挂在「有正文的气泡」上,纯附件消息没有正文气泡) 2026-10-07 19:18:28 +08:00
AskaEth a94e310cd7 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)。
2026-10-07 19:16:25 +08:00
4 changed files with 436 additions and 27 deletions

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))
}
}
+50 -2
View File
@@ -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**
+52 -1
View File
@@ -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"]