8 Commits
Author SHA1 Message Date
AskaEth 31ab1ff004 Merge branch 'feat/w80-voice-failed-status-judgment' into android 2026-10-27 21:33:01 +08:00
AskaEth 576dd58a6b 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)。
2026-10-27 21:22:28 +08:00
AskaEth a9c940d39f Merge branch 'feat/w79-share-declare-wiring' into android 2026-10-27 00:30:44 +08:00
AskaEth f6eb93027d fix(w79): 让「分享零声明 / 批量只第一条声明」只剩一个判据
接 W76 的 finding:M2 把口径提成纯函数 ShareRules.declaresSendPreference 并加了穷举测试,
但接线那一行落在 M2 scope 外,于是判据仍是两处、每处各半个——分享路径写死
applySendPreference = false,批量那条写 applySendPreference && index == 0,两处都单测不到,
而纯函数没有调用方(dead code)。

改法是把入口的事实交回入口:emitPlannedMessages 的参数从 applySendPreference: Boolean
改成 fromShare: Boolean(sendShared 传 true、输入栏 send 传 false),函数体只写
applySendPreference = ShareRules.declaresSendPreference(fromShare, index)。于是「谁声明」
这个问题的答案只剩 ShareRules 一处,VM 只负责把 (fromShare, index) 喂进去。

emitAndDeliver 的 applySendPreference: Boolean = true 默认值一并去掉(改必填)。唯一调用方
就是 emitPlannedMessages,留着默认值等于把口径又抄了一遍:任何新调用点不写这个参数就会
静默拿到「声明」,而这正是本次要消灭的那种第二出处。deliver 里 if (applySendPreference)
与 parallel = applySendPreference && … 一字未改,只是值的来源变了。

行为零变化(逐入口核对):分享路径 fromShare = true ⇒ declaresSendPreference 恒 false ⇒
零 interrupt 帧、零 parallel,与原先写死 false 等价;手打路径 fromShare = false ⇒ 只有
index == 0 声明,与原先 && index == 0 等价。

测试:新增 ShareDeclarationWiringTest 3 条,钉的是「喂进去的是什么」——判据本身已被
ShareSendDeclarationTest 在裸整数上穷举,这里补另一半:VM 那个 forEachIndexed 的 index
是 plan.messages 的位置,不是选图顺序、也不是 imagesToSend 的下标,两者在超限截断时会分叉。
所以用 imageSendPlan + plan.messages.indices 复刻 VM 的形状,覆盖分享 N=0..MAX_IMAGES
恒不声明、手打只第一条声明、7 张图截断到 4 条时声明不落到这次不发的消息上。
ShareSendDeclarationTest 不动:裸整数判据与 plan 形状互补,合并会丢掉一层的分辨力。
全量 testDebugUnitTest --rerun-tasks:142 suites / 1755 tests / 0 failures / 0 errors /
0 skipped(基线 141/1752,+1 suite / +3 tests)。

顺手 git rm --cached .cyrene-build.lock:快照 e94bdf6 把一个空的构建锁带进了分支根目录,
锁的实际位置在仓库之外(D:/Project/Code/Uni/.cyrene-build.lock),这个 0 字节副本是误入。

仍未验:ChatViewModel 的编排本身仍没有测试穿过(项目缺 fake AppContainer / SessionsApi
基建,已登记在 05 的遗留里),本次靠「参数必填 + 判据单点 + 复刻喂入形状」三条结构约束守。
2026-10-27 00:21:20 +08:00
AskaEth e94bdf6583 tower: snapshot of uncommitted base checkout changes (worktree wt-6) 2026-10-26 23:59:21 +08:00
AskaEth 6aa434dad5 Merge branch 'feat/w75-tts-speech-bearer-playback' into android 2026-10-26 23:58:41 +08:00
AskaEth 222f691b78 fix(w75): 让「停止朗读」真的能停,切走不再补响旧朗读,缓存键带上 baseUrl
修 rev-w75 的三条发现(M5 / W75 TTS 朗读)。

P1-1 「停止朗读是空操作」—— 根因是三处合谋,谁都没错、合起来没反应:
MessageBubble 在播放中画暂停符、读屏名字是 TTS_STOP_DESCRIPTION,可 onSpeak 直通
viewModel::speak;而 beginTtsSpeak 的防抖只认 pending,播放中文件早就到手 ⇒ 不是
pending ⇒ 放行;整格被换成新的 pending 后缓存命中秒回,起播 effect 又因为「已经在放
同一条」跳过 toggleClip —— 唯一带停止语义的那条路径根本没走到。

改法:把「这一下是停还是念」抽成纯函数 ttsSpeakIntent(playback, messageId, clip),
判据落在那条唯一播放通道上(它是「有没有在响」的唯一真相),不再让三处各自解释
「播放中」是什么意思。ChatScreen 的 onSpeak 据它分派:Stop -> voicePlayer.stop(),
Speak -> viewModel.speak(message)。另需 clip.file != null:通道处于 Loading 时也算
active,但那一刻文件可能还没到手,判成 Stop 会停掉一次没起播的载入。

P2-1 「切走再切回补响旧朗读」—— 改成按「这一屏还在不在」判,而不是按时间:新增纯函数
ChatUiState.dropPendingTtsAutoPlay()(只对 autoPlay && file == null 的那一格置假),
由 ChatScreen 的 DisposableEffect.onDispose 调用。放弃「记发起时刻 + 超龄丢」是因为
时间不是这件事的判据 —— 合成一段长文本本来就可能花十几秒,按秒数丢会把「人就在看着
屏幕」的那一次也一起丢掉。配套回归点:applyTtsOutcome 的 autoPlay 改成从旧那一格
继承(原先硬写 false);否则作废之后结果晚到又会把闸门抬回真,等于没修。

P2-2 「TTS 缓存键缺 baseUrl」—— cacheName 的哈希输入改为
baseUrl.trimEnd('/') + "\n" + sessionId + "\n" + text。换一台服务器后,同一个
(sessionId, text) 在那边可能是另一个声音(人格映射 / 发音人 / 合成实现都是那台的),
命中上一台合成的 WAV 就是念错人。trimEnd('/') 归一,http://a/ 与 http://a 不分两份
缓存。类 KDoc 与 cacheName 的 KDoc 同步改写。

测试:TtsSpeakStateTest 11 -> 20(+9),SpeechClientTest 8 -> 10(+2)。
全量 testDebugUnitTest --rerun-tasks:138 suites / 1726 tests / 0 failures /
0 errors / 0 skipped(基线 138/1715,+11)。

仍未验:真机出声;请求在飞时没有「取消在飞请求」;(sessionId, text) 这份缓存对服务端
换发音人仍无失效手段(rev-w75 已同意这半边可接受)。
2026-10-26 23:52:23 +08:00
AskaEth 504c500e20 W75 她的消息朗读 —— /audio/speech 只走 owner Bearer,字节嗅探 WAV,缓存落 media/
后端回执三十八 §3 点名「独立真机 Bearer 样例待安卓首次接入时顺手确证」,本批次就是
这次接入:在她的消息气泡外侧加一枚「朗读」图标,点按 → 剥标签取纯文本 → POST
/api/v1/audio/speech → 播放 WAV。只做手动点按,不做自动朗读 / 设置页开关 / 实时流。

## 契约(Cyrene_v2/Docs/api-client.md,2246 行)

- L126(§1.2 U6 表)、L1603(v0.97 补写)、L1609(v0.96 sessionId 增量)、
  L561(416 非信封旁注,同句明说 /audio/speech 的 200 也不是信封)、
  L2173(voice / format 不生效)。
- POST /api/v1/audio/speech,owner-only 双凭据门:Authorization: Bearer <owner JWT>
  **或** X-API-Key,两分支同 authorize(RoleOwner);无 / 无效 → 401,错误形状同 §4.1
  {error:{code,message}} 信封。
- 请求体 {text, voice?, format?, instruction?, sessionId?}。voice 接受但不生效、
  format 不生效;发音人由 sessionId 的生效人格解析(会话绑定 → 全局默认 → 配置 default),
  **省略 sessionId 不等于没有角色**。
- 成功 200 + 原始音频字节(非信封),产物恒 WAV,**按字节嗅探,不要照抄 Content-Type**。
- 错误码:空 text → 400;500 voice_unavailable / tts_failed / tts_persona_unmapped /
  tts_no_character / tts_empty(后者带响应头 X-Voice-Provider: silent)。

## 只走 Bearer,不加 API Key 输入框

主人 2026-10-26 拍板:本端 owner 已持有 JWT,再往设置里塞一个 API Key 输入框只是把
同一份凭据复制一遍、多一个会过期的状态。SpeechClient 的 tokenProvider 直接接
AuthManager 的当前 token,401 交给既有的 unauthorizedPolicy 收口。

## 落盘

新增 data/api/SpeechApi.kt、data/voice/SpeechClient.kt、domain/chat/VoiceSpeak.kt、
ui/chat/VoiceSpeak.kt;改 ChatViewModel / MessageBubble / ChatScreen。AppContainer 未动
(SpeechClient 在 ViewModel 里 lazy 构造,baseUrl 与 token 都取自 container.authManager)。

