fix(w80): 让「画不画语音形态」只剩「键在且 status 不是 failed」两条判据
后端 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)。
This commit is contained in:
1 parent
a9c940d39f
commit
576dd58a6b
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 = "")))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user