From 576dd58a6b1f41a7948782130ce390ce94ba6068 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 27 Oct 2026 21:22:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(w80):=20=E8=AE=A9=E3=80=8C=E7=94=BB?= =?UTF-8?q?=E4=B8=8D=E7=94=BB=E8=AF=AD=E9=9F=B3=E5=BD=A2=E6=80=81=E3=80=8D?= =?UTF-8?q?=E5=8F=AA=E5=89=A9=E3=80=8C=E9=94=AE=E5=9C=A8=E4=B8=94=20status?= =?UTF-8?q?=20=E4=B8=8D=E6=98=AF=20failed=E3=80=8D=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 M57-M59 预告(devdocs/02 第 1255/1260 行我们的答复):`voice` 键在不再等于 「这是一段能播的声音」。合成降级 / 入站转写失败的行 `status = "failed"`,正文是一句话, `fallbackReason` 里给出降级原因——按老判据会被画成一个点下去必然播不了的语音气泡。 判据拆成两问,各自落在一处(domain/chat/VoiceMessage.kt): - `hasVoice` = `voice` 键存在 —— 「这条**来自**一段声音」; - `isPlayableVoice` = `status != "failed"` —— 认不出的 status 一律当可播(fail-open: 多画一枚语音形态最坏是点了播不出来,有既有失败文案兜着;反方向会把真有副本的语音 静默吞成纯文字,用户再也点不到); - `isVoiceMessage` = 两者相与 —— 「画不画语音形态」。 failed 行改画「正文 + 一行灰色小标」:`voiceFallbackNote` 给固定前半句 + 服务端 `fallbackReason` **原样**(不猜闭集、不做映射——取值集合后端尚未定稿;缺席/空白时退化成 固定那一句,不白屏)。新组件 `VoiceFallbackNote` 用 footnote2 + onSurfaceVariantSummary,与语音形态旁边那几句脚注同一档。 `isContentlessUserMessage` 挂 `hasVoice` 而**不**是 `isVoiceMessage`:failed 行虽没有语音 形态,但有正文和那行小标,同样不是「什么都没有」——判错了屏幕上会多一句假的 「内容没有被传过来」。`messageTimeCarrier` 同理按 `hasVoice` 挑落脚点。 DTO 上补第八个键 `fallbackReason`(只在 failed 时有,`explicitNulls = false` 时不写出), 所以转写成功的行键集仍是原来七个。 验证:`assembleDebug` + `testDebugUnitTest` 全绿,142 个测试文件 / 1766 个用例 / 0 失败 (基线 1755,新增 11 条;VoiceMessageTest 20、VoiceDtoTest 13、WsEventPersistedVoiceTest 7)。 --- .../yeij/cyrene/data/api/dto/MessageDto.kt | 13 +- .../top/yeij/cyrene/data/api/dto/VoiceDto.kt | 38 ++++-- .../yeij/cyrene/domain/chat/VoiceMessage.kt | 113 ++++++++++++++++-- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 84 ++++++++----- .../top/yeij/cyrene/ui/chat/VoiceBubble.kt | 52 +++++++- .../yeij/cyrene/data/api/dto/VoiceDtoTest.kt | 47 +++++++- .../data/ws/WsEventPersistedVoiceTest.kt | 62 +++++++++- .../cyrene/domain/chat/VoiceMessageTest.kt | 112 +++++++++++++++-- 8 files changed, 452 insertions(+), 69 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt index 21b480c..c60e93b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt @@ -65,8 +65,15 @@ data class HistoryMessageDto( val turnId: String? = null, /** * 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]); - * **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事,判据只有一个:这个键在不在 - * (落在一处:`domain.chat.isVoiceMessage`)。 + * **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事。 + * + * ## 判据是**两个**问题,别再合成一个 + * - 「这条**来自**一段声音吗」= 这个键在不在(`domain.chat.hasVoice`); + * - 「要不要画语音形态」= 键在 **且** `status != "failed"`(`domain.chat.isVoiceMessage`)。 + * 后端 M57-M59 的出站合成降级行(`{source:"tts", status:"failed", fallbackReason}`)与入站的 + * 转写失败行都是**键在、但没有可播的语音**:正文照常按文字渲染,另加一行灰色小标 + * (`domain.chat.voiceFallbackNote`),**不画**语音形态。按旧的单条判据(键在即语音)它们会 + * 被画成一个点下去必然播不了的气泡。 * * ## 覆盖率(契约 v0.97):**全部读面都带**,第一条帧就带 * `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、 @@ -77,7 +84,7 @@ data class HistoryMessageDto( * (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。 * * 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底: - * `voice` 在就判成语音消息,不在就不是。**不要为它加重试 / 轮询 / 延迟判定**。 + * `voice` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。 * * ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify; * ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回 diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/VoiceDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/VoiceDto.kt index fa4f006..8b20709 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/VoiceDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/VoiceDto.kt @@ -6,17 +6,23 @@ import kotlinx.serialization.Serializable * 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**, * `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。 * - * ## 🎤 判据 = 本键存在 + * ## 🎤 判据 = 本键存在 **且** `status != "failed"` * `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。 * 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]` * 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。 - * 判据落在一处:`domain.chat.isVoiceMessage`。 + * + * 键在只说明「这条来自一段声音」;要不要画语音形态还看 `status`(见下)。 + * 判据落在一处:`domain.chat.isVoiceMessage`(`domain.chat.hasVoice` 答的是前半个问题)。 * * ## 三态 `status`(闭集) - * `transcribed`(正文即转写文本)/ `empty`(转写成功但没识别出文字)/ `failed`(没拿到转写文本 - * ——**正文是服务端给的那行中文诚实提示**)。客户端**按 `status` 分支,绝不按正文文案分支**; - * 设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / - * 无字节一律只进服务端日志),所以本端**不猜**细分。 + * `transcribed`(正文即转写文本;出站方向 = 合成成功)/ `empty`(转写成功但没识别出文字)/ + * `failed`(**没有可播的语音**——入站没拿到转写文本,出站是合成降级回文字)。客户端**按 `status` + * 分支,绝不按正文文案分支**;设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / + * 限流 / 超时 / 格式不支持 / 无字节一律只进服务端日志),所以本端**不猜**细分。 + * + * ## `fallbackReason` 只在 `failed` 时有 + * 出站方向(`source = "tts"`)合成降级时,服务端在这个键里给出降级原因;本端**原样**小字展示 + * (`domain.chat.voiceFallbackNote`),**不猜闭集**——取值集合后端尚未定稿,见那个函数的 KDoc。 * * ## `name` / `mime` / `size` / `url` 四者同生共死 * 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。 @@ -39,7 +45,11 @@ import kotlinx.serialization.Serializable data class VoiceDto( /** * `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 - * `voice_item.text`)。见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。 + * `voice_item.text`)/ `"tts"`(我们自己合成的出站语音,后端 M57-M59)。 + * 见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。 + * + * 接收侧**不按 `source` 分支**:合成成功的那条与入站语音同形(`status = transcribed` + + * 四键副本),走同一条渲染路径。 */ val source: String = "", /** @@ -47,6 +57,9 @@ data class VoiceDto( * * 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值 * (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。 + * + * ⚠️ 只有 `"failed"` 是**没有可播的语音**的那一态(`domain.chat.isPlayableVoice`);认不出的 + * 取值一律当可播(fail-open,理由写在那个函数的 KDoc 里)。 */ val status: String = "", /** 音频副本的存储名(内容寻址,`<32hex>.`);**无副本 ⇒ 键缺席**。 */ @@ -57,6 +70,17 @@ data class VoiceDto( val size: Long? = null, /** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */ val durationMs: Int? = null, + /** + * 合成降级的原因(后端 M57-M59 预告,`devdocs/02` 已登记;契约 **v0.98 的正式回执尚未到**)。 + * + * 只在 `status == "failed"` 时有意义,其余状态下服务端不会下发(`omitempty`);入站转写失败 + * 也不带它——入站的失败原因由正文那行诚实提示承载。 + * + * 本端**原样**小字展示(`domain.chat.voiceFallbackNote`),**不猜闭集、不做映射**:取值集合 + * 后端还没定(`tts_timeout` 之类是我们看到的预告示例,不是契约)。缺席 / 空白时那一行小标 + * 退化成固定的一句(`VOICE_FALLBACK_NOTE`),不白屏。 + */ + val fallbackReason: String? = null, /** 副本的服务端相对路径(`/api/v1/media/`)。**不要自己拼这条路**,也不要当绝对地址用。 */ val url: String? = null, ) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceMessage.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceMessage.kt index 84e5f20..03e4f79 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceMessage.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceMessage.kt @@ -10,13 +10,19 @@ import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException /** * `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得; - * `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`)。 + * `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`); + * `tts` = **我们自己合成的出站语音**(她的 `speak` 工具产出的那一段,后端 M57-M59 新增)。 + * + * 三个取值在**接收侧**没有分叉:合成成功的那条与入站语音同形(`status = transcribed` + 四键 + * 副本齐全),照同一条路渲染;合成降级的那条是 `status = failed`(见 [VoiceStatus.FAILED])。 */ object VoiceSource { const val ASR: String = "asr" const val PLATFORM: String = "platform" + + const val TTS: String = "tts" } /** @@ -28,17 +34,22 @@ object VoiceSource { */ object VoiceStatus { - /** 正文**就是**转写文本。 */ + /** 正文**就是**转写文本(出站方向 = 合成成功、有副本可播)。 */ const val TRANSCRIBED: String = "transcribed" /** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */ const val EMPTY: String = "empty" /** - * 没拿到转写文本 ⇒ **正文是服务端给的那行中文诚实提示**,照常显示它。 + * 这一条**没有可播的语音**,正文是一句话(见 [isPlayableVoice])。 * - * 设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 - * 一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。 + * 两个方向同形、语义统一(后端 M57-M59 预告,契约 v0.98 待正式回执): + * - **入站**:没拿到转写文本 ⇒ 正文是服务端给的那行中文诚实提示; + * - **出站**:她本想用语音、合成降级回文字 ⇒ 正文是普通文字,另带 `fallbackReason` + * (见 [voiceFallbackNote])。 + * + * 入站方向设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / + * 无字节一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。 */ const val FAILED: String = "failed" } @@ -53,6 +64,19 @@ object VoiceStatus { */ const val VOICE_EMPTY_NOTE: String = "没有识别出文字" +/** + * `status == "failed"` 的那一行**灰色小标**的固定前半句(后面可能跟服务端给的 `fallbackReason`)。 + * + * 为什么要有这一行:这一态的消息 `voice` 键**在**、但**没有可播的语音**(见 [isPlayableVoice]), + * 所以屏幕上不会有语音形态。少了这一行,一条「她本想发语音、合成降级成文字」的消息与一条普通文字 + * 消息**逐像素相同**——看的人无从知道这条本来是一段声音。它只说本端能确定的那件事(这条没有可播的 + * 声音),**不解释原因**(原因归服务端,见 [voiceFallbackNote] 里那段原话)。 + * + * ⚠️ 措辞要同时站得住两个方向(入站转写失败 / 出站合成降级),所以**不写**「转写失败」也**不写** + * 「合成失败」:本端分不清,也不该替服务端选一个说法。 + */ +const val VOICE_FALLBACK_NOTE: String = "这条语音没有可播放的声音" + /** * 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。 * @@ -80,12 +104,50 @@ data class VoiceAudio( ) /** - * 🎤 **判据 = `voice` 键存在**(契约 §4.4)。 + * `voice` 键在不在(纯函数)—— 「这条消息**来自**一条语音」。 * - * 只有一个条件,而且**永远只有这一个**:`content` 里有没有 `[语音]` 这类字样与本判据无关 - * (QQ 的占位标记自 v0.67 起不再下发,别人也可以自己打出一模一样的字)。 + * 这是契约 §4.4 给的**唯一**来源判据:非语音消息整键缺席(不是 `{}` 也不是 `null`), + * `content` 里有没有 `[语音]` 这类字样与本判据无关(QQ 的占位标记自 v0.67 起不再下发, + * 别人也可以自己打出一模一样的字)。 + * + * ⚠️ 它与「要不要画语音气泡」**不是同一件事**了:合成降级 / 转写失败的那些行 `voice` 键照样在, + * 但**没有可播的语音**(见 [isPlayableVoice])。判「是不是语音消息」用本函数,判「画不画语音 + * 形态」用 [isVoiceMessage]——两个问题分开问,混用会让降级行被画成一个必然播不了的气泡。 */ -fun isVoiceMessage(message: HistoryMessageDto): Boolean = message.voice != null +fun hasVoice(message: HistoryMessageDto): Boolean = message.voice != null + +/** + * 这条语音**有没有可播的语音形态**(纯函数):除了 `voice` 键要在,`status` 还不能是 `failed`。 + * + * ## 为什么判据是「不是 `failed`」而不是「是 `transcribed` / `empty`」 + * 后端 M57-M59 预告(契约 v0.98 待正式回执)只点名了 `failed` 这一态**明确没有可播的语音**: + * 入站是没拿到转写文本、出站是合成降级回文字,两个方向的正文都是一句话而不是一段语音。 + * + * 反过来,认不出的 `status` **一律当可播**(fail-open)。理由是这个方向错得便宜、另一个方向错得 + * 贵:后端将来若新增一个成功态(比如 `transcribed` 之外的 `truncated`),fail-open 只是多画一枚 + * 语音形态(最坏是点了播不出来,有既有的失败文案兜着);fail-closed 则会把一条**真的有副本**的 + * 语音静默吞成纯文字,用户再也点不到那段声音——而且没有任何界面提示说明为什么。所以这里只把 + * 契约明确说过「没有语音」的那一态排除掉,其余交给「四键齐全才画播放控件」([voiceAudio])。 + */ +fun isPlayableVoice(voice: VoiceDto): Boolean = voice.status != VoiceStatus.FAILED + +/** + * 🎤 **要不要画语音形态的判据** = `voice` 键存在 **且** `status != "failed"`(契约 §4.4 + + * 后端 M57-M59 预告)。 + * + * 只有这两个条件,而且**永远只有这两个**: + * - `voice` 键缺席 ⇒ 不是语音消息([hasVoice]); + * - `status == "failed"` ⇒ 这条**没有可播的语音**(入站转写失败 / 出站合成降级),正文照常按文字 + * 渲染,另加一行灰色小标([voiceFallbackNote])——**不**画语音形态,因为画出来就是一个必然 + * 播不了的控件。 + * + * 入站语音(`asr` / `platform`)与合成成功的出站语音(`tts`)走的是**同一条**路:判据只看 + * `status`,不看 `source`。 + */ +fun isVoiceMessage(message: HistoryMessageDto): Boolean { + val voice = message.voice ?: return false + return isPlayableVoice(voice) +} /** * 这条语音的**可播放副本**;四个键没凑齐(或名字 / MIME / URL 是空白)就回 `null`。 @@ -128,18 +190,45 @@ fun voiceDurationLabel(durationMs: Int?): String? { } /** - * 这一条在**正文之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。 + * 语音形态**之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。 * * 只对 `status == "empty"` 且**正文为空**时成立: * - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补); - * - `failed`:正文**就是**服务端那行诚实提示,替它加一句等于替它改文案——**不做**; * - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾** * (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话); - * - 未知 `status`(含空串):**不猜**,与 `failed` 一样只显示正文。 + * - 未知 `status`(含空串):**不猜**,只显示正文。 + * + * ⚠️ `failed` 那一态**不走这里**(它连语音形态都不画):正文由服务端给,本端另加一行小标 + * ([voiceFallbackNote])。本函数仍对 `failed` 回 `null`,因为「语音形态旁边多说一句」这个位置 + * 对那一态根本不存在。 */ fun voiceNote(status: String, content: String): String? = if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null +/** + * `status == "failed"` 时那行灰色小标该写什么;别的状态回 `null`(纯函数)。 + * + * 两段拼成一句话:**固定前半句**([VOICE_FALLBACK_NOTE],说的是本端能确定的事实)+ 服务端给的 + * `fallbackReason`(**一个字符都不改**,原样放进括号里)。 + * + * ## 为什么原样放、不做映射 + * `fallbackReason` 的取值集合**后端还没定**(`devdocs/02` 里我们已经问了「是闭集还是自由文本」, + * 尚未答复;契约 v0.98 的正式回执也还没到)。在有答案之前,本端**不猜**它是不是闭集、更不替它 + * 编一套友好文案:认不出的取值被映射成一句话,比原样显示更难排查——用户报「显示成『语音超时』」 + * 时,我们连服务端到底给了什么都不知道。原样显示,最坏是屏幕上出现一个 `tts_timeout` 这样的词, + * 而它**正是**我们和后端对账时要的那个字符串。 + * + * ## `fallbackReason` 缺席时 + * 只有固定前半句。这一档同时是**空正文 + `failed`** 的兜底:这一态的消息正文按契约应当非空 + * (服务端那行诚实提示 / 降级后的原文本),但本端**不靠**这条保证——真收到一条空正文的 `failed` + * 行时,屏幕上至少还有这行小标,而不是一个**什么都没有**的空白(那正是空气泡那种最糟的呈现)。 + */ +fun voiceFallbackNote(voice: VoiceDto?): String? { + if (voice == null || voice.status != VoiceStatus.FAILED) return null + val reason = voice.fallbackReason?.takeIf { it.isNotBlank() } ?: return VOICE_FALLBACK_NOTE + return "$VOICE_FALLBACK_NOTE($reason)" +} + /** * 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。 * 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 426c004..94c3e57 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 @@ -52,11 +52,13 @@ import top.yeij.cyrene.domain.chat.TTS_SPEAK_DESCRIPTION import top.yeij.cyrene.domain.chat.TTS_STOP_DESCRIPTION import top.yeij.cyrene.domain.chat.TurnPhase import top.yeij.cyrene.domain.chat.VoicePlaybackState +import top.yeij.cyrene.domain.chat.hasVoice import top.yeij.cyrene.domain.chat.isActiveFor import top.yeij.cyrene.domain.chat.isLoadingFor import top.yeij.cyrene.domain.chat.isVoiceMessage import top.yeij.cyrene.domain.chat.ttsClipIdOf import top.yeij.cyrene.domain.chat.ttsTextOf +import top.yeij.cyrene.domain.chat.voiceFallbackNote import top.yeij.cyrene.domain.chat.voiceNote import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yeij.cyrene.domain.parse.ContentPart @@ -115,10 +117,15 @@ private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传 * * 判据刻意**窄**,只有 `user` 消息才可能成立: * - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白); - * - **语音消息一律不成立**(`voice != null`):语音的转写文本可能为空(`status = empty`, - * 或 `transcribed` 却什么都没有),但那条消息**不是什么都没有** —— 它有一个语音形态 - * (`VoiceMessageRow` 会把它画出来)。少了这一条,一条「微信语音、没认出发音、也没有音频副本」 - * 的消息会被扣上「内容没有被传过来」这顶帽子,而那句话是**假的**; + * - **`voice` 键在就一律不成立**([hasVoice]):这一条问的是「这条里**有没有东西可看**」,不是 + * 「有没有语音形态」,所以两档都要挡住: + * - `status = empty` / `transcribed`(语音形态画得出来):转写文本可能为空,但那条消息**不是 + * 什么都没有** —— 它有一个语音形态(`VoiceMessageRow` 会把它画出来)。少了这一档,一条 + * 「微信语音、没认出发音、也没有音频副本」的消息会被扣上「内容没有被传过来」这顶帽子, + * 而那句话是**假的**; + * - `status = failed`(入站转写失败 / 出站合成降级):**没有**语音形态,但有一条正文 + * (服务端那行诚实提示 / 降级后的原文本)和那行降级小标([voiceFallbackNote] 的产物)—— + * 同样不是「什么都没有」; * - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说), * 顺手改会波及所有助手气泡,不在本次范围内。 * @@ -132,8 +139,10 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean = message.image.isNullOrBlank() && message.documents.isEmpty() && message.thinking.isNullOrBlank() && - // 判据只有一处(domain.chat.isVoiceMessage):绝不在这里另写一个 `voice != null`。 - !isVoiceMessage(message) + // 判据只有一处(domain.chat.hasVoice):绝不在这里另写一个 `voice != null`。 + // ⚠️ 用 hasVoice 而不是 isVoiceMessage:failed 行(转写失败 / 合成降级)也**不是**什么都没有 + // —— 它有正文(服务端那行诚实提示 / 降级后的原文本)与那行降级小标(voiceFallbackNote)。 + !hasVoice(message) /** * 一条落库消息渲染成一个或多个气泡: @@ -142,9 +151,12 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean = * - 其它 mode 原样整条渲染,不解析标签; * - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片; * - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画); - * - `voice` 非空时(🎤 判据 = **这个键存在**,契约 §4.4)在正文之外多画一枚语音形态 - * ([VoiceMessageRow]:播放 / 暂停 + 时长,有副本时才有播放控件);正文照常渲染 - * ——转写文本 / 服务端那行诚实提示都在里面,一个字都不改; + * - `voice` 键在时(🎤 判据 = **这个键存在且 `status != "failed"`**,契约 §4.4 + 后端 M57-M59 + * 预告,见 `isVoiceMessage`)在正文之外多画一枚语音形态([VoiceMessageRow]:播放 / 暂停 + 时长, + * 有副本时才有播放控件);正文照常渲染——转写文本 / 服务端那行诚实提示都在里面,一个字都不改; + * `status = "failed"` 那一档(入站转写失败 / 出站合成降级)**没有**语音形态可画,改为在正文之外 + * 多画一行灰色小标([VoiceFallbackNote],文案由 `voiceFallbackNote` 给:固定前半句 + + * 服务端 `fallbackReason` 原样); * - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过 * [ImageHostRebase](见 [imageBaseUrl]); * - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明 @@ -158,7 +170,8 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean = * 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。 * * 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档); - * 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音形态) + * 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音那一块 + * ——语音形态或 `failed` 行的降级小标,`voice` 键在就必画其一) * ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为 * `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间** * (W72 补的就是它)。 @@ -312,19 +325,34 @@ fun MessageBubble( // 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示 // 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示 // 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。 - 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 (isVoiceMessage(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, + ) + } + } else { + // status == "failed":这条**没有可播的声音**(入站转写失败 / 出站合成降级)。 + // 正文照常按文字渲染(下面那条路),这里只多画一行灰色小标 —— **不**画语音形态: + // 画出来就是一个必然播不了的控件(见 isVoiceMessage 的 KDoc)。 + // 没有这一行,这一条与普通文字消息逐像素相同,看的人无从知道它本来是一段声音。 + voiceFallbackNote(voice)?.let { note -> + BubbleWithTime( + time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice }, + isUser = isUser, + ) { side -> + VoiceFallbackNote(text = note, alignEnd = isUser, modifier = side) + } + } } } if (isContentlessUserMessage(message)) { @@ -535,7 +563,7 @@ sealed interface MessageTimeCarrier { /** 图片(`image` 非空才会画)。 */ data object Image : MessageTimeCarrier - /** 语音形态(`voice` 键在才会画)。 */ + /** 语音那一块(`voice` 键在才会画:语音形态,或 `failed` 行的降级小标)。 */ data object Voice : MessageTimeCarrier } @@ -544,16 +572,18 @@ sealed interface MessageTimeCarrier { * * ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西 * - * [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音形态 → 正文气泡。 + * [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音那一块 → 正文气泡。 * 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。 * * 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」: * 解析出来的末段偶尔只有旁白(`` / ``),那一项不画正文框,挂上去时间就 * 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。 - * 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音形态 → 图片 → 附件芯片排。这一档就是 + * 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音那一块 → 图片 → 附件芯片排。这一档就是 * **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡 * `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来** * (主人 2026-10-07 验收时发现的缺口)。 + * 「语音那一块」按 `voice` 键判([hasVoice])而**不**按 [isVoiceMessage]:`failed` 行不画语音 + * 形态,但会画那行降级小标([VoiceFallbackNote])——它也占着一块,也照样能挂时间。 * 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。 * * ## 谁**不**走这一档 @@ -579,7 +609,7 @@ fun messageTimeCarrier( if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody) // 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。 return when { - message.voice != null -> MessageTimeCarrier.Voice + hasVoice(message) -> MessageTimeCarrier.Voice !message.image.isNullOrBlank() -> MessageTimeCarrier.Image message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments else -> null diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceBubble.kt index ad23dd9..940d9de 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceBubble.kt @@ -30,7 +30,10 @@ import top.yukonga.miuix.kmp.icon.extended.Pause import top.yukonga.miuix.kmp.icon.extended.Play import top.yukonga.miuix.kmp.theme.MiuixTheme -/** 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = `voice` 键存在),不是内容。 */ +/** + * 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = 这条画的是语音形态, + * 见 `isVoiceMessage`),不是内容。 + */ const val VOICE_LABEL: String = "语音" /** 加载中那一句(与 [VOICE_LABEL] 同一行,替换掉时长那一格)。 */ @@ -49,8 +52,10 @@ private val VOICE_ICON_SIZE = 18.dp * 一条语音消息在**转写文本之外**的那个「语音」形态:`[播放/暂停] 语音 · 时长`。 * * ## 画什么由三条契约判据决定(都在这一个函数里,不散落) - * - **🎤 判据 = `voice` 键存在**:本组件只有拿到 `voice` 才会被调用(调用点在 [MessageBubble]), - * 所以这里不认识正文里有没有 `[语音]` 字样; + * - **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**:本组件只有在这条消息要画语音形态时 + * 才会被调用(调用点在 [MessageBubble],判据是 `isVoiceMessage`),所以这里既不认识正文里有 + * 没有 `[语音]` 字样、也不认识 `status`——`failed` 那一态(没有可播的声音)根本不走这条路, + * 它由 `VoiceFallbackNote` 单独画一行灰色小标; * - **无副本 ⇒ 不画播放控件**([voiceAudio] 回 `null`):四个键(`name`/`mime`/`size`/`url`) * 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示 * (时长与副本**无关**); @@ -171,3 +176,44 @@ fun VoiceMessageRow( } } } + +/** + * `status == "failed"` 那条消息**没有语音形态**,但要多说的一句话(`voice` 键在、没有可播的声音)。 + * + * ## 为什么这一行必须存在 + * 这一态的消息正文照常按文字渲染(入站是服务端那行诚实提示,出站是合成降级后的原文本)。少了这一行, + * 一条「她本想用语音、合成降级成文字」的消息与一条普通文字消息**逐像素相同**——看的人无从知道这条 + * 本来是一段声音,也看不出它与别的话有什么不一样。所以这枚小标不是装饰,它是这一态**唯一**的可见 + * 凭据([VoiceMessageRow] 在这里根本不画,见 `isVoiceMessage`)。 + * + * ## 样式为什么抄 [VoiceMessageRow] 里 note 那一行 + * `footnote2` + `onSurfaceVariantSummary`:与语音形态旁边那几句说明(「没有识别出文字」/ 加载中 / + * 时长)**同一档**。这三处说的都是「关于这段声音的一句脚注」,同一个外观才让人读成一类东西; + * 换成正文那一档字级会被误读成消息内容的一部分,换成 `error` 又会把一次**降级**说成一次**失败** + * ——降级本身是服务端做对的一件事(她的话一个字没丢,只是没变成声音)。 + * + * ## 不判 `status`,也不拼文案 + * [text] 由调用方用纯函数 `voiceFallbackNote` 算好传进来(固定前半句 + 服务端 `fallbackReason` 原样)。 + * 本组件只负责画:判据与措辞都在 domain 层单测钉着,这里再判一次就多出「两处判据不一致」的可能 + * ——与 [VoiceMessageRow] 对 `note` 的处理同一条纪律。 + * + * @param text 已经算好的那一整句(本组件不改一个字符)。 + * @param alignEnd 用户侧(气泡在右):整行贴右;否则贴左。 + */ +@Composable +fun VoiceFallbackNote( + text: String, + alignEnd: Boolean = false, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start, + ) { + Text( + text = text, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/VoiceDtoTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/VoiceDtoTest.kt index 5e6097a..ee0068e 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/VoiceDtoTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/VoiceDtoTest.kt @@ -18,12 +18,14 @@ import top.yeij.cyrene.domain.chat.VoiceStatus * 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。 * * 要钉住的六件事: - * 1. **七个键逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url` - * —— 键名不许自己发明(形状错一个字母线上就是「不是语音」); + * 1. **键名逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url`, + * 外加**只在 `status = "failed"` 时**出现的第八个键 `fallbackReason`(后端 M57-M59 预告, + * 契约 v0.98 的正式回执尚未到)—— 键名不许自己发明(形状错一个字母线上就是「不是语音」); * 2. **缺席与 `null` 是同一件事**:非语音消息整键缺席 ⇒ `null`(不是 `{}`,也不是别的哨兵); * 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉); * 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」); * 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢); + * `fallbackReason` 反过来**不是**闭集——本端原样留着、不做任何映射; * 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。 */ class VoiceDtoTest { @@ -67,6 +69,8 @@ class VoiceDtoTest { // url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。 assertEquals("/api/v1/media/$name", voice.url) assertFalse(voice.url!!.startsWith("http")) + // 转写成功的行不该有降级原因(服务端 `omitempty` 不下发)⇒ 解成 null,显示层据此不加小标。 + assertNull(voice.fallbackReason) } @Test @@ -184,6 +188,8 @@ class VoiceDtoTest { @Test fun `encoding a full voice keeps the seven key names`() { // 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。 + // 这里没传 `fallbackReason`(非 failed 行本来就不该有)⇒ `explicitNulls = false` 不写这个键, + // 键集仍是七个。 val encoded = json.encodeToJsonElement( VoiceDto.serializer(), VoiceDto( @@ -203,6 +209,43 @@ class VoiceDtoTest { ) } + @Test + fun `encoding a fallback voice keeps the fallbackReason key`() { + // 第八个键(后端 M57-M59 预告):只在 `status = "failed"` 时出现。它在 DTO 上是**真字段**, + // 不是靠 `ignoreUnknownKeys` 吞掉的陌生键——否则出站降级那一行写不出去。 + val encoded = json.encodeToJsonElement( + VoiceDto.serializer(), + VoiceDto( + source = VoiceSource.TTS, + status = VoiceStatus.FAILED, + fallbackReason = "tts_timeout", + ), + ).jsonObject + + assertEquals(setOf("source", "status", "fallbackReason"), encoded.keys) + assertEquals("tts_timeout", encoded["fallbackReason"]!!.jsonPrimitive.content) + } + + @Test + fun `a fallback voice decodes its reason and has no copy`() { + // 降级行:没有可播的声音(四个副本键全缺席),原因原样留在 `fallbackReason` 里—— + // 本端**不映射、不猜闭集**(取值集合后端尚未定稿),显示层原样小字。 + val body = """ + {"id":"msg-10","sessionId":"web","role":"user","content":"想跟你说句话", + "createdAt":1700000000009, + "voice":{"source":"tts","status":"failed","fallbackReason":"tts_timeout"}} + """.trimIndent() + + val voice = decode(body).voice!! + assertEquals(VoiceSource.TTS, voice.source) + assertEquals(VoiceStatus.FAILED, voice.status) + assertEquals("tts_timeout", voice.fallbackReason) + assertNull("降级行没有可播的声音", voice.name) + assertNull(voice.mime) + assertNull(voice.size) + assertNull(voice.url) + } + @Test fun `encoding a voice with no copy omits the nullable keys`() { // `explicitNulls = false`(与后端 `omitempty` 同一口径):无副本时那四个键**不写出来**, diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt index 756e30b..a3a36d9 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt @@ -2,26 +2,33 @@ package top.yeij.cyrene.data.ws import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import top.yeij.cyrene.domain.chat.hasVoice import top.yeij.cyrene.domain.chat.isVoiceMessage import top.yeij.cyrene.domain.chat.voiceAudio +import top.yeij.cyrene.domain.chat.voiceFallbackNote /** - * 落库帧上的 `voice`(契约 **v0.97**,后端 M44 `fb55769`)。 + * 落库帧上的 `voice`(契约 **v0.97**,后端 M44 `fb55769`;`failed` / `fallbackReason` 见 M57-M59)。 * - * ## 这条钉子钉的是「撤掉兜底」之后唯一剩下的判据 + * ## 这条钉子钉的是「撤掉兜底」之后剩下的判据 * W73 时期的旧口径是「`voice` 只在四个 REST 读取面填充,实时帧与握手回放缺席 ⇒ 实时到达的语音 * 先按普通文本显示,等下一次拉取自动升级」。后端把落库顺序改成 **INSERT → 写 `voice` 列 → * notify** 之后,语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`, - * 于是那条兜底**整条撤掉**,判据只剩一条:`voice` 键存在 = 语音消息(`isVoiceMessage`)。 + * 于是那条兜底**整条撤掉**。判据剩下两条:`voice` 键存在(`hasVoice`)**且** `status != "failed"` + * (`isVoiceMessage`)—— 降级行没有可播的声音,画成语音形态就是给一个点下去必然失败的按钮。 * - * 钉在这里的三件事,任何一件被将来的改动破坏都会让语音消息静默退回纯文本: + * 钉在这里的四件事,任何一件被将来的改动破坏都会让语音消息静默退回纯文本(或反过来, + * 把一条降级行画成播不出来的语音): * 1. `message_persisted` 载荷里的 `voice` **真的被解出来**(`PersistedData` 只解 `message`, * 而生产 `Json` 是 `ignoreUnknownKeys = true` ⇒ 漏解不报错,只是判据永远为假); * 2. `channel_persisted` 同样——两条帧共用一个载荷类型; - * 3. `voice` 键**缺席**仍是「不是语音」(判据只有一个,不为「实时帧可能不带」留任何分支)。 + * 3. `voice` 键**缺席**仍是「不是语音」(不为「实时帧可能不带」留任何分支); + * 4. `status = "failed"` 的降级行**不是**语音形态,但也不是纯文本:正文 + 一行降级小标 + * (`voiceFallbackNote`),且 `voiceAudio` 为 null ⇒ 没有播放控件。 * * 帧形状照服务端实现:`data:{message:{…}}`(`channel_persisted` 同形;与 `message_edited` * 那条「`data` 就是消息本身」**不同**)。 @@ -51,6 +58,21 @@ class WsEventPersistedVoiceTest { {"id":"1373","sessionId":"web","role":"user","content":"在的","createdAt":1791088571787} """.trimIndent().replace("\n", " ") + /** 我们自己合成的出站语音(后端 M57-M59):与入站语音**同形**,走同一条渲染路径。 */ + private val ttsRow = """ + {"id":"1375","sessionId":"web","role":"assistant","content":"早上好呀", + "voice":{"source":"tts","status":"transcribed","name":"$copyName","mime":"audio/ogg", + "size":12345,"durationMs":3200,"url":"/api/v1/media/$copyName"}, + "createdAt":1791088571789} + """.trimIndent().replace("\n", " ") + + /** 合成降级那一行:`status = "failed"` + `fallbackReason`,四个副本键全缺席(没有可播的声音)。 */ + private val fallbackRow = """ + {"id":"1374","sessionId":"web","role":"assistant","content":"想跟你说句话", + "voice":{"source":"tts","status":"failed","fallbackReason":"tts_timeout"}, + "createdAt":1791088571788} + """.trimIndent().replace("\n", " ") + @Test fun `a persisted frame carries voice back to the decoder`() { val event = WsEvent.parse(frame("message_persisted", """{"message":$voiceRow}""")) as WsEvent.MessagePersisted @@ -112,4 +134,34 @@ class WsEventPersistedVoiceTest { assertTrue(isVoiceMessage(message)) assertEquals("明天几点出发", message.content) } + + @Test + fun `a tts fallback row is text with a note, not a voice bubble`() { + // 合成降级那一行:键在(`hasVoice` 为真 ⇒ 不许当成「什么都没有」),但 `status = "failed"` + // ⇒ 没有可播的声音,画语音形态就是给一个点下去必然失败的按钮。正文照旧 + 一行原样小标。 + val event = WsEvent.parse(frame("message_persisted", """{"message":$fallbackRow}""")) as WsEvent.MessagePersisted + + val message = event.message!! + val voice = message.voice!! + assertTrue("降级行仍来自一段声音", hasVoice(message)) + assertFalse("但它没有可播的声音,不许画成语音形态", isVoiceMessage(message)) + assertNull("没有副本 ⇒ 一个播放控件都不画", voiceAudio(voice)) + assertEquals("想跟你说句话", message.content) + // 原因**原样**出现在小标里(不映射、不猜闭集)。 + assertNotNull(voiceFallbackNote(voice)) + assertTrue(voiceFallbackNote(voice)!!.contains("tts_timeout")) + } + + @Test + fun `a synthesized voice row is an ordinary voice bubble`() { + // `source = tts` 与 asr / platform 走**同一条**路:判据只看 `status`,不看 `source`。 + val event = WsEvent.parse(frame("message_persisted", """{"message":$ttsRow}""")) as WsEvent.MessagePersisted + + val message = event.message!! + val voice = message.voice!! + assertEquals("tts", voice.source) + assertTrue(isVoiceMessage(message)) + assertEquals(copyName, voiceAudio(voice)?.name) + assertNull("合成成功的那条不该有降级小标", voiceFallbackNote(voice)) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceMessageTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceMessageTest.kt index 7c9e1da..0fcc7ae 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceMessageTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceMessageTest.kt @@ -2,6 +2,7 @@ package top.yeij.cyrene.domain.chat import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -17,13 +18,15 @@ import top.yeij.cyrene.ui.chat.isContentlessUserMessage /** * 语音消息的**呈现判据**(纯函数,接收侧)。 * - * 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,五条口径逐条钉在这里: - * 1. **🎤 判据 = `voice` 键存在**(`isVoiceMessage`)——**绝不**从正文里认媒体类型 - * (正文里可能出现用户自己打出的 `[语音]` 字样); + * 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,六条口径逐条钉在这里: + * 1. **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**(`isVoiceMessage`)——**绝不**从正文里 + * 认媒体类型(正文里可能出现用户自己打出的 `[语音]` 字样),也绝不把 `failed` 行画成语音形态 + * (那一行没有可播的声音,见 `isPlayableVoice` / `hasVoice`); * 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全); * 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充; * 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示; - * 5. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**, + * 5. **`failed` 行多一行灰色小标**(`voiceFallbackNote`):固定前半句 + 服务端 `fallbackReason` 原样; + * 6. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**, * 绝不退回「自己拼一条 `/api/v1/media/`」。 */ class VoiceMessageTest { @@ -50,6 +53,7 @@ class VoiceMessageTest { mime: String? = "audio/ogg", size: Long? = 12_345, durationMs: Int? = 3_200, + fallbackReason: String? = null, url: String? = "/api/v1/media/$copyName", ) = VoiceDto( source = source, @@ -58,19 +62,22 @@ class VoiceMessageTest { mime = mime, size = size, durationMs = durationMs, + fallbackReason = fallbackReason, url = url, ) - // —— 1. 判据 = 键存在 —— + // —— 1. 「来自一条语音」的判据 = 键存在(`hasVoice`)—— @Test - fun `the voice key is the only criterion`() { - assertTrue(isVoiceMessage(message(voice = voice()))) - assertFalse(isVoiceMessage(message(content = "在的"))) + fun `the voice key is the only criterion for coming from a voice`() { + assertTrue(hasVoice(message(voice = voice()))) + assertFalse(hasVoice(message(content = "在的"))) // 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发, // 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。 - assertFalse(isVoiceMessage(message(content = "[语音]"))) - assertFalse(isVoiceMessage(message(content = "语音"))) + assertFalse(hasVoice(message(content = "[语音]"))) + assertFalse(hasVoice(message(content = "语音"))) + assertTrue(hasVoice(message(content = "[语音]", voice = voice()))) + // 「要不要画语音形态」是**另一个**问题(见下面第 4b 节),但键在是它的前提。 assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice()))) } @@ -163,6 +170,83 @@ class VoiceMessageTest { assertNull(voiceNote(VoiceStatus.EMPTY, "其实有字")) } + // —— 4b. failed 行:没有可播的声音,但仍然是「一条语音消息」—— + + @Test + fun `a failed voice is not a voice bubble but still a voice message`() { + // 判据拆成两个问题:hasVoice 答「这条来自一条语音吗」,isVoiceMessage 答「画不画语音形态」。 + // 合成降级 / 转写失败的行两问的答案是「是」「否」——正因如此才不能混用。 + val outbound = message(content = "想跟你说句话", voice = voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED)) + assertTrue(hasVoice(outbound)) + assertFalse(isVoiceMessage(outbound)) + // 入站转写失败同形:本端分不清(也不该分)这两个方向。 + assertFalse(isVoiceMessage(message(voice = voice(status = VoiceStatus.FAILED)))) + // 键缺席才是「不是语音消息」。 + assertFalse(hasVoice(message(content = "在的"))) + } + + @Test + fun `a failed voice is excluded by status, not by its copy keys`() { + // 两个判据各判各的、互不顶替:voiceAudio 只看四键齐不齐(**不看** status), + // isPlayableVoice 只看 status(**不看**四键)。所以「四键齐全但 status = failed」这种 + // 后端按契约不该发的形状,voiceAudio 照样能构造出来 —— 拦住语音形态的是 isVoiceMessage。 + val contradictory = voice(status = VoiceStatus.FAILED) + assertNotNull(voiceAudio(contradictory)) + assertFalse(isPlayableVoice(contradictory)) + assertFalse(isVoiceMessage(message(voice = contradictory))) + // 反向:四键不齐的 transcribed 行照样「可播」(画语音形态、只是没有播放控件)。 + val noCopy = voice(name = null, mime = null, size = null, url = null) + assertNull(voiceAudio(noCopy)) + assertTrue(isPlayableVoice(noCopy)) + assertTrue(isVoiceMessage(message(voice = noCopy))) + } + + @Test + fun `the fallback reason is shown verbatim and never mapped`() { + // 原样放进括号:后端换一个取值本端一个字符都不改(闭集还没定,映射比原样更难排查)。 + assertEquals( + "这条语音没有可播放的声音(tts_timeout)", + voiceFallbackNote(voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED, fallbackReason = "tts_timeout")), + ) + assertEquals( + "这条语音没有可播放的声音(budget_exceeded)", + voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = "budget_exceeded")), + ) + } + + @Test + fun `a failed voice without a reason still says something`() { + // 缺席 / 空白:只剩固定前半句。这一档同时是「空正文 + failed」的兜底 —— + // 屏幕上至少还有这行小标,而不是一个什么都没有的空白。 + assertEquals(VOICE_FALLBACK_NOTE, voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = null))) + assertEquals(VOICE_FALLBACK_NOTE, voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = " "))) + } + + @Test + fun `only a failed voice gets a fallback note`() { + assertNull(voiceFallbackNote(null)) + assertNull(voiceFallbackNote(voice())) + assertNull(voiceFallbackNote(voice(status = VoiceStatus.EMPTY))) + // 认不出的 status 也不加这一行:多画一句「没有可播放的声音」就是替服务端断言它没有。 + assertNull(voiceFallbackNote(voice(status = "future-status"))) + } + + @Test + fun `a synthesized voice that went through is an ordinary voice bubble`() { + // source = tts 与 asr / platform 走**同一条**路:判据只看 status,不看 source。 + val tts = voice(source = VoiceSource.TTS) + assertTrue(isVoiceMessage(message(content = "早上好呀", voice = tts))) + assertNotNull(voiceAudio(tts)) + assertNull(voiceFallbackNote(tts)) + } + + @Test + fun `an unknown status is played rather than silently swallowed`() { + // fail-open:认不出的 status 一律当可播。这个方向错得便宜(多画一枚语音形态,最坏点了播不出来, + // 有既有的失败文案兜着);反方向错得贵(把真有副本的语音静默吞成纯文字,用户再也点不到)。 + assertTrue(isVoiceMessage(message(voice = voice(status = "truncated")))) + } + // —— 5. 相对路径接到当前服务器地址 —— @Test @@ -244,6 +328,14 @@ class VoiceMessageTest { // ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。 val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null)) assertFalse(isContentlessUserMessage(empty)) + // `failed` 那一档同样不成立,只是理由不同:它**没有**语音形态,但有那行降级小标 + // (见 `voiceFallbackNote`)—— 同样不是「什么都没有」。判据因此挂在 `hasVoice` 上而**不是** + // `isVoiceMessage` 上:两种落点都画得出东西。 + val failed = message( + content = "", + voice = voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED, fallbackReason = "tts_timeout"), + ) + assertFalse(isContentlessUserMessage(failed)) // 对照:同样空的正文,没有 voice 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。 assertTrue(isContentlessUserMessage(message(content = ""))) }