- 缓存名 tts_<sha256("sessionId\ntext")[:32]>.wav,落 VoiceStore.directory(cacheDir)
  即 <cacheDir>/media/,**不进 documents/** —— 系统可回收,丢掉重下一份即可。
  代价:服务端换发音人后同一段文字会命中旧音频,按 (sessionId, text) 寻址是刻意的。
- 字节嗅探 isWavBytes:size ≥ 12 + RIFF@0 + WAVE@8。200 + JSON 信封(哪怕
  Content-Type 谎报 audio/wav)判 SpeechNotAudioException,且不落缓存。
- 两档失败:TtsFailure.Unconfigured 只认 tts_persona_unmapped / tts_no_character
  两个 code(引导去设人格),其余一律 Soft(可重试)。不认识的 code 不猜,落 Soft。
- 失败只写 ttsClip.failure 气泡脚注,**不写 state.error 横幅** —— 朗读失败不该盖住聊天。
- 播放交给唯一的播放通道(ui.chat.VoicePlayer),键 tts:<messageId> 与语音副本的键
  分得开 ⇒ 「念这一条」与「放这条消息的语音副本」天然互斥,同一时刻只有一个声音。
- 起播在渲染层那个 LaunchedEffect(ttsClip.clipId, file, autoPlay):三键缺一不可 ——
  「放 → 停 → 再点同一条」时 (clipId, file) 一对没变,只有 autoPlay 由假改真才能再点着。

## 状态机抽成纯函数(可测)

VM 拖不进 JVM 测试(要 AppContainer / viewModelScope,工程里没有 coroutines-test),
所以这条路的判据都落在两个纯函数上,VM 只负责循环 CAS + launch:
- ChatUiState.beginTtsSpeak(messageId, clipId):返回 null = 这一次点击该被忽略
  (同一条正在飞)。已落好的(file != null)算「再放一遍」、失败过的算「用户重试」,
  两种都放行 —— 防抖判据只认 pending 这一种。
- ChatUiState.applyTtsOutcome(messageId, clipId, result):clipId 已经不是这一格 ⇒
  原样返回、一个字节都不写(否则「点了 B,界面在 A 下面报错」)。

## 验证

./gradlew.bat --offline compileDebugKotlin → BUILD SUCCESSFUL
./gradlew.bat --offline assembleDebug       → BUILD SUCCESSFUL(38 tasks)
./gradlew.bat --offline testDebugUnitTest --rerun → BUILD SUCCESSFUL
  138 suites / 1715 tests,0 failures / 0 errors / 0 skipped
  (基线 134 / 1675;+4 suites +40 tests 全来自新增的
   SpeechApiTest 4 / SpeechClientTest 8 / VoiceSpeakTest 16 / TtsSpeakStateTest 11)
SpeechApiTest 与 SpeechClientTest 都跑真 com.sun.net.httpserver.HttpServer + 真
OkHttp/Retrofit,不是 mock;SpeechClientTest 用 Files.createTempDirectory 验真落盘与
第二次同文本请求计数仍为 1。

未验:真机出声。已知取舍——请求在飞时图标 clickable(enabled = !speakPending) 点不动,
没有做「取消在飞请求」。
2026-10-26 23:25:42 +08:00
19 changed files with 2292 additions and 86 deletions

No files matched your search

@@ -0,0 +1,67 @@
package top.yeij.cyrene.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Streaming
/**
* 一段待朗读的文本(`POST /api/v1/audio/speech` 的请求体,api-client.md §1.2 U6 / 行 126)。
*
* ## 只有两个字段,是**刻意的**
* 契约给了五个键 `{text, voice?, format?, instruction?, sessionId?}`,但其中三个在本端**不生效**:
* - `voice`:接受但不生效 —— 发音人**不由它决定**;
* - `format`:不生效 —— 产物恒为 WAV(响应也明说「别照抄 `Content-Type`」,见 [SpeechApi]);
* - `instruction`:只影响表达方式,本端当前的产品口径里没有「怎么说」这一档输入。
*
* 发一个服务端会忽略的键,只会让排查时多一条**看起来有含义、实际没有**的字段。所以这里只发
* 真正起作用的那两个 —— 尤其是 [sessionId]:发音人由它**那条会话的生效人格**解析
* (会话绑定人格 → 全局默认人格 → 都没有就取服务端配置的 default),**省略它不等于没有角色**。
*
* [sessionId] 为 `null` 时字段**整个缺席**(`ApiClientFactory.json` 的 `explicitNulls = false`),
* 而不是发一个 `"sessionId": null`。
*/
@Serializable
data class SpeechRequestDto(
@SerialName("text") val text: String,
@SerialName("sessionId") val sessionId: String? = null,
)
/**
* 语音合成(契约 **v0.96 增量 / v0.97 补写**,api-client.md §1.2 **U6** `POST /api/v1/audio/speech`)。
*
* ## 鉴权:owner-only,双凭据门
* `Authorization: Bearer <owner JWT>` **或** `X-API-Key`,两个分支在服务端落到同一个
* `authorize(RoleOwner)`。本端只走前者(主人 2026-10-26 拍板:不设 API Key 输入框),令牌由
* [ApiClientFactory.create] 挂在 OkHttpClient 上的 `AuthInterceptor` 自动注入,本接口不碰它。
* 无 / 无效凭据 → `401`,错误体是 §4.1 那个 `{error:{code,message}}` 信封。
*
* ## 响应**不是信封**
* 成功是 `200` + **原始音频字节**(与 [MediaApi] 同一个形状),所以返回 [ResponseBody] 由上层
* 读字节并负责关闭,[Streaming] 让它不一次性读进内存。
*
* ⚠️ **产物恒为 WAV,按字节嗅探判定,不要照抄 `Content-Type`** —— 那条头的实际值不在契约里
* 承诺(§4.1 行 561 的同句旁注就是这个意思)。嗅探在 [top.yeij.cyrene.data.voice.SpeechClient]。
*
* 失败时的错误码(全部是 `{error:{code}}` 信封):
* `400`(空 text)、`500 voice_unavailable`(未接语音)、`500 tts_failed`、
* `500 tts_persona_unmapped`(人格没有发音人映射)、`500 tts_no_character`(连生效人格都没有)、
* `500 tts_empty`(拿到的是静默空音频,同时响应头带 `X-Voice-Provider: silent`)。
*
* @see top.yeij.cyrene.data.voice.SpeechClient
*/
interface SpeechApi {
/**
* 把 [body] 合成为一段 WAV 字节。
*
* 路径**不带前导 `/`**(与 [ChatApi] 一致):Retrofit 的 `baseUrl` 已含 `/` 结尾,
* 带前导 `/` 会把它当成根路径、把部署在子路径下的服务端地址整段丢掉。
*/
@Streaming
@POST("api/v1/audio/speech")
suspend fun speech(@Body body: SpeechRequestDto): Response<ResponseBody>
}
@@ -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,
)
@@ -0,0 +1,219 @@
package top.yeij.cyrene.data.voice
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SpeechApi
import top.yeij.cyrene.data.api.SpeechRequestDto
import top.yeij.cyrene.data.repository.DocumentCache
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.readBounded
import java.io.File
import java.security.MessageDigest
/** 服务端回的字节**不是**一段 WAV(200 却回了 JSON 信封 / HTML 错误页 / 别的东西)。 */
class SpeechNotAudioException : Exception("speech response is not wav")
/**
* 一次 TTS 请求的**字节嗅探**:真的是 RIFF/WAVE 吗?
*
* 契约(api-client.md §4.1 行 561 的旁注、§1.2 U6)只说「产物恒为 WAV」,**没有**承诺
* `Content-Type` 是什么,并明写「不要照抄 `Content-Type`」。所以判据只能落在字节上:
* 头 4 字节 `RIFF` + 偏移 8 起的 4 字节 `WAVE`(RIFF 容器里夹一段 `fmt ` 那种标准布局)。
*
* 为什么要判:`MediaPlayer` 拿到非音频字节时抛的是它自己那套 `setDataSource` 异常,
* 与本端「取字节失败」的语义完全不同;而 200 回 JSON 信封恰恰是**最可能**的一种
* 「网关/反代把错误包了一层」的形态 —— 提前拦在这里,失败就被如实归到「解析失败」那一档,
* 而不是在播放器里炸成一个没人认识的东西。
*/
fun isWavBytes(bytes: ByteArray): Boolean =
bytes.size >= WAV_HEADER_BYTES &&
bytes[0] == 'R'.code.toByte() &&
bytes[1] == 'I'.code.toByte() &&
bytes[2] == 'F'.code.toByte() &&
bytes[3] == 'F'.code.toByte() &&
bytes[8] == 'W'.code.toByte() &&
bytes[9] == 'A'.code.toByte() &&
bytes[10] == 'V'.code.toByte() &&
bytes[11] == 'E'.code.toByte()
/** RIFF 头里本端要看的那几个字节都在前 12 个之内。 */
private const val WAV_HEADER_BYTES = 12
/** 本端一次读得下、也放得动的合成音频上限(与语音副本同一把尺子:附件上限 20 MB)。 */
private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
/**
* 「把一段文字念出来」的取字节层(`POST /api/v1/audio/speech` 的唯一调用方,契约 api-client.md
* §1.2 **U6**,v0.96 增量 + v0.97 补写)。
*
* ## 凭据:只走 Bearer,不设 API Key
* 服务端是 owner-only 的**双凭据门**(Bearer owner JWT **或** `X-API-Key`,两个分支落到同一个
* `authorize(RoleOwner)`)。本端只走前者:令牌由 [ApiClientFactory.create] 挂在 OkHttpClient 上的
* `AuthInterceptor` 注入,并顺带享受「带令牌仍 401 ⇒ 单飞续期并重试一次」。
* 主人 2026-10-26 拍板**不加 API Key 输入框** —— 那会多一份要保管、要轮换、要泄漏的凭据,
* 而本端本来就已经有 owner 会话。
*
* ## 发音人从哪来:`sessionId`,不是 `voice`
* 契约里 `voice` **接受但不生效**、`format` **不生效**。真正决定发音人的是 `sessionId` 那条会话的
* **生效人格**(会话绑定 → 全局默认 → 服务端配置的 default)。所以 [speak] 一定要拿到当前会话 id
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。
*
* ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次
* 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
* `documents/` 根下,不扩大暴露面一个字节)。
*
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
*
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
* 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit]
* (非空才算)——它是 `cacheDir` 下的东西,系统随时可回收,被回收之后下次朗读重新请求,
* **不是**错误。
*
* ## 错误怎么分类
* 非 2xx 一律走 [ApiErrorMapper](`401` ⇒ [CyreneError.Unauthorized],其余 ⇒
* [CyreneError.Server] 且**保留服务端那句 `code`**——`tts_persona_unmapped` /
* `tts_no_character` 这两档的文案完全不同,折成一句话就说不清了)。
* 分类成「柔和提示」还是「明确降级」是纯函数的事,在
* [top.yeij.cyrene.domain.chat.ttsFailureOf] 里,本类不做产品判断。
*
* @param baseUrlProvider 每次现读服务器地址(换服务器 ⇒ 客户端重建,与 `AppContainer` 同一纪律)。
* @param apiFactory 按 baseUrl 造 [SpeechApi];收成 lambda 是为了单测能塞一个直连本地 HTTP 替身的
* 实例,而不必造一整套依赖注入。
*/
class SpeechClient(
private val baseUrlProvider: () -> String,
private val tokenProvider: () -> String? = { null },
private val unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
private val cacheDir: File,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val apiFactory: (String) -> SpeechApi = { baseUrl ->
ApiClientFactory.create(
baseUrl,
SpeechApi::class.java,
tokenProvider,
unauthorizedPolicy,
)
},
) {
/** 按 baseUrl 缓存 Retrofit 实例(换地址才重建),与 `AppContainer.mediaApi` 同一手法。 */
private var cachedApi: SpeechApi? = null
private var cachedBaseUrl: String? = null
private fun api(): SpeechApi {
val baseUrl = baseUrlProvider()
val cached = cachedApi
if (cached != null && cachedBaseUrl == baseUrl) return cached
return apiFactory(baseUrl).also {
cachedApi = it
cachedBaseUrl = baseUrl
}
}
/**
* 把 [text] 合成成一段 WAV 并落盘,返回**可直接交给 `MediaPlayer`** 的那份文件。
*
* [sessionId] 是当前会话 id:它决定发音人(见类 KDoc)。传 `null` 表示「本端不知道是哪个会话」,
* 服务端会退到全局默认人格 —— 调用方**不要**为了让字段好看而编一个 id 出来。
*/
suspend fun speak(text: String, sessionId: String?): Result<File> = withContext(dispatcher) {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
// 服务端对空 text 回 `400`;本端连请求都不发(省一次往返,也省一次无意义的失败)。
return@withContext Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
}
val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId, baseUrlProvider()))
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
try {
val response = api().speech(SpeechRequestDto(text = trimmed, sessionId = sessionId))
val body = response.body()
when {
response.isSuccessful && body != null -> body.use { stream ->
val bytes = readBounded(stream.byteStream(), MAX_SPEECH_BYTES)
when {
// 超过上限:本端一次读不下(合成音频远小于它,走到这里说明对面不对劲)。
bytes == null -> Result.failure(SpeechNotAudioException())
// 0 字节:与 MediaRepository 同一口径,判解析失败而不是「拿到一段静音」。
bytes.isEmpty() -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
// 200 却不是 WAV:**绝不**把 JSON / HTML 喂给 MediaPlayer(见 isWavBytes)。
!isWavBytes(bytes) -> Result.failure(SpeechNotAudioException())
else -> cache(file, bytes)
}
}
// 2xx 却没有响应体:与 apiCall 同一口径。
response.isSuccessful -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
private fun cache(file: File, bytes: ByteArray): Result<File> = try {
DocumentCache.write(file, bytes)
Result.success(file)
} catch (_: Exception) {
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——绝不退回「把字节留在内存里等播放器
// 读」那条路:MediaPlayer 只吃路径,而缓存目录写不进去时多半写得进的地方也没有。
file.delete()
Result.failure(DocumentCacheUnavailableException())
}
private companion object {
/** 合成缓存的名字前缀:与语音副本(服务端内容寻址名)在同一目录里区分开。 */
const val NAME_PREFIX = "tts_"
/** sha256 前 32 位十六进制,与服务端存储名 / [DocumentCache.localName] 同一长度。 */
const val NAME_HEX_LENGTH = 32
/**
* 缓存名 = `tts_<sha256(baseUrl \n sessionId \n text)[:32]>.wav`。
*
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
* 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
*
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
*/
fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(
"${baseUrl.trimEnd('/')}\n${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8),
)
val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH)
return NAME_PREFIX + hex + ".wav"
}
}
}
@@ -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] 上(纯函数)。
*
@@ -0,0 +1,167 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
/**
* 「把这条消息念出来」这条路上,**本端要说的那几句话**,以及失败该分到哪一档。
*
* 这个文件里全是纯函数 / 常量,没有 Android、没有网络、没有状态:单测直接调(见
* `VoiceSpeakTest`)。真正的取字节在 `data.voice.SpeechClient`,播放复用 `ui.chat.VoicePlayer`。
*/
/**
* 从一条消息解析出来的气泡里抽出**要念的那段纯文本**。
*
* ## 为什么只念 `body`
* [ParsedBubble] 把一条气泡拆成三段:`head`(说话人抬头)、`body`(正文)、`tail`(旁白 / 动作)。
* 念出来时**只念正文**:
* - `head` 是「昔涟:」这种抬头,念出来像在念字幕;
* - `tail` 里的旁白(`*她笑了笑*`)与动作(`[动作]`)是**舞台指示**,语音合成念它只会变成一串
* 莫名其妙的字面量。
*
* ## 为什么按 `\n` 拼、且空就回 `null`
* 一条消息可能被切成多个气泡(连续说两段);`\n` 让合成的停顿落在段落之间,比空格自然。
* 全都为空(纯附件消息、纯旁白消息、或者解析完什么都不剩)时回 `null` —— 调用方据此**不显示**
* 朗读入口,而不是让用户点一个必然拿到 `400` 的按钮。
*
* 取完 `trim`:首尾空白会变成合成音频开头 / 结尾的静音,没有任何用处。
*/
fun ttsTextOf(bubbles: List<ParsedBubble>): String? =
bubbles.asSequence()
.map { it.body }
.filter { it.isNotBlank() }
.joinToString("\n")
.trim()
.takeIf { it.isNotEmpty() }
/**
* 一条消息(`content` + `mode`)里**要念的那段文本**;`null` = 没有可念的正文。
*
* 这是「这条消息能不能朗读」的**唯一判据**:`ChatViewModel.speak` 用它决定发不发请求,
* `MessageBubble` 用它决定画不画那枚图标。两处必须是同一个答案 —— 画了却发不出去、或者能发
* 却不给入口,都是 bug。
*
* ## 为什么要看 `mode`
* `chat`(或缺席)是「按 markup 切气泡的聊天式渲染」;其余 mode 下气泡里放的是**原文**,
* 一条 markup 都不解析(`MessageBubble` 就是这么画的)。朗读要念的显然也是**用户看到的那段字**:
* - 聊天式 ⇒ 走 [ttsTextOf] 剥掉抬头 / 旁白;
* - 其余 ⇒ 原文原样,只 `trim`。
*
* 判据本身与 `MessageBubble` 里那句 `isChat` 一字不差,刻意留在这里一份:多写一份就会出现
* 「界面按一种切法、朗读按另一种切法」的分叉。
*/
fun ttsTextOf(message: HistoryMessageDto): String? =
if (message.mode.isNullOrBlank() || message.mode == "chat") {
ttsTextOf(parse(message.content).filterNot { it.isEmpty })
} else {
message.content.trim().takeIf { it.isNotEmpty() }
}
/**
* 一次朗读失败该归到哪一档 —— 判据只看**服务端到底说了什么**,不看 HTTP 码的表面。
*
* ## 为什么分两档
* 这两档对用户的意思完全不同,混成一句就等于什么都没说:
* - [Soft]:**这会儿**没读出来(网络断了、令牌过期、网关抽风)。过一会儿再点是**有意义**的,
* 所以文案只陈述事实、不诱导、也不吓人;
* - [Unconfigured]:**配置**上就没这条路的。服务端明说了是哪种缺(`tts_persona_unmapped`
* 没有发音人映射 / `tts_no_character` 连生效人格都没有),重试**永远不会**成功 —— 所以文案要说
* 清「缺什么」,并且**不提重试**(提了就是在骗人点)。
*/
sealed interface TtsFailure {
/** 要说给用户的那一句话。 */
val message: String
/** 再点一次**可能**有用吗?([Unconfigured] 恒 `false`:重试永远不会成功。) */
val retryable: Boolean
/** 这会儿没读出来:不解释原因(本端确实不知道是哪一段断了),也不诱导重试。 */
data object Soft : TtsFailure {
override val message: String = TTS_SOFT_FAILURE_MESSAGE
override val retryable: Boolean = true
}
/**
* 服务端明说了配置缺一块。[code] 就是服务端那个错误码(原样留着,排查时对得上号)。
*/
data class Unconfigured(val code: String) : TtsFailure {
override val message: String = when (code) {
TTS_CODE_PERSONA_UNMAPPED -> TTS_PERSONA_UNMAPPED_MESSAGE
TTS_CODE_NO_CHARACTER -> TTS_NO_CHARACTER_MESSAGE
// 兜底:服务端回了一个本端还不认识的「配置缺失」类错误码。不猜它的意思,
// 但也不装作是临时故障——它毕竟带着一个明确的 code。
else -> TTS_UNCONFIGURED_MESSAGE
}
override val retryable: Boolean = false
}
}
/**
* 把一次朗读的异常分到 [TtsFailure] 两档里。
*
* 只有「服务端明确回了一个**配置缺失**类的 `code`」才进 [TtsFailure.Unconfigured];其余一切
* ——网络失败、解析失败、401(令牌过期,续期后还能用)、`voice_unavailable` / `tts_failed` /
* `tts_empty`(对面这会儿不行,不是本端的配置问题)——都进 [TtsFailure.Soft]。
*
* `tts_empty`(静默空音频)刻意归到柔和档:那是**这一次**合成出来的东西不能用,换个时间再试
* 完全可能成功,说成「配置没配好」就是错的。
*/
fun ttsFailureOf(error: Throwable): TtsFailure {
val cause = (error as? CyreneException)?.error ?: return TtsFailure.Soft
return when (cause) {
is CyreneError.Server -> when (cause.code) {
TTS_CODE_PERSONA_UNMAPPED,
TTS_CODE_NO_CHARACTER,
-> TtsFailure.Unconfigured(cause.code)
else -> TtsFailure.Soft
}
is CyreneError.Unauthorized -> TtsFailure.Soft
is CyreneError.Local -> TtsFailure.Soft
}
}
/**
* 朗读在某一条播放通道上的**键**:`tts:<messageId>`。
*
* 播放通道只有一条(`ui.chat.VoicePlayer` 的那个实例,与语音副本共用),所以键必须与语音副本
* (键 = `voice` 的 `messageId`)分得开:前缀就是那道分界线。同一条消息的朗读与它的语音副本
* 因此**互斥**(点其中一个会停掉另一个)——这是对的,同一时刻只该有一个声音。
*/
fun ttsClipIdOf(messageId: String): String = TTS_CLIP_PREFIX + messageId
/** 朗读入口那枚图标的读屏名字(也是设备验收时 `uiautomator` 的 `content-desc`)。 */
const val TTS_SPEAK_DESCRIPTION: String = "朗读这条消息"
/** 正在朗读时那枚图标的读屏名字(点它 = 停)。 */
const val TTS_STOP_DESCRIPTION: String = "停止朗读"
/** 正在取合成音频时那枚图标的读屏名字。 */
const val TTS_LOADING_DESCRIPTION: String = "正在准备朗读"
/** 柔和档:这会儿没读出来。**不说原因**(本端确实不知道断在哪一段),也不提重试。 */
const val TTS_SOFT_FAILURE_MESSAGE: String = "这会儿没能把这段读出来"
/** `tts_persona_unmapped`:人格有,但这个人格没有配发音人。 */
const val TTS_PERSONA_UNMAPPED_MESSAGE: String = "这个角色还没配好发音人"
/** `tts_no_character`:连生效人格都没有(会话没绑定、也没有全局默认人格)。 */
const val TTS_NO_CHARACTER_MESSAGE: String = "这个会话还没有配好发音人"
/** 服务端回了配置缺失类错误码、但本端还不认识那一个时的兜底文案。 */
const val TTS_UNCONFIGURED_MESSAGE: String = "这个角色还没有配好朗读的发音人"
/** 朗读播放通道的键前缀(与语音副本的键 `messageId` 区分开)。 */
const val TTS_CLIP_PREFIX: String = "tts:"
/** 服务端错误码:人格没有发音人映射。 */
const val TTS_CODE_PERSONA_UNMAPPED: String = "tts_persona_unmapped"
/** 服务端错误码:连生效人格都没有。 */
const val TTS_CODE_NO_CHARACTER: String = "tts_no_character"
@@ -506,6 +506,33 @@ fun ChatScreen(
}
}
// 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:<messageId>`,
// 与语音副本天然互斥——点一个停另一个)。键里带 `file`:`null → 文件` 那一刻才是真正能播的
// 时刻。`autoPlay` 只用一次,理由与上面那条完全相同(用户可能已经切走了)。
//
// ⚠️ `autoPlay` 必须进键:用户「放 → 停 → 再点同一条」时 `(clipId, file)` 一对**没变**
// (同一份缓存文件),只按那一对做键就再也不会起播。置假那一次会把键改成 `false`、下一轮
// 点击改回 `true`,于是每一点都重新点着一次;置假本身触发的重入在第一行就返回了,不成环。
//
// 起播之后这一格**不再变**:播放走到哪一步是播放通道的事,VM 不需要知道(见 `TtsClip` 的
// 类 KDoc)。
val ttsClip = state.ttsClip
LaunchedEffect(ttsClip?.clipId, ttsClip?.file, ttsClip?.autoPlay) {
val clip = ttsClip ?: return@LaunchedEffect
val file = clip.file ?: return@LaunchedEffect
if (!clip.autoPlay) return@LaunchedEffect
viewModel.consumeTtsAutoPlay()
if (!voicePlayer.state.value.isActiveFor(clip.clipId)) {
voicePlayer.toggleClip(clip.clipId) { Result.success(file) }
}
}
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
DisposableEffect(viewModel) {
onDispose { viewModel.dropPendingTtsAutoPlay() }
}
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
@@ -759,6 +786,11 @@ fun ChatScreen(
// 语音播放(接收侧):状态来自**这一个会话**的那条播放通道,
// 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。
voicePlayback = voicePlayback,
// 朗读(W75):**只有这一条**的朗读状态进来(对不上的当没点过)。
// 播放状态仍是同一条通道——朗读的键是 `tts:<messageId>`,所以两枚
// 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。
ttsClip = state.ttsClip?.takeIf { it.messageId == item.message.id },
ttsPlayback = voicePlayback,
onDocumentClick = { ref ->
previewNotice = null
previewTarget = DocumentTarget.of(ref)
@@ -774,6 +806,20 @@ fun ChatScreen(
onEdit = viewModel::startEdit,
onDelete = viewModel::startDelete,
onVoiceToggle = voicePlayer::toggle,
// 朗读:请求、失败分类、落盘全在 VM;但**这一下是停还是念**必须在
// 这里分(判据是纯函数 [ttsSpeakIntent])。直通 `viewModel::speak`
// 会让「停止朗读」变成空操作(P1-1):播放中 `TtsClip.pending` 是假
// ⇒ VM 的防抖放行、整格换成新 pending、缓存命中秒回,而下面那个起播
// effect 又因为「已经在放同一条」跳过 `toggleClip`——三处各自都对,
// 合起来点了没反应。唯一带停止语义的入口在播放通道上,所以在这一层分。
onSpeak = { message ->
val clip = state.ttsClip
?.takeIf { it.messageId == message.id }
when (ttsSpeakIntent(voicePlayer.state.value, message.id, clip)) {
TtsSpeakIntent.Stop -> voicePlayer.stop()
TtsSpeakIntent.Speak -> viewModel.speak(message)
}
},
)
is ChatDisplayItem.Pending -> PendingBubble(
@@ -35,6 +35,7 @@ import top.yeij.cyrene.data.repository.MessageEditDraft
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.PagedMessages
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.data.voice.SpeechClient
import top.yeij.cyrene.data.repository.applyMessageEdit
import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages
@@ -71,6 +72,9 @@ import top.yeij.cyrene.domain.chat.parallelForSend
import top.yeij.cyrene.domain.chat.parallelWireValue
import top.yeij.cyrene.domain.chat.readBounded
import top.yeij.cyrene.domain.chat.toDocumentFailure
import top.yeij.cyrene.domain.chat.ttsClipIdOf
import top.yeij.cyrene.domain.chat.ttsFailureOf
import top.yeij.cyrene.domain.chat.ttsTextOf
import top.yeij.cyrene.domain.chat.turnBucketOf
import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor
import top.yeij.cyrene.domain.chat.voiceClipRejection
@@ -636,6 +640,76 @@ fun ChatUiState.applyMessageActionFailure(
}
}
/**
* 点「朗读」这一步的状态迁移(纯函数,VM 与测试共用)。
*
* 返回 `null` = **这一次点击该被忽略**:同一条消息的请求还在飞([TtsClip.pending])。这是防抖的
* 唯一判据。已经落好(`file != null`)或已经失败过(`failure != null`)的**不算**在飞 ——
* 前者是「再放一遍」,后者是用户手动重试,两种都该放行。
*
* 返回新状态 = 放行,并且这一格已经换成「请求在飞」。`autoPlay` 保持默认的真:字节到手那一刻
* 要起播一次(见 [TtsClip.autoPlay]),置假只在真的起播之后(`ChatViewModel.consumeTtsAutoPlay`)。
*/
fun ChatUiState.beginTtsSpeak(messageId: String, clipId: String): ChatUiState? {
val current = ttsClip
if (current != null && current.clipId == clipId && current.pending) return null
return copy(ttsClip = TtsClip(messageId, clipId, null, null, false))
}
/**
* 请求回来了,把结论落到这一格(纯函数,VM 与测试共用)。
*
* 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、
* 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。
*
* `autoPlay` **从旧那一格继承**而不是重置成真:用户可能在请求落地之前就离开了这一屏
* ([ChatUiState.dropPendingTtsAutoPlay] 把这一位置了假),结果晚到之后若又把它抬回真,
* 切回来时就会突然响一段刚才点的朗读 —— 那正是要拦的那件事。
*
* 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的
* 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。
*/
fun ChatUiState.applyTtsOutcome(
messageId: String,
clipId: String,
result: Result<File>,
): ChatUiState {
val current = ttsClip
if (current == null || current.clipId != clipId) return this
val autoPlay = current.autoPlay
return result.fold(
onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false, autoPlay)) },
onFailure = { error ->
val failure = ttsFailureOf(error)
copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable, autoPlay))
},
)
}
/**
* 这一屏要走了:把**还没到手**的那一次自动起播作废(纯函数,VM 与测试共用)。
*
* ## 为什么需要它
* 起播闸门活在渲染层那个 `LaunchedEffect` 里(见 `ChatScreen`),而它只在**这一屏在的时候**
* 才会被跑一次。用户点了朗读之后立刻切走(换会话 / 进详情页),合成还在飞;结果落地的瞬间这一屏
* 不在,闸门没被消费过;等他切回来,那个 effect 一挂上就以「文件到了 + `autoPlay` 还是真」为由
* 起播 —— 突然响一段**已经过去**的朗读,不是他此刻的动作。
*
* ## 为什么在「离开」这一刻判,而不是给 clip 记一个发起时刻再按超龄丢
* 时间不是这件事的判据:合成一段长文本本来就可能花上十几秒,按秒数丢会把**人就在看着屏幕**
* 的那一次也一起丢掉(点了却不出声)。「这一屏还在不在」才是真正的分界 —— 离开时把没消费的
* 自动起播划掉,正好只拦掉「他不在」的那些。
*
* ## 只动「还没到手」的那一格
* 文件已经在手(`file != null`)说明闸门要么已经跑过(`autoPlay` 已被 [consumeTtsAutoPlay]
* 置假)、要么就是正在起播的这一刻:这两种情况一律原样返回,绝不打断一次真的播放。
*/
fun ChatUiState.dropPendingTtsAutoPlay(): ChatUiState {
val clip = ttsClip ?: return this
if (!clip.autoPlay || clip.file != null) return this
return copy(ttsClip = clip.copy(autoPlay = false))
}
sealed interface ChatDisplayItem {
val key: String
@@ -857,6 +931,17 @@ data class ChatUiState(
* (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。
*/
val voiceNotice: String? = null,
/**
* 「把这一条念出来」这一次的产物(W75,`POST /api/v1/audio/speech`);null = 用户还没点过朗读。
*
* **只有一格**:用户同一时刻点的是**一条**消息(见 [TtsClip])。它同时承载三件事 ——
* 请求在飞([TtsClip.pending])、字节已经落到本机([TtsClip.file])、上一次为什么没成
* ([TtsClip.failure])。音频本体是**文件路径**而不是字节,理由同 [voiceReply]:每次状态
* `copy` 都在几 MB 的数组上做相等比较是不可接受的。
*
* 「正在响」不在这一格里 —— 那归唯一的播放通道(`ui.chat.VoicePlayer`),键是 [TtsClip.clipId]。
*/
val ttsClip: TtsClip? = null,
) {
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
val items: List<ChatDisplayItem>
@@ -1541,7 +1626,7 @@ class ChatViewModel(
emitPlannedMessages(
plan = imageSendPlan(text, images, documents),
clearDraft = true,
applySendPreference = true,
fromShare = false,
localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX,
)
}
@@ -1622,6 +1707,92 @@ class ChatViewModel(
}
}
/**
* 用户点了某条消息上的「朗读」(W75,`POST /api/v1/audio/speech`)。
*
* ## 念什么
* 只念**她**的消息(`assistant`):`user` 那一侧点朗读没有意义,而且服务端的发音人由
* **会话的生效人格**决定 —— 念主人自己说的话会用她的声音,那是错的。判据就是角色这一条,
* 其余(要念哪段字)全交给纯函数 [ttsTextOf]:它剥掉抬头与旁白,只留正文;一个字都不剩
* (纯附件 / 纯旁白)时**直接不发请求** —— 服务端对空 `text` 回 `400`,本端没有理由先跑一趟。
*
* ## 防重入
* 同一条消息的请求**在飞的时候再点一次**直接忽略:`_state.ttsClip` 已经是那条消息的
* `pending` 态。用户点**另一条**则照常发起(旧那条被整格替换,它的音频文件留在缓存里,
* 下次点它命中、不发请求 —— 见 `data.voice.SpeechClient`)。
*
* 播放那半边**不在这里**:这里只把「字节到手」写进 [ChatUiState.ttsClip],起播由渲染层那个
* `LaunchedEffect` 交给唯一的播放通道(`ui.chat.VoicePlayer`)。分开是必须的 —— VM 不该碰
* `MediaPlayer`,而播放通道要能在「换了一条语音副本」时独立停下,不必回头改这一格。
*
* ## 失败
* 分类是纯函数 [ttsFailureOf]:`tts_persona_unmapped` / `tts_no_character` 这两档是**配置**缺失
* (重试永远不会成功,文案要说清缺什么),其余一律柔和提示。**不重试**:这条路上没有任何
* 自动重试 —— 用户再点一次就是那次重试,而配置缺失那一档连这句话都不该说。
*
* 失败**不弹窗、不走 [ChatUiState.error] 横幅**:那是「这一次请求失败了」的全局通道,而朗读
* 是一次附带的小动作,把它抬成横幅会盖住真正的发送失败。文案挂在**这条消息**下面(见
* `MessageBubble`)。
*/
fun speak(message: HistoryMessageDto) {
if (message.role == "user") return
val text = ttsTextOf(message) ?: return
val clipId = ttsClipIdOf(message.id)
// 判据与落位都在纯函数里([beginTtsSpeak] / [applyTtsOutcome]):同一条正在飞 ⇒ 这里
// 拿回 null,直接返回。用 compareAndSet 而不是 update,是因为「放行与否」和「写进哪一
// 格」必须是同一次读写的结论——分开写会让并发的两次点击都以为自己放行了。
while (true) {
val current = _state.value
val begun = current.beginTtsSpeak(message.id, clipId) ?: return
if (_state.compareAndSet(current, begun)) break
}
viewModelScope.launch {
val result = speechClient.speak(text, sessionId)
_state.update { it.applyTtsOutcome(message.id, clipId, result) }
}
}
/**
* 这一次朗读的**自动起播**已经用掉了(渲染层在真的起了播放之后调)。
*
* 与 [consumeVoiceReplyAutoPlay] 同一条纪律、同一个理由:请求发出去之后用户可能已经切到别的
* 会话 / 别的页面,切回来时不该突然响一段刚才点的朗读(见 [TtsClip.autoPlay])。
*/
fun consumeTtsAutoPlay() {
_state.update { state ->
val clip = state.ttsClip
if (clip == null || !clip.autoPlay) state else state.copy(ttsClip = clip.copy(autoPlay = false))
}
}
/**
* 这一屏走了:把**还没到手**的那一次自动起播作废(判据与取舍全在纯函数
* [dropPendingTtsAutoPlay] 里)。
*
* 由渲染层的 `DisposableEffect.onDispose` 调:起播闸门只在这一屏活着的时候跑得到,人走了
* 而结果后到,切回来时就不该突然响一段旧朗读。
*/
fun dropPendingTtsAutoPlay() {
_state.update { it.dropPendingTtsAutoPlay() }
}
/**
* 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造
* (与 `AppContainer` 那些按需 getter 同一手法)。
*
* `baseUrlProvider` / `tokenProvider` 都传**函数**而不是值:换服务器 / 令牌续期之后,
* 下一次朗读自动用新的(与 `mediaApi` 同源同纪律)。凭据只走 owner Bearer ——
* 主人 2026-10-26 拍板不设 API Key 输入框(见 `data.voice.SpeechClient` 的类 KDoc)。
*/
private val speechClient: SpeechClient by lazy {
SpeechClient(
baseUrlProvider = { container.authManager.currentBaseUrl },
tokenProvider = container.authManager::currentToken,
unauthorizedPolicy = container.authManager.unauthorizedPolicy,
cacheDir = container.appContext.cacheDir,
)
}
/**
* 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。
*
@@ -1783,7 +1954,7 @@ class ChatViewModel(
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
*
* ## 分享不声明(`applySendPreference` 恒为 false)
* ## 分享不声明(`fromShare = true` ⇒ [ShareRules.declaresSendPreference] 恒为 false)
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递,
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
* 两种声明在这里**都**是错的:
@@ -1844,7 +2015,7 @@ class ChatViewModel(
emitPlannedMessages(
plan = imageSendPlan(text, share.images, files),
clearDraft = false,
applySendPreference = false,
fromShare = true,
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
)
}
@@ -2008,16 +2179,20 @@ class ChatViewModel(
* 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则
* 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。
*
* 判据**只有一处**:[ShareRules.declaresSendPreference](纯函数,穷举整批的测试在
* `ShareSendDeclarationTest`)。这里只把两条入口的事实([fromShare])与位置([index])喂给它,
* 不在本函数里再写一遍 `&& index == 0`——那种写法正是 W76 之前两处各半个判据的由来。
*
* @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送,
* 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。
* @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的
* 那条传 false(分享不是用户按的发送键,从不声明)。
* @param fromShare 这一批是不是分享进来的:分享落地那条传 true([sendShared]),输入栏那条传
* false([send])。它是 [ShareRules.declaresSendPreference] 的入参之一——分享从不声明。
* @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。
*/
private suspend fun emitPlannedMessages(
plan: ImageSendPlan,
clearDraft: Boolean,
applySendPreference: Boolean,
fromShare: Boolean,
localIdPrefix: String,
) {
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages)
@@ -2028,7 +2203,7 @@ class ChatViewModel(
imageUri = message.imageUri,
documents = message.documents,
clearDraft = clearDraft && index == 0,
applySendPreference = applySendPreference && index == 0,
applySendPreference = ShareRules.declaresSendPreference(fromShare, index),
localIdPrefix = localIdPrefix,
)
}
@@ -2040,9 +2215,9 @@ class ChatViewModel(
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false:
* [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些
* ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。
* `interrupt` 与 `parallel`)。它是**已经算好的结果**,由 [emitPlannedMessages] 按
* [ShareRules.declaresSendPreference] 逐条判出来(唯一调用方就是它)——本函数不再自己推导,
* 也没有默认值:判据只能有一个出处,给个 `= true` 的默认值等于把口径又抄了一遍。
* @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认),
* [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在
* 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动**
@@ -2055,7 +2230,7 @@ class ChatViewModel(
imageUri: String?,
documents: List<DraftDocument>,
clearDraft: Boolean,
applySendPreference: Boolean = true,
applySendPreference: Boolean,
localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX,
) {
// 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。
@@ -2135,10 +2310,10 @@ class ChatViewModel(
// 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
//
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍
// 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true
// (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,且是这一批的第一条)。
// 这个值由 [emitPlannedMessages] 按 [ShareRules.declaresSendPreference] 逐条算好后传进来——
// 分享那条入口([ChatViewModel.sendShared])因此一次都不声明:它是**批量**投递,两种声明在那儿
// 都是错的(理由与取舍见 [sendShared] 的「分享不声明」一段)。
val mode = currentInterruptMode()
val busy = _state.value.busyMode != null
if (applySendPreference) interruptRunningTurn(mode, busy)
@@ -2559,7 +2734,7 @@ class ChatViewModel(
* 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发
* `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时**
* 才调(分享那条批量路径传 false,见 [sendShared])。
* 才调(那个值出自 [ShareRules.declaresSendPreference],分享那条批量路径恒为 false,见 [sendShared])。
*
* [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**:
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
@@ -47,9 +47,18 @@ import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageHostRebase
import top.yeij.cyrene.domain.chat.TTS_LOADING_DESCRIPTION
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
@@ -63,6 +72,8 @@ import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.More
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.time.Instant
import java.time.ZoneId
@@ -106,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` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
* 顺手改会波及所有助手气泡,不在本次范围内。
*
@@ -123,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)
/**
* 一条落库消息渲染成一个或多个气泡:
@@ -133,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])时,气泡位置改为一句如实的说明
@@ -149,7 +170,8 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
*
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音形态)
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音那一块
* ——语音形态或 `failed` 行的降级小标,`voice` 键在就必画其一)
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
* (W72 补的就是它)。
@@ -189,6 +211,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认
* [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。
* @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。
* @param ttsClip 这一条(或**别的一条**)的朗读状态;只有 `messageId` 对得上时才影响本条。
* 见 [TtsClip]:`null` / 对不上 / 本条没点过朗读 ⇒ 与加这一块之前逐像素相同。
* @param ttsPlayback 那条**播放通道**此刻的状态。朗读与语音副本共用它,所以朗读的键是
* `ttsClipIdOf(message.id)` 而不是 `message.id` —— 两枚图标的「正在响」互不串台。
* @param onSpeak 用户点了「朗读」:交给上层发请求(本组件不自己发)。
*/
@Composable
fun MessageBubble(
@@ -197,11 +224,14 @@ fun MessageBubble(
phase: TurnPhase = TurnPhase.Sent,
legBadge: String? = null,
voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle,
ttsClip: TtsClip? = null,
ttsPlayback: VoicePlaybackState = VoicePlaybackState.Idle,
onDocumentClick: (DocumentRefDto) -> Unit = {},
onImageClick: (String) -> Unit = {},
onEdit: (HistoryMessageDto) -> Unit = {},
onDelete: (HistoryMessageDto) -> Unit = {},
onVoiceToggle: (HistoryMessageDto) -> Unit = {},
onSpeak: (HistoryMessageDto) -> Unit = {},
) {
val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
@@ -237,6 +267,20 @@ fun MessageBubble(
// 那一行根本没被画出来。
val timeCarrier = messageTimeCarrier(message, bubbles)
// —— 朗读(W75)——
// 「这条消息有没有可念的正文」:判据与 VM 发请求用的是**同一个纯函数**([ttsTextOf]),
// 所以「画了入口」与「发得出去」永远不会分叉。这里喂的是**已经解析好的** `bubbles`:
// 它们就是屏幕上这些气泡,比让纯函数再解析一遍更贴近「用户看到的那段字」。
// 只给她的消息(`assistant`)——主人的话不该用她的声音念回来,VM 里还有第二道闸。
val speakable = !isUser && remember(bubbles) { ttsTextOf(bubbles) != null }
// 本条自己的那份朗读状态:`messageId` 对不上的(用户点了别的一条)一律当没点过。
val ownClip = ttsClip?.takeIf { it.messageId == message.id }
val ownClipId = remember(message.id) { ttsClipIdOf(message.id) }
val speakActive = ttsPlayback.isActiveFor(ownClipId)
// 「正在准备」有两个来源:VM 那边请求还在飞(网络 / 合成,绝大多数时间在这里),以及播放通道
// 正在取字节(本端落好的文件是直接给的,这一档只是一闪而过)。
val speakPending = ownClip?.pending == true || ttsPlayback.isLoadingFor(ownClipId)
MessageGroup(
isUser = isUser,
footer = bubbleFooter(
@@ -246,6 +290,10 @@ fun MessageBubble(
shared = isSharedMessage(message),
),
onOpenMenu = openMenu,
onSpeak = if (speakable) { { onSpeak(message) } } else null,
speakActive = speakActive,
speakPending = speakPending,
ttsFailure = ownClip?.failure,
) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
@@ -277,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)) {
@@ -500,7 +563,7 @@ sealed interface MessageTimeCarrier {
/** 图片(`image` 非空才会画)。 */
data object Image : MessageTimeCarrier
/** 语音形态(`voice` 键在才会画)。 */
/** 语音那一块(`voice` 键在才会画:语音形态,或 `failed` 行的降级小标)。 */
data object Voice : MessageTimeCarrier
}
@@ -509,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`:这条消息确实一个可视的块都没有。
*
* ## 谁**不**走这一档
@@ -544,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
@@ -751,6 +816,10 @@ private fun MessageGroup(
isUser: Boolean,
footer: String? = null,
onOpenMenu: (() -> Unit)? = null,
onSpeak: (() -> Unit)? = null,
speakActive: Boolean = false,
speakPending: Boolean = false,
ttsFailure: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
@@ -763,7 +832,7 @@ private fun MessageGroup(
content()
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
if (footer != null || onOpenMenu != null) {
if (footer != null || onOpenMenu != null || onSpeak != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
@@ -776,6 +845,31 @@ private fun MessageGroup(
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
// 「朗读」紧挨「⋯」:两枚都是这条消息的动作,分开放会让人以为是两个话题。
// 与「⋯」同一个尺寸、同一条 `Icon + clickable` 纪律(理由见下面那段注释)。
if (onSpeak != null) {
Icon(
imageVector = if (speakActive) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = when {
speakActive -> TTS_STOP_DESCRIPTION
speakPending -> TTS_LOADING_DESCRIPTION
else -> TTS_SPEAK_DESCRIPTION
},
// 正在响 ⇒ 主色(与语音形态那枚图标同一口径);其余是角标行那档灰。
tint = if (speakActive) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
// 正在准备时**点不动**:这时候再发一次请求没有任何意义,用户想要的是
// 「别再等了」——而那条路(取消在飞的合成)本端没有做,所以不给一个
// 点了没反应的入口(见报告「未做」)。
modifier = Modifier
.size(MESSAGE_ACTION_ICON_SIZE)
.clip(CircleShape)
.clickable(enabled = !speakPending, onClick = onSpeak),
)
}
if (onOpenMenu != null) {
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
@@ -792,6 +886,18 @@ private fun MessageGroup(
}
}
}
// 朗读没成功时那一句。挂在角标行**下面**、自成一行:塞进那一行会把来源角标挤成两截,
// 而这句话是给用户看的一整句,不该跟角标抢横向空间。
// 颜色用 `error` 而不是灰:它是一条**如实告知**(不是装饰),但也不弹窗、不打断——
// 用户的眼睛本来就在他刚点的那条消息上。
if (ttsFailure != null) {
Text(
text = ttsFailure,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
}
}
@@ -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,
)
}
}
@@ -0,0 +1,103 @@
package top.yeij.cyrene.ui.chat
import java.io.File
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.ttsClipIdOf
/**
* 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。
*
* ## 为什么它住在 VM 的状态里,而不是气泡自己的 `remember`
* 一次朗读要经过一段**跨组合重建**的等待(发请求、等字节、落盘):转屏 / 滚出屏幕再滚回来
* 都不该让已经落好的那段音频消失,也不该让「正在准备」这件事凭空重置。所以请求发起、失败分类、
* 落盘结果都在 `ChatViewModel.speak` 里,气泡只是这份状态的视图。
*
* ## 一次只有一个:`ttsClip` 是**单格**
* 与 [VoiceReply] 同一取舍:同一时刻用户点的是**一条**消息,多留几条既占内存又没人看。
* 用户点了另一条 ⇒ 这一格被新的那条整个替换(旧那条的音频文件留在缓存里,下次点它命中不发请求)。
*
* ## 播放不在这一格里
* 「正在响 / 已经停了」由**唯一的播放通道**(`ui.chat.VoicePlayer` 的 `state`)说,键是
* [clipId](`tts:<messageId>`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、
* 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道
* `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事
* 本来就不是一件事。
*
* @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。
* @property clipId 播放通道上的键(`tts:<messageId>`):与语音副本的键(= 它的 `messageId`)
* 分得开,于是「念这一条」与「放这条消息的语音副本」天然互斥(点一个停另一个)。
* @property file 合成音频在本机的那份 WAV;`null` = 还没到手(请求在飞,或已经失败了)。
* @property failure 上一次没成功时给用户看的那句话(`domain.chat.TtsFailure.message`);
* `null` = 没失败过 / 还没结果。
* @property retryable 再点一次**可能**有用吗(`domain.chat.TtsFailure.retryable`)。为假时图标
* 仍然可点 —— 但那是「重来一次」而不是「重试」,文案上不提重试(见 `TtsFailure.Unconfigured`)。
* @property autoPlay 这一次结果还该不该**自动起播**(默认 true,起播过就置假,见
* `ChatViewModel.consumeTtsAutoPlay`)。
*
* 为什么要这一位:请求发出去之后用户可能已经切到别的会话 / 别的页面。若只按「文件到了就放」
* 来触发,等他切回来时会**突然响一段刚才点的朗读** —— 那不是他此刻的动作。所以自动起播只认
* **第一次**落好的那一下,之后靠那枚图标。
*/
data class TtsClip(
val messageId: String,
val clipId: String,
val file: File?,
val failure: String?,
val retryable: Boolean,
val autoPlay: Boolean = true,
) {
/** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */
val pending: Boolean get() = file == null && failure == null
}
/**
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
*
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
* 判据跨了三处状态(这一格的 [TtsClip]、那条唯一播放通道的 [VoicePlaybackState]、以及
* `ttsClipIdOf` 铸的键),而它们谁都不该在自己的类里替对方下结论。
*/
sealed interface TtsSpeakIntent {
/** 这一条**正在响**,手上也有它的音频 ⇒ 这一下是「停」。 */
data object Stop : TtsSpeakIntent
/** 其余一切(没在响、响的是别的、失败过、缓存已命中想再听一遍)⇒ 交给 VM。 */
data object Speak : TtsSpeakIntent
}
/**
* 这一下点击是「停」还是「(重新)念」。
*
* ## 为什么必须有这条判据:否则「停止朗读」是空操作
* 图标在播放中画的是暂停符、读屏名字是 `TTS_STOP_DESCRIPTION`(见 `MessageBubble`),所以
* 用户点它时**意思是停**。但播放中 `TtsClip.pending` 是假的(文件早就到手了),于是
* `ChatViewModel.beginTtsSpeak` 的防抖放行、整格被换成新的 pending、缓存命中秒回、
* 起播 effect 又因为「已经在放同一条」而跳过 `toggleClip` —— 三处各自都「没错」,合起来就是
* 点了没反应。判据落在**播放通道**这一处(它是「有没有在响」的唯一真相),才不会有第二处
* 各自解释「播放中」是什么意思。
*
* ## 为什么要 `file != null`
* 「正在响」与「音频在手上」是两件事:通道处于 [VoicePlaybackState.Loading] 时它也算 active,
* 而那一刻 [TtsClip.file] 可能还是 `null`(字节在 VM 那边取)。那时把点击解释成「停」会停掉
* 一次**还没起播**的载入,而用户点它的本意是「念」——所以只有文件确实在手、通道确实在响时才判
* [TtsSpeakIntent.Stop];其余一律 [TtsSpeakIntent.Speak],由 VM 的 pending 分支去防抖。
*
* @param playback 那条唯一播放通道此刻的状态(`VoicePlayer.state`)。
* @param messageId 被点的那条消息。
* @param clip 这一条自己的朗读格(`messageId` 对不上的要当没点过,判据里再核一次)。
*/
fun ttsSpeakIntent(
playback: VoicePlaybackState,
messageId: String,
clip: TtsClip?,
): TtsSpeakIntent {
val file = clip?.takeIf { it.messageId == messageId }?.file
return if (file != null && playback.isActiveFor(ttsClipIdOf(messageId))) {
TtsSpeakIntent.Stop
} else {
TtsSpeakIntent.Speak
}
}
@@ -0,0 +1,156 @@
package top.yeij.cyrene.data.api
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* `POST /api/v1/audio/speech` 的**运行时接线**:`ApiClientFactory.create`(主 REST client)→
* [SpeechApi]。
*
* 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,
* 与 [RestAttemptClientTest] 同一套做法,不引任何新依赖)。
*
* ## 它钉住的四条事实(每条都是判据,不是描述)
* 1. 请求真的写出去,落到 `POST <baseUrl>api/v1/audio/speech` —— 路径**不带前导 `/`**,
* 否则部署在子路径下的服务端地址会被整段丢掉([SpeechApi] 的 KDoc 写的就是这条)。
* 2. 它带着 **owner Bearer 令牌**与 `X-API-Version: 1`:服务端是 owner-only 的双凭据门,
* 本端只走 Bearer 那一支(主人 2026-10-26 拍板不设 API Key 输入框)。
* 3. `sessionId` **有值时随体发出**——发音人由那条会话的生效人格决定,这是它唯一的来源。
* 4. `sessionId` 为 `null` 时字段**整个缺席**(`explicitNulls = false`),而不是发一个
* `"sessionId": null`:契约里「省略」与「显式空」是两件事。
*/
class SpeechApiTest {
private val backend = FakeSpeechBackend()
@After
fun tearDown() {
backend.close()
}
/** 与生产同形:主 REST client(带令牌与 401 策略),只是 baseUrl 指向本地假端点。 */
private fun api(baseUrl: String = backend.baseUrl) = ApiClientFactory.create(
baseUrl = baseUrl,
service = SpeechApi::class.java,
tokenProvider = { TOKEN },
)
/** 发一次真请求并把响应体关掉(`@Streaming` 的体必须由调用方关闭)。 */
private suspend fun speech(dto: SpeechRequestDto) {
val response = withTimeoutOrNull(TIMEOUT_MILLIS) { api().speech(dto) }
response?.body()?.close()
}
@Test
fun `the request really puts a POST api v1 audio speech on the wire`() = runBlocking {
backend.reply(200, WAV)
speech(SpeechRequestDto(text = "你好"))
// 请求行不含 HTTP 版本(HttpExchange 只给 method + URI),判据是「方法 + 路径」。
assertEquals("POST /api/v1/audio/speech", backend.requestLines.single())
}
@Test
fun `the request carries the owner bearer token and the api version`() = runBlocking {
backend.reply(200, WAV)
speech(SpeechRequestDto(text = "你好"))
val headers = backend.headers.single()
assertEquals("Bearer $TOKEN", headers["authorization"])
assertEquals("1", headers["x-api-version"])
}
@Test
fun `the session id travels in the body because it decides the voice`() = runBlocking {
backend.reply(200, WAV)
speech(SpeechRequestDto(text = "你好", sessionId = SESSION_ID))
val raw = backend.bodies.single()
val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw)
assertEquals("你好", sent.text)
assertEquals(SESSION_ID, sent.sessionId)
assertTrue("sessionId 该随体发出,实际 $raw", raw.contains("\"sessionId\""))
}
@Test
fun `a null session id leaves the field out entirely instead of sending null`() = runBlocking {
backend.reply(200, WAV)
speech(SpeechRequestDto(text = "你好"))
val raw = backend.bodies.single()
val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw)
assertNull(sent.sessionId)
// 「省略」与「显式 null」在契约里是两件事:后者会让服务端以为本端知道是哪个会话。
assertFalse("sessionId 为 null 时不该出现这个键,实际 $raw", raw.contains("sessionId"))
}
/** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**、请求头原文与请求体原文。 */
private inner class FakeSpeechBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/api/v1/audio/speech", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
val requestLines = CopyOnWriteArrayList<String>()
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
val headers = CopyOnWriteArrayList<Map<String, String>>()
val bodies = CopyOnWriteArrayList<String>()
@Volatile
private var status = 200
@Volatile
private var payload = WAV
fun reply(status: Int, body: ByteArray) {
this.status = status
this.payload = body
}
private fun handle(exchange: HttpExchange) {
requestLines += "${exchange.requestMethod} ${exchange.requestURI}"
headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") }
bodies += String(exchange.requestBody.readBytes(), Charsets.UTF_8)
exchange.responseHeaders.add("Content-Type", "audio/wav")
exchange.sendResponseHeaders(status, payload.size.toLong())
exchange.responseBody.use { it.write(payload) }
}
override fun close() {
server.stop(0)
}
}
private companion object {
const val SESSION_ID = "web"
const val TOKEN = "test-token"
const val TIMEOUT_MILLIS = 10_000L
/** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */
val WAV: ByteArray =
"RIFF".toByteArray(Charsets.US_ASCII) +
byteArrayOf(36, 0, 0, 0) +
"WAVE".toByteArray(Charsets.US_ASCII) +
ByteArray(40)
}
}
@@ -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` 同一口径):无副本时那四个键**不写出来**,
@@ -0,0 +1,253 @@
package top.yeij.cyrene.data.voice
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import java.io.File
import java.net.InetSocketAddress
import java.nio.file.Files
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 「把一段文字念出来」的取字节层([SpeechClient]):发什么、收什么、落哪儿、失败算哪一档。
*
* 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,
* 与 [top.yeij.cyrene.data.api.RestAttemptClientTest] 同一套做法,不引任何新依赖),落盘用真临时目录。
*
* ## 它钉住的六条事实
* 1. **产物恒为 WAV,按字节嗅探**:`200` 却回 JSON 信封(网关/反代把错误包了一层的典型形态)
* 绝不喂给 `MediaPlayer`,如实判 [SpeechNotAudioException]。这一条刻意让假端点回
* `Content-Type: audio/wav` —— 头是**不可信**的,判据只落在字节上。
* 2. **0 字节不算「一段静音」**,判解析失败(与 `MediaRepository` 同一口径)。
* 3. **落盘位置是 `<cacheDir>/media/tts_<hash>.wav`**:不在 `documents/`(那是 FileProvider 唯一
* 暴露出去的根),名字按 `(baseUrl, sessionId, text)` 内容寻址。
* 4. **同一段文字一个请求都不发第二次**(用请求计数断言,不是「文件存在」)。
* 5. **空文本连请求都不发**:服务端对它只会回 `400`,本端没必要走一次往返。
* 6. **非 2xx 保留服务端那句 `code`**:`tts_persona_unmapped` / `tts_no_character` 这两档的文案
* 完全不同,折成一句话就说不清了。
* 7. **换服务器不是命中**(P2-2):同一个 `(sessionId, text)` 在另一台服务器上可能是另一个声音,
* 而地址末尾多写一个 `/` 必须**还是**同一份缓存(`trimEnd('/')` 归一)。
*/
class SpeechClientTest {
private lateinit var cacheRoot: File
private val backend = FakeSpeechBackend()
@Before
fun setUp() {
cacheRoot = Files.createTempDirectory("cyrene-tts").toFile()
}
@After
fun tearDown() {
backend.close()
cacheRoot.deleteRecursively()
}
/** 与生产同形:令牌由 [top.yeij.cyrene.data.api.ApiClientFactory] 的拦截器注入。 */
private fun client(baseUrl: String = backend.baseUrl) = SpeechClient(
baseUrlProvider = { baseUrl },
tokenProvider = { TOKEN },
cacheDir = cacheRoot,
)
private suspend fun speak(
client: SpeechClient = client(),
text: String = "你好",
sessionId: String? = SESSION_ID,
): Result<File>? = withTimeoutOrNull(TIMEOUT_MILLIS) { client.speak(text, sessionId) }
@Test
fun `a real wav lands under media as a tts named file and comes back playable`() = runBlocking {
backend.reply(200, WAV)
val file = speak()!!.getOrThrow()
assertEquals("media", file.parentFile?.name)
assertTrue("缓存名该带 tts_ 前缀,实际 ${file.name}", file.name.startsWith("tts_"))
assertTrue("缓存名该是 .wav,实际 ${file.name}", file.name.endsWith(".wav"))
assertArrayEquals(WAV, file.readBytes())
// 令牌确实是本层挂上去的那一条(owner Bearer,不是 API Key)。
assertEquals("Bearer $TOKEN", backend.headers.single()["authorization"])
}
@Test
fun `the same text a second time sends no request at all`() = runBlocking {
backend.reply(200, WAV)
val first = speak()!!.getOrThrow()
val second = speak()!!.getOrThrow()
assertEquals(first, second)
assertEquals("第二次该命中缓存,一个请求都不发", 1, backend.requestLines.size)
}
@Test
fun `a json envelope served with 200 is rejected instead of fed to the player`() = runBlocking {
// 头照写 audio/wav —— 恰恰证明判据不能落在 Content-Type 上。
backend.reply(200, """{"error":{"code":"tts_failed","message":"合成失败"}}""".toByteArray())
val failure = speak()!!.exceptionOrNull()
assertTrue("200 却不是 WAV 该判 SpeechNotAudioException,实际 $failure", failure is SpeechNotAudioException)
// 绝不落盘:一份不是音频的缓存会在下一次「命中」并被喂给播放器。
assertNoCacheFile()
}
@Test
fun `an empty body is a parse failure rather than a silent clip`() = runBlocking {
backend.reply(200, ByteArray(0))
val failure = speak()!!.exceptionOrNull()
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error)
assertNoCacheFile()
}
@Test
fun `a persona without a voice keeps the server code instead of flattening it`() = runBlocking {
backend.reply(500, """{"error":{"code":"$CODE_PERSONA_UNMAPPED","message":"人格没有发音人映射"}}""".toByteArray())
val failure = speak()!!.exceptionOrNull() as CyreneException
val server = failure.error as CyreneError.Server
assertEquals(500, server.httpStatus)
assertEquals(CODE_PERSONA_UNMAPPED, server.code)
assertNoCacheFile()
}
@Test
fun `blank text fails locally without a round trip`() = runBlocking {
val failure = speak(text = " \n ")!!.exceptionOrNull()
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error)
assertTrue("空文本不该发请求", backend.requestLines.isEmpty())
}
@Test
fun `two sessions with the same text are two different clips`() = runBlocking {
backend.reply(200, WAV)
val web = speak(sessionId = "web")!!.getOrThrow()
val phone = speak(sessionId = "phone")!!.getOrThrow()
// 发音人由会话的生效人格决定 ⇒ 同一段文字在不同会话里可以是两个声音,缓存名必须分开。
assertFalse("不同会话的同一段文字不该撞同一个缓存名", web == phone)
assertEquals(2, backend.requestLines.size)
}
@Test
fun `the same session and text on another server is not a cache hit`() = runBlocking {
backend.reply(200, WAV)
val here = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
// 换一台服务器:会话 id 与文字都一样,但那边的人格映射 / 发音人 / 合成实现是它自己的。
val elsewhere = FakeSpeechBackend()
try {
elsewhere.reply(200, WAV)
val there = speak(client = client(baseUrl = elsewhere.baseUrl))!!.getOrThrow()
assertFalse("换服务器后不该命中上一台合成的那份 WAV(那是别人的声音)", here == there)
assertEquals("这台各请求一次", 1, backend.requestLines.size)
assertEquals("那台也请求了一次", 1, elsewhere.requestLines.size)
} finally {
elsewhere.close()
}
}
@Test
fun `a trailing slash on the base url is the same server`() = runBlocking {
backend.reply(200, WAV)
val withSlash = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
val without = speak(client = client(baseUrl = backend.baseUrl.trimEnd('/')))!!.getOrThrow()
// 地址末尾多写一个 `/` 不该让整份合成缓存作废(归一在 `cacheName` 里)。
assertEquals(withSlash, without)
assertEquals(1, backend.requestLines.size)
}
@Test
fun `wav sniffing looks at the bytes and nothing else`() {
assertTrue(isWavBytes(WAV))
assertFalse("11 字节连 RIFF 头都不完整", isWavBytes(WAV.copyOf(11)))
assertFalse("空数组", isWavBytes(ByteArray(0)))
// RIFX 是 RIFF 的大端变体,不是本端承诺的产物形状。
assertFalse("RIFX 不是 WAVE", isWavBytes("RIFX".toByteArray() + WAV.copyOfRange(4, WAV.size)))
// 头对了但第 8 字节起不是 WAVE:RIFF 容器里装的是别的东西。
assertFalse("RIFF 里不是 WAVE", isWavBytes("RIFF".toByteArray() + ByteArray(WAV.size - 4)))
}
private fun assertNoCacheFile() {
val media = File(cacheRoot, "media")
val leftovers = media.listFiles()?.filter { it.name.startsWith("tts_") }.orEmpty()
assertTrue("失败时不该留下缓存文件,实际 $leftovers", leftovers.isEmpty())
}
/** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**与请求头原文。 */
private inner class FakeSpeechBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/api/v1/audio/speech", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
val requestLines = CopyOnWriteArrayList<String>()
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
val headers = CopyOnWriteArrayList<Map<String, String>>()
@Volatile
private var status = 200
@Volatile
private var payload = WAV
fun reply(status: Int, body: ByteArray) {
this.status = status
this.payload = body
}
private fun handle(exchange: HttpExchange) {
requestLines += "${exchange.requestMethod} ${exchange.requestURI}"
headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") }
exchange.requestBody.readBytes()
// 一律回 audio/wav:判据必须落在字节上,本测试**故意**让头说谎。
exchange.responseHeaders.add("Content-Type", "audio/wav")
exchange.sendResponseHeaders(status, payload.size.toLong())
exchange.responseBody.use { it.write(payload) }
}
override fun close() {
server.stop(0)
}
}
private companion object {
const val SESSION_ID = "web"
const val TOKEN = "test-token"
const val TIMEOUT_MILLIS = 10_000L
const val CODE_PERSONA_UNMAPPED = "tts_persona_unmapped"
/** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */
val WAV: ByteArray =
"RIFF".toByteArray(Charsets.US_ASCII) +
byteArrayOf(36, 0, 0, 0) +
"WAVE".toByteArray(Charsets.US_ASCII) +
ByteArray(40)
}
}
@@ -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 = "")))
}
@@ -0,0 +1,178 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.parse
import java.io.IOException
/**
* 「朗读」这条路上本端要说的那几句话与纯判据([ttsTextOf] / [ttsFailureOf] / [ttsClipIdOf])。
*
* 这个文件全是纯函数,不碰网络也不碰 Android:取字节在 `SpeechClientTest`(真 HTTP),
* 图标与播放接线在界面层。
*
* ## 它钉住的四件事
* 1. **念什么**:只念正文(`body`)——抬头(`head`)像字幕、旁白(`tail`)是舞台指示,都不念;
* 一条消息切成多个气泡时按 `\n` 拼,空正文不产生空行。
* 2. **能不能念**:「这条消息有没有可念的正文」是唯一判据,`ChatViewModel.speak`(发不发请求)与
* `MessageBubble`(画不画图标)用的必须是同一个答案;`mode` 不是 `chat` 时念的是**原文**,
* 因为那时气泡里放的就是原文、一个 markup 都不解析。
* 3. **失败算哪一档**:只有服务端明确回了「配置缺失」类 `code` 才进
* [TtsFailure.Unconfigured](重试永远不会成功,文案要说清缺什么、且不提重试),
* 其余一切(网络、解析、401、`voice_unavailable` / `tts_failed` / `tts_empty`)都是
* [TtsFailure.Soft]。
* 4. **播放键**:`tts:<messageId>`,与语音副本的键(就是 `messageId`)分得开。
*/
class VoiceSpeakTest {
// ---- 念什么:气泡 → 文本 ------------------------------------------------
@Test
fun `bubbles are joined by a newline so the pause lands between paragraphs`() {
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p>再见</p>")))
}
@Test
fun `an empty message has nothing to read out loud`() {
assertNull(ttsTextOf(parse("")))
assertNull(ttsTextOf(emptyList()))
}
@Test
fun `narration alone is stage direction and is not read out loud`() {
// 旁白整段进 head(首部依附),正文为空 ⇒ 没有可念的字。
assertNull(ttsTextOf(parse("<p><narration>她笑了笑</narration></p>")))
}
@Test
fun `the speaker head is skipped and only the body is read`() {
val bubbles = parse("<p><narration>她笑了笑</narration>你好</p>")
assertEquals("你好", ttsTextOf(bubbles))
}
@Test
fun `a blank bubble does not become a blank line`() {
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p> </p><p>再见</p>")))
}
// ---- 能不能念:一条消息 → 文本 ------------------------------------------
@Test
fun `a chat message is read after the markup is stripped`() {
assertEquals("你好\n再见", ttsTextOf(message(content = "<p>你好</p><p>再见</p>", mode = "chat")))
}
@Test
fun `a message without a mode counts as a chat message`() {
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = null)))
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = " ")))
}
@Test
fun `a non chat mode is read verbatim because the bubble shows the raw text`() {
// 非 chat 的 mode 下气泡里放的就是原文(MessageBubble 的 isChat 判据),
// 所以朗读念的也必须是那同一段字,一个标签都不许吞。
assertEquals("<p>你好</p>", ttsTextOf(message(content = "<p>你好</p>", mode = "roleplay")))
assertEquals("你好", ttsTextOf(message(content = " 你好 ", mode = "roleplay")))
}
@Test
fun `a message with nothing to say answers null in both modes`() {
assertNull(ttsTextOf(message(content = "", mode = "chat")))
assertNull(ttsTextOf(message(content = " ", mode = "roleplay")))
assertNull(ttsTextOf(message(content = "<p><narration>她笑了笑</narration></p>", mode = "chat")))
}
// ---- 失败算哪一档 ------------------------------------------------------
@Test
fun `a persona without a voice mapping says so and is not worth retrying`() {
val failure = ttsFailureOf(serverError(TTS_CODE_PERSONA_UNMAPPED))
assertEquals(TtsFailure.Unconfigured(TTS_CODE_PERSONA_UNMAPPED), failure)
assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, failure.message)
assertFalse("配置缺失类重试永远不会成功", failure.retryable)
}
@Test
fun `a session without any character says so instead of blaming the network`() {
val failure = ttsFailureOf(serverError(TTS_CODE_NO_CHARACTER))
assertEquals(TTS_NO_CHARACTER_MESSAGE, failure.message)
assertFalse(failure.retryable)
}
@Test
fun `an unrecognized code is not guessed into the configuration bucket`() {
// 本端认不出这个 code,就**不知道**它是配置缺失还是临时故障 ⇒ 不猜,落柔和档:
// 判成「配置没配好」等于对用户说了一句本端没有依据的话。
val failure = ttsFailureOf(serverError("tts_something_new"))
assertEquals(TtsFailure.Soft, failure)
assertTrue(failure.retryable)
}
@Test
fun `a configuration failure built from an unknown code still explains itself`() {
// 兜底分支:万一将来有调用点直接构造了一个本端还不认识的配置缺失码,
// 文案仍是「缺发音人」而不是「网络不好」,且照样不提重试。
val failure = TtsFailure.Unconfigured("tts_something_new")
assertEquals(TTS_UNCONFIGURED_MESSAGE, failure.message)
assertFalse(failure.retryable)
}
@Test
fun `a server that is merely unavailable right now stays in the soft bucket`() {
listOf("voice_unavailable", "tts_failed", "tts_empty").forEach { code ->
val failure = ttsFailureOf(serverError(code))
assertEquals("$code 该是柔和档", TtsFailure.Soft, failure)
assertEquals(TTS_SOFT_FAILURE_MESSAGE, failure.message)
assertTrue("$code 过一会儿再点是有意义的", failure.retryable)
}
}
@Test
fun `an expired token is soft because renewal can still save it`() {
val failure = ttsFailureOf(CyreneException(CyreneError.Unauthorized(code = "", message = "")))
assertEquals(TtsFailure.Soft, failure)
}
@Test
fun `a network or parse failure is soft and does not pretend to know why`() {
assertEquals(TtsFailure.Soft, ttsFailureOf(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))))
assertEquals(TtsFailure.Soft, ttsFailureOf(IOException("boom")))
// 非 CyreneException 一律柔和档:本端确实不知道断在哪一段。
assertEquals(TtsFailure.Soft, ttsFailureOf(IllegalStateException("boom")))
}
// ---- 播放键 ------------------------------------------------------------
@Test
fun `the clip key is namespaced so it never collides with a voice copy`() {
assertEquals("tts:m-1", ttsClipIdOf("m-1"))
assertFalse("朗读与语音副本的键必须分得开", ttsClipIdOf("m-1") == "m-1")
}
private fun serverError(code: String) = CyreneException(
CyreneError.Server(code = code, message = "服务端说明", httpStatus = 500),
)
private fun message(content: String, mode: String?) = HistoryMessageDto(
id = "m-1",
sessionId = "web",
role = "assistant",
content = content,
mode = mode,
createdAt = 0L,
)
}
@@ -0,0 +1,75 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.share.ShareRules
/**
* 接线:`ChatViewModel.emitPlannedMessages` 逐条投递时喂给 [ShareRules.declaresSendPreference] 的
* 那两个入参(`fromShare` 与 `index`)。
*
* ## 判据在 `ShareSendDeclarationTest`,这里钉的是「喂进去的是什么」
* 判据本身(`!fromShare && index == 0`)已被 `ShareSendDeclarationTest` 在裸整数上穷举过。本测试补的
* 是另一半:VM 那个 `forEachIndexed` 的 `index` **是 [ImageSendPlan.messages] 里的位置**,不是选图
* 顺序、也不是 `imagesToSend` 的下标。两者在超限截断时会不一样——`images` 给了 7 张而 `messages` 只有
* [ShareRules.MAX_IMAGES] 条,若哪天把声明算在「原始选图位置」上,就会有一条落在这次根本不发的消息上。
*
* ## 为什么不是「直接调 VM」
* `ChatViewModel` 要一个 [top.yeij.cyrene.app.AppContainer],本项目的单元测试没有 fake container /
* fake SessionsApi 这套基建(登记在 `05` 的遗留里),所以 VM 里的编排穿不过去。这里退一步钉住**它喂进去
* 的形状**;调用点自身靠结构守:`fromShare` 是必填参数(没有默认值)、判据只有 [ShareRules] 一处。
*/
class ShareDeclarationWiringTest {
private fun images(n: Int): List<String> = (1..n).map { "content://img$it" }
/** VM 里那一段:`plan.messages.forEachIndexed { index, _ -> …declaresSendPreference(fromShare, index) }`。 */
private fun declarations(fromShare: Boolean, plan: ImageSendPlan): List<Boolean> =
plan.messages.indices.map { index -> ShareRules.declaresSendPreference(fromShare, index) }
@Test
fun `a share batch declares nothing at any position of the plan`() {
// 0 = 只有正文的分享(也走这条路,同样不声明);1..MAX_IMAGES 逐档穷举。
(0..ShareRules.MAX_IMAGES).forEach { n ->
val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList())
assertEquals("N=$n 的切分至少一条消息", n.coerceAtLeast(1), plan.messages.size)
assertFalse(
"分享 N=$n 的任何一条都不许声明(声明就会打断同一批里的上一条)",
declarations(fromShare = true, plan).any { it },
)
}
}
@Test
fun `the typed send declares on the first planned message only`() {
(0..ShareRules.MAX_IMAGES).forEach { n ->
val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList())
assertEquals(
"手打 N=$n:只有第一条按「她忙时发消息」偏好投递",
List(plan.messages.size) { index -> index == 0 },
declarations(fromShare = false, plan),
)
}
}
@Test
fun `truncated images never shift the declaration onto a message outside the batch`() {
val plan = imageSendPlan(
text = "看这个",
images = images(ShareRules.MAX_IMAGES + 3),
documents = emptyList(),
)
// 超限那几张本来就不发(见 truncatedImagesNotice),声明只按真正发出去的条数算。
assertEquals(ShareRules.MAX_IMAGES, plan.messages.size)
assertEquals(3, plan.truncatedImages)
assertEquals(ShareRules.MAX_IMAGES, declarations(fromShare = true, plan).size)
assertFalse(declarations(fromShare = true, plan).any { it })
assertEquals(
List(ShareRules.MAX_IMAGES) { index -> index == 0 },
declarations(fromShare = false, plan),
)
}
}
@@ -0,0 +1,308 @@
package top.yeij.cyrene.ui.chat
import java.io.File
import java.io.IOException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.domain.chat.TTS_CODE_NO_CHARACTER
import top.yeij.cyrene.domain.chat.TTS_CODE_PERSONA_UNMAPPED
import top.yeij.cyrene.domain.chat.TTS_NO_CHARACTER_MESSAGE
import top.yeij.cyrene.domain.chat.TTS_PERSONA_UNMAPPED_MESSAGE
import top.yeij.cyrene.domain.chat.TTS_SOFT_FAILURE_MESSAGE
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.ttsClipIdOf
/**
* 朗读那一格([ChatUiState.ttsClip])的**状态机**,纯函数部分。
*
* VM 本身拖不进 JVM 测试(它要 `AppContainer`、`viewModelScope`,工程里也没有 coroutines-test),
* 所以这条路上能钉、也最该钉的判据都被抽成了纯函数:放行与否在 [beginTtsSpeak],落位在
* [applyTtsOutcome],这一下点击是「停」还是「念」在 [ttsSpeakIntent],离开这一屏时作废在
* [dropPendingTtsAutoPlay]。六件事各自一组:
*
* 1. **防抖**:同一条正在飞时再点一次必须被忽略(否则每点一次就是一次合成请求);
* 2. **重放 / 重试放行**:已经落好的是「再放一遍」、失败过的是「用户重试」,都不算在飞;
* 3. **过期结果丢弃**:结果回来时这一格已经换人了,一个字节都不许写回去 ——
* 否则会出现「点了 B,界面在 A 下面报错」;
* 4. **失败不抬横幅**:朗读失败只落在这一格里,`error` 必须原样是 null
* (那是发送失败 / 队列满的通道,抬上去会盖住真错误);
* 5. **播放中再点 = 停**(P1-1):判据只认那条唯一的播放通道,不认 `pending`;
* 6. **离开这一屏就作废还没到手的自动起播**(P2-1),而且结果晚到不许把它抬回真。
*/
class TtsSpeakStateTest {
private val messageId = "m-1"
private val clipId = ttsClipIdOf(messageId)
private val audio = File("tts_m-1.wav")
private fun server(code: String, status: Int = 500) =
CyreneException(CyreneError.Server(code, "boom", status))
// —— 放行与否 ——
@Test
fun `a click on an idle state starts a pending clip`() {
val begun = ChatUiState().beginTtsSpeak(messageId, clipId)
assertNotNull("空闲时点朗读必须放行", begun)
val clip = begun!!.ttsClip!!
assertEquals(messageId, clip.messageId)
assertEquals(clipId, clip.clipId)
assertNull("还没结果", clip.file)
assertNull("还没失败", clip.failure)
assertTrue("这一格必须是 pending(图标据此画成正在准备)", clip.pending)
assertTrue("字节到手那一刻要起播一次", clip.autoPlay)
}
@Test
fun `a second click while the request is in flight is ignored`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
assertNull("同一条正在飞时再点一次必须被忽略", pending.beginTtsSpeak(messageId, clipId))
}
@Test
fun `a click after the bytes landed is a replay, not a duplicate`() {
val played = ChatUiState().applyTtsOutcome(messageId, clipId, Result.success(audio))
val replay = played.beginTtsSpeak(messageId, clipId)
assertNotNull("已经落好的是「再放一遍」,不是重复请求", replay)
assertNull("重放要重新等一次结果(缓存命中不会真发请求)", replay!!.ttsClip!!.file)
assertTrue("再放一遍也必须重新起播", replay.ttsClip.autoPlay)
}
@Test
fun `a click after a failure is the user retrying`() {
val failed = ChatUiState().applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline")))
val retry = failed.beginTtsSpeak(messageId, clipId)
assertNotNull("失败过的那一条必须放行(用户再点一次就是那次重试)", retry)
assertNull("重试把上一次的文案清掉,否则「准备中」下面还挂着上一句失败", retry!!.ttsClip!!.failure)
}
@Test
fun `clicking another message replaces the single slot`() {
val other = "m-2"
val pendingA = ChatUiState().beginTtsSpeak(messageId, clipId)!!
val pendingB = pendingA.beginTtsSpeak(other, ttsClipIdOf(other))!!
assertEquals("这一格只有一份:换人就整格替换", ttsClipIdOf(other), pendingB.ttsClip!!.clipId)
assertEquals(other, pendingB.ttsClip.messageId)
}
// —— 结果落位 ——
@Test
fun `success lands the file and keeps autoplay armed`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio))
val clip = done.ttsClip!!
assertSame(audio, clip.file)
assertNull(clip.failure)
assertFalse("播放交给那条唯一的通道,这一格不需要知道走到哪了", clip.retryable)
assertTrue("渲染层的起播闸门只看这一位:置假就永远不响", clip.autoPlay)
assertFalse("有文件就不再是 pending", clip.pending)
}
@Test
fun `a result for a message the user has already left is discarded`() {
val other = "m-2"
val pendingB = ChatUiState().beginTtsSpeak(other, ttsClipIdOf(other))!!
// A 的结果晚到了:这一格已经是 B。
assertSame(
"过期的成功结果不许写回去",
pendingB,
pendingB.applyTtsOutcome(messageId, clipId, Result.success(audio)),
)
assertSame(
"过期的失败结果更不许写回去(否则会点了 B、在 A 下面报错)",
pendingB,
pendingB.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))),
)
}
@Test
fun `a result arriving after the slot was cleared is discarded`() {
val cleared = ChatUiState()
assertSame(cleared, cleared.applyTtsOutcome(messageId, clipId, Result.success(audio)))
}
@Test
fun `an unconfigured failure names what is missing and says nothing about retrying`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
val unmapped = pending.applyTtsOutcome(
messageId,
clipId,
Result.failure(server(TTS_CODE_PERSONA_UNMAPPED)),
).ttsClip!!
val noCharacter = pending.applyTtsOutcome(
messageId,
clipId,
Result.failure(server(TTS_CODE_NO_CHARACTER)),
).ttsClip!!
assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, unmapped.failure)
assertEquals(TTS_NO_CHARACTER_MESSAGE, noCharacter.failure)
assertFalse("配置缺失这一档重试永远不会成功", unmapped.retryable)
assertFalse(noCharacter.retryable)
assertNull("失败就是失败:不许留着上一次的文件", unmapped.file)
assertFalse("失败没有可起播的东西", unmapped.pending)
}
@Test
fun `a transient failure stays soft and retryable`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
val offline = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))).ttsClip!!
val expired = pending.applyTtsOutcome(
messageId,
clipId,
Result.failure(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"))),
).ttsClip!!
assertEquals(TTS_SOFT_FAILURE_MESSAGE, offline.failure)
assertTrue(offline.retryable)
// 令牌过期续期后还能用 ⇒ 也是柔和档,不许说成「配置没配好」。
assertEquals(TTS_SOFT_FAILURE_MESSAGE, expired.failure)
assertTrue(expired.retryable)
}
@Test
fun `neither outcome raises the page banner`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
val failed = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline")))
val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio))
// 朗读是一次附带的小动作:它失败不该盖住真正的发送失败 / 队列满提示。
assertNull("失败只挂在这条消息下面,不抬成横幅", failed.error)
assertNull(done.error)
}
// —— 这一下点击是「停」还是「念」(P1-1)——
//
// 缺陷现场:图标在播放中画的是暂停符、读屏名字是「停止朗读」,可点击直通 `viewModel.speak`,
// 而 VM 的防抖只认 `pending`(播放中文件早就到手 ⇒ 不是 pending ⇒ 放行),起播 effect 又会
// 因为「已经在放同一条」跳过 `toggleClip` —— 于是「停」这一步根本不存在。
/** 已经落好文件的那一格(= 用户点过一次、字节到手了)。 */
private fun landed(): TtsClip =
ChatUiState()
.beginTtsSpeak(messageId, clipId)!!
.applyTtsOutcome(messageId, clipId, Result.success(audio))
.ttsClip!!
@Test
fun `clicking the clip that is already playing is a stop`() {
val clip = landed()
val intent = ttsSpeakIntent(VoicePlaybackState.Playing(clipId), messageId, clip)
assertEquals("播放中再点一次必须是「停」,否则停止朗读是空操作", TtsSpeakIntent.Stop, intent)
}
@Test
fun `clicking while the channel is still loading this clip is a stop too`() {
val clip = landed()
// 文件已在手、通道正把它交给 MediaPlayer:这一下的意思同样是「别放了」。
assertEquals(
TtsSpeakIntent.Stop,
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, clip),
)
}
@Test
fun `a clip that is not the one playing is a new speak`() {
val clip = landed()
// 正在响的是这条消息的**语音副本**(键 = messageId),不是它的朗读(键 = tts:messageId)。
assertEquals(
"响的是别的键 ⇒ 这一下是念(并且会停掉那一条,见 VoicePlayer.toggleClip)",
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Playing(messageId), messageId, clip),
)
}
@Test
fun `an idle channel with the file in hand is a replay`() {
val clip = landed()
assertEquals(
"放完 / 停掉之后再点同一条是「再放一遍」",
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Idle, messageId, clip),
)
}
@Test
fun `a channel loading this clip without the file yet is not a stop`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!.ttsClip!!
// 文件还没到手:交给 VM 的 pending 分支去防抖,而不是在这一层把一次没起播的载入停掉。
assertEquals(
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, pending),
)
}
@Test
fun `a failure on the channel is a retry rather than a stop`() {
val clip = landed()
assertEquals(
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Failed(clipId, "boom"), messageId, clip),
)
}
// —— 离开这一屏就作废还没到手的自动起播(P2-1)——
@Test
fun `leaving the screen disarms an autoplay that has not landed yet`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
val left = pending.dropPendingTtsAutoPlay()
assertFalse("人走了,结果回来时不该突然响", left.ttsClip!!.autoPlay)
assertEquals("作废只动闸门,请求照旧在飞", clipId, left.ttsClip.clipId)
assertTrue(left.ttsClip.pending)
}
@Test
fun `a result landing after the screen was left does not rearm autoplay`() {
val left = ChatUiState().beginTtsSpeak(messageId, clipId)!!.dropPendingTtsAutoPlay()
val done = left.applyTtsOutcome(messageId, clipId, Result.success(audio))
// 关键回归:结果落地时把 `autoPlay` 抬回真的话,切回来照样会响(等于没修)。
assertFalse("作废过的那一次,结果晚到也不许把闸门抬回真", done.ttsClip!!.autoPlay)
assertSame(audio, done.ttsClip.file)
}
@Test
fun `leaving the screen never interrupts a clip whose bytes already landed`() {
val landed = ChatUiState()
.beginTtsSpeak(messageId, clipId)!!
.applyTtsOutcome(messageId, clipId, Result.success(audio))
assertSame("文件已在手 ⇒ 闸门要么跑过了、要么正在起播:一个字节都不许动", landed, landed.dropPendingTtsAutoPlay())
val empty = ChatUiState()
assertSame("空格子同理", empty, empty.dropPendingTtsAutoPlay())
}
}