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 d1534e9..1fd4200 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 @@ -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(), ) - // 时间挂在**最后一个有正文的气泡**上(而不是「列表最后一项」):解析出的末段偶尔只有旁白 - // (``/``),那一项不画正文框,挂上去时间就没有落脚点。 - 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 原本那一档,一个字都不改)。为什么不是「列表最后一项」: + * 解析出来的末段偶尔只有旁白(`` / ``),那一项不画正文框,挂上去时间就 + * 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。 + * 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, +): 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) }, diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageTimeCarrierTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageTimeCarrierTest.kt new file mode 100644 index 0000000..371279c --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageTimeCarrierTest.kt @@ -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 = 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 = "甲

乙

"), bubbles), + ) + } + + @Test + fun `a trailing annotation only bubble never carries the time`() { + // 末段只有旁白(``/``)时不画正文框:时间退回上一个有正文的气泡。 + val bubbles = listOf(body("甲"), body("")) + + assertEquals( + MessageTimeCarrier.TextBubble(0), + messageTimeCarrier(message(content = "甲她抬头"), 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)) + } +} diff --git a/devtools/fake-backend/README.md b/devtools/fake-backend/README.md index c751d96..ee6e6ba 100644 --- a/devtools/fake-backend/README.md +++ b/devtools/fake-backend/README.md @@ -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** diff --git a/devtools/fake-backend/fake_backend.py b/devtools/fake-backend/fake_backend.py index 85f1bab..e6c63ac 100644 --- a/devtools/fake-backend/fake_backend.py +++ b/devtools/fake-backend/fake_backend.py @@ -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"]