Merge branch 'feat/w80-voice-failed-status-judgment' into android

This commit is contained in:
AskaEth committed 2026-10-27 21:33:01 +08:00
commit 31ab1ff004
8 files changed
+452 -69

No files matched your search

@@ -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` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
@@ -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>.<ext>`);**无副本 ⇒ 键缺席**。 */
@@ -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/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
val url: String? = null,
)
@@ -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] 上(纯函数)。
*
@@ -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 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
* 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
@@ -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,
)
}
}
@@ -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` 同一口径):无副本时那四个键**不写出来**,
@@ -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))
}
}
@@ -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/<name>`」。
*/
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 = "")))
}