19 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 436c0f06f6 Merge branch 'feat/w76-multiimage-share-batch-delivery' into android 2026-10-26 23:40:28 +08:00
AskaEth 4a9e22ea77 Merge branch 'feat/w77-unread-incremental-after-cursor' into android 2026-10-26 23:40:17 +08:00
AskaEth 6d158ca9bd fix(w77): 满页之后的「不满页」也说不清尾巴,complete 收紧为「从未拿过满页」
r2 复审指出 `loadIncrement` 的第三档(`!hasMore` ⇒ `complete = true`)是空页档同一个
洞的另一个出口:它只看「本页满不满」,不看整个追尾里有没有拿过满页。

反例(页宽 2、`after=0`、积压 m1@500 / m2@500 / m3@500 / m4@600):第一页给
[m1,m2](满页,服务端 LIMIT 恰好把 500 那一毫秒劈开),第二页 `after=500` 只给
[m4@600]、不满页 ⇒ 旧代码报 `complete = true`,而 m3@500 既不 `> 500`、永远拿不到,
也不会触发回落整表 —— 未读红点被做小,正是这条增量路径最不能出的错。

改法:第三档改为 `complete = !chasedFullPage`,不变式收紧为

    complete = true 当且仅当整个追尾从未拿过满页。

理由写在 KDoc 里:只有服务端的 LIMIT 在某一毫秒中间切过一刀才会留尾巴,客户端看不见
那一刀落在哪儿,只知道自己拿过满页就等于发生过一次切点 —— 此后无论下一页是空页还是
不满页,都说不出「追到了尾巴」。四档表(第一页空 / 第一页不满 / 满页后空 / 满页后不满 /
翻满 maxPages)与代价段一并重写。

代价(复审已认可):积压 ≥ 页宽(默认 50 条)一律回落整表,与「翻满 MAX_INCREMENT_PAGES
⇒ 回落」同类,也正合那句「积压多到增量不再便宜」;主路径(几条新消息、第一页就不满)
不受影响。

- `MessageRepository.loadIncrement`:标志位 + 第三档出口,KDoc 不变式与四档表重写;
- `MessageRepository.loadAfter`:KDoc 的告警从「满页之后的空页」改为「满页之后的一页
  (空页、不满页都一样)」;
- `SessionsViewModel.flushPendingIncrements`:回落理由 2 同步为「整个追尾里拿过整页」;
- 新增反例测试 `a short page after a full page is not proof of a clean tail either`。

验证:`./gradlew testDebugUnitTest assembleDebug` BUILD SUCCESSFUL;135 个套件
1692 条测试全绿(上一轮 1691,+1 即上述反例),0 failures / 0 errors / 0 skipped。
2026-10-26 23:34:59 +08:00
AskaEth b35b1b53ab W76:修假后端 keep-alive 下请求体缓存跨请求串用(设备实测「3 张图只投出 2 张」的真凶)
`protocol_version = "HTTP/1.1"` ⇒ 同一条 keep-alive 连接上的多个请求由**同一个 Handler
实例**依次处理(`handle_one_request` 循环),而上一轮给 `_read_body` 加的缓存
(`_body_read` / `_body_cache`)从不在请求之间重置。后果两条:

- 第 2 个请求拿到第 1 个请求的 body ⇒ `out/http.jsonl` 里的摘要记成上一份;
- 第 2 个请求自己那份 body **没人从 `rfile` 读走**,残留字节留在内核缓冲里,被下一个
  请求的请求行一并吃掉 ⇒ `BaseHTTPRequestHandler` 回
  `501 Unsupported method ('{"sessionId":...}POST')`。

W76 设备实测里这表现成「系统分享 3 张图,只投出 2 条消息」,很容易被误判成 App 批量
投递的缺陷。修法是在 `handle_one_request` 入口把缓存清掉,按请求隔离。

验证:
1. 重启假后端后,在同一条 keep-alive 连接上连发 3 个**不同 body** 的
   `POST /api/v1/chat`,三发全 202,`out/http.jsonl` 记下三个不同 body(同一 client port)。
2. 完整设备重放(DocumentsUI 选 3 张图 → 分享 → Cyrene → 发送)三发三 202,
   3 条独立消息落库。
2026-10-26 23:27:04 +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
AskaEth db53de1a7e W77 增量追尾:满页之后的空页不再当作尾巴,未读基数的边界如实登记
review 判了两条 P2,都只动 W77 这一摊。

1) `loadIncrement` 里 `newest <= cursor` 那条同毫秒防线是死代码。服务端的 `after` 是
**严格** `>`(`list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 一致),返回的
每一行都 `createdAt > cursor`,那个分支永不触发。真实风险在别处:满页恰好把同一毫秒劈开
时,下一次 `after=newest` 只会回空页,旧代码把那次空页当成「追到了尾巴」⇒ 静默少给几条
且不回落。改成「拿过一整页之后紧跟的空页 ⇒ `complete = false`」:客户端分不出干净尾巴与
这个洞,就如实说没追完,把决定权交回调用方(未读面上是回落整表)。第一页就空仍算干净
尾巴(没有可少给的),不满一页仍算追到尾巴,翻满预算仍算没追完。

连带把测试改成真后端的形状:满页后空页那条的期望从 `complete = true` 改成 `false`;同
毫秒那条不再让假后端把同一批行回两次(严格 `>` 下后端给不出这种响应),改成「三条同毫秒、
页宽 2、第二页空」并断言第二次请求真的带 `after=500`;新增「第一页空页 ⇒ `complete =
true`」把新逻辑的另一半钉住。KDoc 里「再翻也只会拿到同一批行」这类与契约矛盾的措辞一并
重写。

2) `applyIncrement` 的 `base + unseen` 在断线期间他端已读时会多计:`session_read` 帧永久
丢,`read_at` 又是账号级游标。代码行为不动,只把措辞限定清楚 ——「同源」的只是**过滤口径**
(`count(created_at > read_at)`),基数仍是上次整表的旧值,跨端读会让它漂移,由下一次整表
刷新自愈。这是**已接受的边界**,KDoc 写明了,另加一条测试把它钉住
(`a read elsewhere while offline over-counts until the next whole table`),免得日后被
当成「已修好」。

不装「`readAt > from` ⇒ 回落整表」的保守闸:本端刚点开过该会话时 `readAt` 本来就被推到
「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量路径在正常
使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门不如
不装,理由写在 KDoc 里。

顺手把 `SessionItem.lastMessageAt` 与 `flushPendingIncrements` 里过时的注释改成新口径
(它现在同时是增量追尾的 `after` 游标)。

验证:`./gradlew.bat testDebugUnitTest assembleDebug` 全绿 —— 135 个 suite / 1691 条
(比上轮 +2)/ 0 失败 / 0 跳过,`assembleDebug` 打包成功。
2026-10-26 23:21:15 +08:00
AskaEth d716d26864 W77 未读增量 —— 非激活会话的静默刷新改用 GET /messages?after= 前向增量
W49 那条「非激活会话落了新消息 ⇒ 整表重拉 GET /sessions」换掉:整表一次要回**所有**
会话(每条一行预览 + 未读计数),而真正变了的只有一个会话。改问那条会话的消息增量
`GET /messages?after=<本端已见最大 createdAt>`,只回那几条新消息。

- MessageRepository.loadIncrement:after 方向的翻页版,翻到尾巴或 maxPages 为止;
  同毫秒并列推不动游标 / 翻满页数 ⇒ complete = false,不猜、也不悄悄少给几条。
  byId 去重后按 MessageOrder 升序交出去。
- InactiveSessionPull 三个纯函数:incrementCursorFor(游标 = SessionItem.lastMessageAt,
  不另立账本)、incrementCursors(任一 id 问不了 ⇒ 整批回落,不混着来)、
  applyIncrement(只动那一行;未读 = 越过本地已读游标的条数,与服务端
  unreadCountQuery 的 count(created_at > read_at) 同源;lastMessageAt 已被别人推进过
  ⇒ 整行不动,防重复计数;没改动就返回同一实例)。
- SessionsViewModel.requestSilentRefresh 带 sessionId,窗口里攒 pendingIncrements
  (集合,不是单个 id —— 一次窗口可能合并几个不同会话);flushPendingIncrements
  逐个问增量,三档回落整表:游标拿不到 / complete = false / 请求失败(含会话已在别处
  删掉的 404)。一次批次最多回落一次。登出时清空 pendingIncrements。
- 节流与 trailing 语义一个字没动(silentRefreshStep / throttleAfterPull 签名未改);
  init / 抽屉打开 / 新建 / 删除 / ContextChanged 仍走整表。增量路径不写
  prefs.saveSnapshot(增量凑不出合格的 SessionSummaryDto,那会用更差的东西覆盖好快照),
  也不捎带 loadPersonas()(此前它只是跟着整表搭便车)。

量级(同 JSON 形状实测):一次静默刷新原本 = GET /sessions 全表 + loadPersonas,
10 个会话时 4100 B、2 个请求;现在 = 1 条增量 126 B、1 个请求。

测试:新增 MessageIncrementRepositoryTest(6,真 OkHttp + 真 Retrofit + JDK
com.sun.net.httpserver,验 URI 参数、满页后再问一次得空页、同毫秒并列、maxPages
用尽、404 映射、字节量对比)与 SessionIncrementTest(13,三个纯函数穷举)。
testDebugUnitTest 全绿 135 suites / 1689 tests / 0 failures / 0 errors / 0 skipped;
assembleDebug 通过。
2026-10-26 22:19:17 +08:00
AskaEth 8babd1d2bb W76:把「分享批量投递零声明」提成可单测的纯函数 + 假后端 http.jsonl 记请求体摘要
两件事都是为了让「分享 3 张图 = 3 条消息、零 interrupt」这条结论有据可查——
一条是代码侧的构造性保证,一条是设备侧的证据工具。

1) ShareRules.declaresSendPreference(fromShare, index)(纯函数)
   「这一次投递要不要按『她忙时发消息』偏好声明 interrupt / parallel」原先分在两处手写:
   ChatViewModel.sendShared 传 false、emitPlannedMessages 里 && index == 0。两处各是半个判据、
   一处都单测不到,而这条边界正是 W25 修掉的「分享多图自相打断」(第 2 张把第 1 张刚起的回合
   打掉,见 02 的 F4 与 05 那一节)。提成一处判据后语义不变,穷举整批(0..MAX_IMAGES-1 及越界)
   由 ShareSendDeclarationTest 钉住:分享恒 false、手打只在第 0 条 true。
   ChatViewModel 不在本次 scope,接线那一行没动(已作为 finding 上报,纯函数暂为唯一出处)。

2) 假后端 out/http.jsonl 记请求体摘要(不只是 headers)
   分享多图走 REST 不走 WS(ChatSendRouting.channelFor:hasImage ⇒ Rest),所以「发了几条、
   每条带的是正文还是图、有没有 parallel」只能从 HTTP 流水里读。此前它只记 headers,
   现在加 body 摘要:结构照留(键 / 布尔 / 数组长度),超过 200 字符的字符串换成 <N chars>,
   非 JSON 记 {bytes, note}——图片的 base64 只留长度,不会把一行撑到几 MB。
   _read_body 顺带加了缓存:body 只能从 rfile 读一次,先记录再让 handler 读会得到空串。

assembleDebug + testDebugUnitTest 全绿:1674 测试 / 0 失败 / 0 错误 / 0 跳过(134 个测试类)。
2026-10-26 22:17:44 +08:00
AskaEth 53d7a9fb69 Merge branch 'feat/w74-voicekey-strict-drop-tolerance-fallb' into android 2026-10-26 22:07:22 +08:00
AskaEth 5989e0d4eb W74 撤掉「容忍 + 下次拉取」兜底 —— voice 键存在即语音,覆盖全部读面
后端回执三十八 §二:M44(合并号 fb55769,契约 v0.97)把落库顺序改成
INSERT → 写 voice 列 → notify,于是语音消息的**第一条** message_persisted /
channel_persisted 帧就带 voice,握手 history 回放也带(与 REST 读面同一收口点
attachVoiceTo → historyVoiceOf)。据此撤掉 W73 时期的「容忍 + 下次拉取」兜底。

## 撤掉的到底是什么(调查结论,不是运行时改动)

全仓 grep 过「容忍 / tolerat / 下次拉取 / refetch / 自动升级 / 四个 REST / 覆盖率」,
**没有任何运行时的兜底分支**:`domain.chat.isVoiceMessage` 从 W73 起就一直只是
`message.voice != null`,没有重试、轮询、延迟判定或「先按文本显示」的标记位。
兜底的全部实体是三处**口径**(两处 KDoc + 一条测试注释),本次逐条改正:

1. MessageDto.kt 类头:「[voice] 是『一处覆盖三条路』的例外,只在四个 REST 读取面填充,
   实时帧与握手回放缺席」→ v0.97 起**不再是例外**,四条读面都填、第一条帧就带。
2. MessageDto.kt 的 voice 字段 KDoc:删掉「覆盖率(v0.79):四个 REST 读取面才有 /
   实时帧没有 / 不要在实时帧上等 voice / 等下一次拉取自动升级成语音气泡 / 这是设计不是缺陷」,
   换成 v0.97 口径(全部读面都带、第一条帧就带),并把回执点名的两条实现前提写成护栏:
   ① voice 列写入必须先于 notify;② 帧内 voice 是发出那一刻的即时列读。
   任一被改回「先 notify 再写列」,本端判据立刻退回 v0.79 行为——而本端刻意不再留兼容分支。
   「列被外部改坏 ⇒ 字段缺席,与『不是语音』同形」那段仍然成立,原样保留。
3. MessageMergeTest.kt 用例 `a later copy carrying voice wins over the earlier copy`
   (原 `a later page carrying voice upgrades …`):断言一字未改,只重写注释理由——
   按 id 合并「新到的那份胜出」照旧必需(同一条消息会从落库帧 / 握手回放 / 分页 / 编辑帧
   多路到达),但它不再是「语音升级的唯一通路」。

## 新增的钉子

WsEventPersistedVoiceTest(5 个用例)钉住 v0.97 的正面事实:message_persisted 载荷里
voice 四键齐全时读得回来且 isVoiceMessage 为真;channel_persisted 同样;平台自带转写
(四键全缺席)仍是语音但 voiceAudio 为 null(不画播放控件);voice 键缺席 ⇒ null ⇒
不是语音(判据只有一个方向);message_edited 帧不许把语音气泡打回纯文本。
这条测试的意义:PersistedData 只解 message,生产 Json 是 ignoreUnknownKeys = true ⇒
未来谁把 voice 从落库帧上摘掉不会报错,只会静默退回纯文本。

## 验证

./gradlew.bat assembleDebug → BUILD SUCCESSFUL
./gradlew.bat testDebugUnitTest → 134 suites / 1675 tests,0 failures / 0 errors / 0 skipped
(基线 133 / 1670;+1 suite +5 tests 全部来自新增的 WsEventPersistedVoiceTest)
2026-10-26 21:59:39 +08:00
AskaEth 4d25d9ec69 merge: W73 —— 语音发送侧(点按录音 16k/单声道 WAV 自封 + RECORD_AUDIO 权限 + voice_input 上行与 clientMsgId 幂等重发 + voice_output 播放与 provider=silent 降级 + 乐观气泡) 2026-10-26 19:00:57 +08:00
AskaEth 4e822df5ed W73 语音输入闭环 —— 录音 → voice_input → voice_output(契约 v0.96 §3.15 ①)
上行(用户按住麦克风说话):
- `AudioRecord` 16 kHz / 单声道 / 16-bit PCM → 自封 44 字节 RIFF/WAVE 头。
  不用 `MediaRecorder`:它默认出 m4a/aac,**不在**契约白名单 wav|mp3|ogg 里;
  WAV 纯手写、零新依赖(`domain/chat/VoiceClip.kt` 是纯函数,可穷举单测)。
- 体积闸门 5 MiB(解码后):契约没给 voice_input 的上限,按已知最严的那一档
  (chat.image 的 base64 硬顶)设计;录满 60 秒只用掉 36%,它是第二道闸。
  空 / 太短(<1s)/ 超限三档**明确拒绝并说一句**,绝不静默。
- `voice_input` 补上 `deviceId` / `clientMsgId`(与原骨架的四个键纯增量,
  省略时逐字节不变)。幂等键由纯函数 `voiceClientMsgIdFor` 决定:**重发复用同一个**。
- 乐观气泡与文本同源:同一个 `pending` 列表(`PendingMessage.voiceDurationMs`)、
  同一个 `rollbackPending`;失败时那段 WAV 留在 VM 私有字段里,界面上给「重发 / 丢弃」。
  语音**不进**自动补发队列(几 MB 字节不该钉在队列里等连接)。

下行(她的语音回复):
- `voice_output` 解析补齐 `sessionId`(帧壳没有,从 data 提到帧上,会话闸才真的生效)、
  `deviceId`、`provider`。
- `provider == "silent"`(或 audio 缺席)⇒ **不画播放器**,只显示 reply 文字 + 一句说明。
- `text` = 转写(那段音频被识别成什么),`reply` = **她说的话**(音频念的就是它):
  显示 `reply` 优先、缺席才退到 `text`(纯函数 `voiceReplyText`)。
- 音频 base64 解码落 `cacheDir/media/`,复用 W62 那条 `VoicePlayer` 通道播放
  (新增 `toggleClip`,同一套 MediaPlayer 生命周期与身份比对);自动播放只认第一次
  (切走再回来不会突然响一段旧语音)。

其它:Manifest 加 RECORD_AUDIO(运行时申请,被拒不再重复申请、文案指路系统设置);
UI 全部 Miuix(extended 包的 Mic),录音中整行换成「录音中 0:07 / 取消 / 发送」。

验收:assembleDebug 通过;testDebugUnitTest 全量 133 suites / 1670 tests / 0 failures
(基线 129 / 1636,新增 4 个 suite / 34 个用例)。
2026-10-26 19:00:16 +08:00
43 changed files with 5471 additions and 177 deletions

No files matched your search

+10
View File
@@ -15,6 +15,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
-->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--
联网权限**显式**声明,不靠任何传递依赖。
@@ -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>
}
@@ -9,8 +9,9 @@ import kotlinx.serialization.Serializable
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
*
* ⚠️ [voice] 是这条「一处覆盖三条路」的**例外**:它只在四个 REST 读取面填充,实时帧与握手回放
* 上缺席(理由写在 [voice] 的 KDoc 里)。
* [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
* `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
* (前提与理由写在 [voice] 的 KDoc 里)。
*/
@Serializable
data class HistoryMessageDto(
@@ -64,18 +65,30 @@ data class HistoryMessageDto(
val turnId: String? = null,
/**
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]);
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事,判据只有一个:这个键在不在。
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事。
*
* ## ⚠️ 覆盖率(契约 v0.79 如实):四个 REST 读取面才有,实时帧没有
* **有** `voice` 的:`GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
* ## 判据是**两个**问题,别再合成一个
* - 「这条**来自**一段声音吗」= 这个键在不在(`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`、
* `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与
* `message_edited` 广播帧。
* `message_edited` 广播帧,**外加** WS 握手 `history` 回放与 `message_persisted` /
* `channel_persisted` 落库帧——后三处由后端 M44(`fb55769`)修好:落库顺序改为
* **INSERT → 写 `voice` 列 → notify**,于是语音消息的**第一条**落库帧就带 `voice`
* (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。
*
* **没有** `voice` 的:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放
* ——入站语音的 `voice` 列是**落库之后**回填的,而落库帧在**落库那一刻**发出,天生拿不到
* 这个值。所以**不要在实时帧上等 `voice`**(本端也刻意不为它加任何重试 / 轮询):实时到达的
* 语音消息先按普通文本显示(转写文本本来就在 [content] 里),**等下一次拉取自动升级**成语音气泡。
* 这是设计,不是缺陷。
* 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底:
* `voice` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。
*
* ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify;
* ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
* 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。
*
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
* 于是这种情况在本端与「不是语音」同形——契约的 `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,
)
@@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext
* 三条读取出口共用同一个端点,只是给的查询参数不同:
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
* - [search]:`q` 会话内搜索(也是倒序);
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
*
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
*/
@@ -87,7 +88,9 @@ class MessageRepository(
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
*
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
* 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
*/
suspend fun loadAfter(
sessionId: String,
@@ -100,6 +103,87 @@ class MessageRepository(
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* **增量追尾**(W77):从 [after] 起把某会话的新消息一直翻到尾巴,最多 [maxPages] 页。
*
* 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。
*
* ## 什么时候只能说 `complete = false`
* 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于:
* `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被
* 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次也问不到
* (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。
*
* 要紧的是**切点**:只有当服务端的 `LIMIT` 在某一毫秒中间切过一刀,才会留下追不回来的尾巴。
* 客户端看不见那一刀落在哪儿,只知道自己**拿过满页**就等于「发生过一次切点」。所以这里只有一条
* 不变式,下面几档都是它的推论:
*
* > `complete = true` **当且仅当整个追尾从未拿过满页**。
*
* - **第一页就是空页 ⇒ `true`**:一条新消息都没有,没有可少给的;
* - **第一页就不满 ⇒ `true`**:`LIMIT` 没切到任何东西,`> cursor` 的行全在这一页里;
* - **拿过整页之后,下一页是空页 ⇒ `false`**:那次空页分不出「干净的尾巴」与「空页底下还压着
* 同一毫秒的几条」;
* - **拿过整页之后,下一页不满 ⇒ 也是 `false`**:不满只说明「`> cursor` 的行就这么多了」,对
* 切点那一毫秒还有没有行**一无所知**。例如积压 `m1@500 m2@500 m3@500 m4@600`、页宽 2:
* 第一页给 `m1 m2`(切点正好劈开 500 那一毫秒),第二页 `after=500` 只给 `m4@600`、不满页,
* 而 `m3@500` 既不 `> 500`、也就永远拿不到;
* - **翻满 [maxPages] ⇒ `false`**。
*
* 代价:积压一到页宽(默认 50 条)就回落整表。这与「翻满 [maxPages] ⇒ 回落」同类——那时增量
* 已经取回 ≥50 行,多问一次整表是边际成本,也正合 [MAX_INCREMENT_PAGES] 那句「积压多到增量不再
* 便宜」。主路径(几条新消息、第一页就不满)不受影响。
*
* 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。
*
* 不传 `platform`:未读面上的口径是服务端 `count(created_at > read_at)`(`unreadCountQuery`,
* 不带平台过滤),按平台过滤会让本地数出来的条数**与服务端的 `unreadCount` 不同源**。
*
* @param after 本端**已见**的最大 `createdAt`(毫秒);服务端返回严格新于它的消息,升序。
* @param maxPages 最多翻几页(默认 [MAX_INCREMENT_PAGES])。用尽即 `complete = false`:
* 一次静默刷新不该为了补一个角标翻十几页。
*/
suspend fun loadIncrement(
sessionId: String,
after: Long,
pageSize: Int = DEFAULT_PAGE_SIZE,
maxPages: Int = MAX_INCREMENT_PAGES,
platform: String? = null,
): Result<MessageIncrement> = withContext(dispatcher) {
val size = pageSize.coerceIn(1, MAX_PAGE_SIZE)
val byId = LinkedHashMap<String, HistoryMessageDto>()
var cursor = after
// 只要翻到过一整页,就说明服务端的 LIMIT 切过一刀,那一刀可能劈开了一毫秒(见 KDoc)。
// 此后无论下一页是空页还是不满页,都说不出「追到了尾巴」。
var chasedFullPage = false
fun done(at: Long, complete: Boolean) = Result.success(
MessageIncrement(messages = byId.values.sortedWith(MessageOrder), cursor = at, complete = complete),
)
var page = 0
while (page < maxPages.coerceAtLeast(1)) {
page++
val raw = apiCall { api.listMessages(sessionId, size, before = null, platform = platform, after = cursor) }
.getOrElse { return@withContext Result.failure(it) }
val paged = toPagedMessages(raw, size)
// 空页有两种含义,客户端分不出来:第一页就空 = 真的没有新的;拿过一整页之后空 = 可能还有
// 同一毫秒的几条卡在游标上(服务端只给严格大于)。后者只能说「没追完」。
if (paged.messages.isEmpty()) {
return@withContext done(cursor, complete = !chasedFullPage)
}
// 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。
paged.messages.forEach { byId[it.id] = it }
val newest = paged.newestCreatedAt ?: return@withContext done(cursor, complete = false)
// 不满页只说明「> cursor 的行就这么多了」,对切点那一毫秒一无所知 —— 与空页档同一个洞,
// 同样是拿过整页之后就不作数。
if (!paged.hasMore) return@withContext done(newest, complete = !chasedFullPage)
cursor = newest
chasedFullPage = true
}
done(cursor, complete = false)
}
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
* 完整消息 DTO**。
@@ -152,6 +236,15 @@ class MessageRepository(
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
const val DEFAULT_SEARCH_PAGE_SIZE = 30
/**
* [loadIncrement] 一次最多翻几页(500 条)。
*
* 这个上限只为一件事存在:一次**后台**的顺手对齐不该翻十几页。越过它就说明积压已经多到
* 「增量」不再便宜,那时整表(`GET /sessions` 一次拿回权威的 `unreadCount` 与预览)
* 反而更划算——调用方据此回落,见 `SessionsViewModel.flushPendingIncrements`。
*/
const val MAX_INCREMENT_PAGES = 10
private val EMPTY_PAGE = PagedMessages(
messages = emptyList(),
hasMore = false,
@@ -190,3 +283,18 @@ private fun toPagedMessages(
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)
}
/**
* [MessageRepository.loadIncrement] 的结论:升序增量消息 + 推进后的游标 + 有没有真的追到尾巴。
*
* [complete] 为 `false` 时 [messages] 仍是**已拿到的全部**(不丢),但调用方不能把它当成
* 「这之后就没有了」——未读面上那种情况要回落到整表,见 `SessionsViewModel.flushPendingIncrements`。
*
* 放在这里而不是 `PagedMessages.kt`:`PagedMessages` 是纯分页形状(一页的元数据),
* 这个类型是 [MessageRepository] 翻页循环的产物,跟着它的生产者走。
*/
data class MessageIncrement(
val messages: List<HistoryMessageDto>,
val cursor: Long,
val complete: Boolean,
)
@@ -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"
}
}
}
@@ -0,0 +1,219 @@
package top.yeij.cyrene.data.voice
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.chat.VoiceClip
/** 录音机的两个可见态(界面只认这两个)。 */
sealed interface VoiceRecorderState {
/** 没在录。 */
data object Idle : VoiceRecorderState
/**
* 正在录。
*
* [elapsedMs] 由**已收到的字节数**反算([VoiceClip.durationMillisOf]),不是另起一个计时器——
* 两个时钟一定会漂,而屏幕上那个数字必须与最终发出去的音频长度一致。
*
* [reachedLimit] = 已经录满 [VoiceClip.MAX_RECORDING_MILLIS],此后的字节被**丢弃**(不是
* 悄悄截断已经收到的部分)。UI 看到它应当立刻收尾发送,见本类注释。
*/
data class Recording(val elapsedMs: Int, val reachedLimit: Boolean) : VoiceRecorderState
}
/**
* 麦克风录音机:`AudioRecord` 收 PCM → 交给 [VoiceClip] 封 WAV(本类不碰封装,见那里的 KDoc)。
*
* ## 为什么自己持有一个作用域,而不复用调用方的 `rememberCoroutineScope`
* 读循环必须活到 [stop] 真的收尾:调用方的组合一旦被丢弃(转屏 / 退到后台那一刻重建),
* 挂在它上面的协程会被取消,而 USB/内置麦克风已经交给系统的那一块缓冲就再也取不回来——
* 用户按下停止却拿到一个缺尾的片段。所以读循环跑在这个类自己的 `SupervisorJob + IO` 上,
* 由 [release] 明确收掉。
*
* ## 满了不自动发送,只置位
* 到 [VoiceClip.MAX_RECORDING_MILLIS] 时把 [VoiceRecorderState.Recording.reachedLimit] 置真、
* 继续把多余的字节丢掉。**发送时机由 UI 决定**(它要先把「正在录」那一行换回输入栏,再交给
* VM):本类不知道 VM 也不发帧,这条边界让它在单测里不必造一个 VM。
*
* ## 权限
* 调用方必须先拿到 `RECORD_AUDIO`(运行时权限)。本类**不申请**也不检查:`AudioRecord`
* 在没权限时会抛 `SecurityException`,[start] 把它连同「设备不支持 / 初始化失败」一起收敛成
* `false`,由 UI 按「这台设备现在开不了麦克风」说一句,**绝不静默**。
*/
class VoiceRecorder(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
/** 当前录音状态(界面据此显示「录音中 0:07」)。 */
val state: StateFlow<VoiceRecorderState> = _state.asStateFlow()
private var record: AudioRecord? = null
/** 收集到的 PCM;只有 [start] 建、[stop]/[cancel] 取走或丢掉。 */
private var collected: ByteArrayOutputStream? = null
private var reader: Job? = null
/**
* 录音代次:每次 [start] 自增。
*
* 只服务一件事:读循环的 `finally` 里**只在还属于这一代**时才把状态收回 [VoiceRecorderState.Idle]。
* 少了它,「取消 → 立刻重录」的窄窗口里,上一轮的收尾会把新那一轮的「录音中」抹成「没在录」,
* 而麦克风其实还在收字节——界面上什么都不显示,用户以为自己没在录。
*/
private var generation = 0
@Volatile
private var collecting = false
/**
* 开始录音。
*
* @return `false` = 这台设备现在起不了麦克风(没有权限 / 设备被占用 / 不支持的采样参数)。
* 调用方必须**如实告知**(不得假装在录)。
*/
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (collecting) return false
val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_MASK, ENCODING)
if (minBuffer <= 0) return false
val recorder = runCatching {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(ENCODING)
.setSampleRate(SAMPLE_RATE_HZ)
.setChannelMask(CHANNEL_MASK)
.build(),
)
// 缓冲给到最小值的两倍:读循环偶尔晚一拍时不至于立刻丢帧。
.setBufferSizeInBytes(minBuffer * 2)
.build()
}.getOrNull() ?: return false
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
runCatching { recorder.release() }
return false
}
val started = runCatching { recorder.startRecording() }.isSuccess
if (!started) {
runCatching { recorder.release() }
return false
}
record = recorder
val sink = ByteArrayOutputStream(INITIAL_PCM_BYTES)
collected = sink
collecting = true
val current = ++generation
_state.value = VoiceRecorderState.Recording(elapsedMs = 0, reachedLimit = false)
reader = scope.launch { readLoop(recorder, sink, current) }
return true
}
/**
* 停止并交出这一段 PCM;`null` = 没有可发的字节(没在录 / 读失败 / 全被丢弃)。
*
* `suspend` 是刻意的:必须等读循环真的收尾([reader] 的 `join`)才去读那份缓冲,
* 否则最后一块还没写进去就被取走——用户会听到句尾少一截。
*/
suspend fun stop(): ByteArray? {
if (!collecting) return null
collecting = false
reader?.join()
reader = null
releaseRecorder()
_state.value = VoiceRecorderState.Idle
val sink = collected ?: return null
collected = null
return sink.toByteArray().takeIf { it.isNotEmpty() }
}
/** 取消:丢掉已经收到的字节,一个都不发(用户点了「取消」)。 */
fun cancel() {
collecting = false
reader?.cancel()
reader = null
releaseRecorder()
collected = null
_state.value = VoiceRecorderState.Idle
}
/** 组合被丢弃时调(等价于 [cancel]:一段没发出去的录音不该在后台继续占着麦克风)。 */
fun release() = cancel()
/**
* 读循环:一块一块收进 [sink],顺手更新已录时长;满了之后**丢字节但不打断录音**
* (见类注释「满了不自动发送」)。
*
* 读出错(`read < 0`)就结束这一次录音:硬件被抢走、设备被拔出都会走到这里,此时
* 已经收到的部分仍然有效([stop] 照常交出它们)。
*
* @param generation 启动时那一代的编号:收尾只在自己还是「当前这一代」时才改状态。
*/
private suspend fun readLoop(recorder: AudioRecord, sink: ByteArrayOutputStream, generation: Int) {
val buffer = ByteArray(READ_BYTES)
try {
while (collecting) {
val read = recorder.read(buffer, 0, buffer.size)
if (read < 0) break
if (read == 0) continue
val used = sink.size()
if (used < VoiceClip.MAX_PCM_BYTES) {
sink.write(buffer, 0, minOf(read, VoiceClip.MAX_PCM_BYTES - used))
}
val elapsed = VoiceClip.durationMillisOf(minOf(sink.size(), VoiceClip.MAX_PCM_BYTES))
_state.value = VoiceRecorderState.Recording(
elapsedMs = elapsed,
reachedLimit = elapsed >= VoiceClip.MAX_RECORDING_MILLIS,
)
}
} finally {
// 读循环自己结束(出错 / 被取消)时也要把状态收回 Idle,否则界面会永远停在「录音中」。
// 只在自己还是当前这一代时才收:否则「取消 → 立刻重录」的窄窗口里,上一轮的收尾
// 会把新一轮的「录音中」擦掉。
if (generation == this.generation && _state.value is VoiceRecorderState.Recording) {
_state.value = VoiceRecorderState.Idle
}
}
}
/** 把 `AudioRecord` 拆干净(`stop` 在没起录时抛 `IllegalStateException`,所以都包在 `runCatching` 里)。 */
private fun releaseRecorder() {
val current = record ?: return
record = null
runCatching { current.stop() }
runCatching { current.release() }
}
private companion object {
val SAMPLE_RATE_HZ: Int = VoiceClip.SAMPLE_RATE_HZ
const val ENCODING: Int = AudioFormat.ENCODING_PCM_16BIT
const val CHANNEL_MASK: Int = AudioFormat.CHANNEL_IN_MONO
/**
* 一次读多少字节:`2048` = 16 kHz 下单声道 16-bit 的 64 ms(1024 帧)。
* 比这更小则更新频率过高(每秒几十次状态写),更大则「停止」那一刻的延迟可感。
*/
const val READ_BYTES: Int = 2048
/** 初始缓冲按 10 秒预分配(够大多数语音,长的那条会自己扩容)。 */
val INITIAL_PCM_BYTES: Int = VoiceClip.pcmBytesFor(10_000)
}
}
@@ -223,6 +223,37 @@ class ChatSocket(
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
/**
* 发送 `voice_input`(契约 v0.96 §3.15):一段 base64 音频 + 格式,走与 `chat` 同一条出站通道。
*
* ## 为什么没有「离线队列」
* 文本那条有补发队列(内容只是一小段字符串,留着不贵);语音的 audio 是**几 MB 的 base64**
* 内联在帧里,队列化它等于把这段内存钉在待发状态里,而重连之后服务端到底要不要它、要哪一段
* 又都是用户该重新决定的事。所以本层与 [sendChat] 一样:**发不出去就回 `false`**,由
* `ChatViewModel` 把这段留在内存里、让用户按「重发」(复用同一个 `clientMsgId`)。
*
* @param audio base64(单行,`Base64.NO_WRAP`)。
* @param format 契约白名单 `wav|mp3|ogg` 之一;本端恒 `"wav"`。
* @param deviceId 与 `chat` 帧同一份来源标识。
* @param clientMsgId 幂等键:同一段语音重发**必须复用**(否则服务端会把同一句话说两遍)。
* @return 未连接 / 写失败返回 `false`。
*/
fun sendVoiceInput(
sessionId: String?,
audio: String,
format: String,
deviceId: String?,
clientMsgId: String?,
): Boolean = send(
WsOutbound.VoiceInput(
audio = audio,
format = format,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
),
)
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
val socket = webSocket ?: return false
@@ -600,12 +600,30 @@ sealed class WsEvent {
override val type: String get() = "observability_log"
}
/** 语音合成回复(定向回请求连接)。 */
/**
* 语音合成回复(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* ## 会话归属**只在 `data` 里**
* 契约明写这帧的帧壳**没有** `sessionId`(与其它结构化流帧不同),会话 id 在载荷上。
* 所以解析层用既有口径 [`resolveSessionId`] 把它提到 [sessionId] 上:帧壳有就用帧壳,
* 否则用 `data` 里那一份。少了这一步,`isForSession` 会把帧**放行给每一个会话的 VM**
* (空值 = 无法判定),于是切到别的会话时也会响一段本属于别的会话的语音。
*
* ## 只定向回发起 `voice_input` 的那条连接、断线不补投
* 这是契约的明确边界,不是缺口:本端**不**为它做任何「重连后拉取」的补救(历史靠 REST,
* 音频只在这一次连接里存在)。所以这一帧只有「当场播放 / 当场显示」这一种用途。
*
* [text] 是**转写**(那段音频被识别成什么),[reply] 是**她说的话**——两者的取舍见
* `domain.chat.voiceReplyText`。[provider] 是降级信号(`silent` = 这一轮没有真音频),
* 判据见 `domain.chat.voiceOutputHasAudio`。
*/
data class VoiceOutput(
val audio: String?,
val format: String?,
val text: String?,
val reply: String?,
val deviceId: String? = null,
val provider: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
@@ -890,8 +908,20 @@ sealed class WsEvent {
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
"message_deleted" -> decode<DeletedData>(data)
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data).let {
VoiceOutput(
audio = it.audio,
format = it.format,
text = it.text,
reply = it.reply,
deviceId = it.deviceId,
provider = it.provider,
turnId = turnId,
seq = seq,
// 帧壳**没有** sessionId(契约 v0.96),归属在 data 里 ⇒ 走既有口径提到帧上。
sessionId = resolveSessionId(sessionId, it.sessionId),
)
}
// 她主动说的话(v0.72)。帧壳**没有** `sessionId`(服务端不设),与会话归属有关的一切
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
"proactive_message" -> decode<ProactiveData>(data).let {
@@ -1129,12 +1159,22 @@ private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList(
@Serializable
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
/**
* `voice_output` 载荷(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* `sessionId` 在这里**不是可选的**(契约「帧壳没有 sessionId(在 data 里)」),但解成缺省空串
* 并交给 [WsEvent.resolveSessionId]:坏帧(真没给)时宁可退化成「无法判定」(放行、由消费方
* 兜底),也不把整帧按坏帧丢掉——丢了就是「她回了语音但屏幕上什么都没有」。
*/
@Serializable
private data class VoiceOutputData(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val reply: String? = null,
val sessionId: String = "",
val deviceId: String? = null,
val provider: String? = null,
)
/**
@@ -105,12 +105,26 @@ sealed interface WsOutbound {
}
}
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
/**
* `voice_input`:`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`(契约 v0.96 §3.15)。
*
* 五个键里 [audio] / [format] 是我们**总要给**的(一段 WAV 的 base64 + `"wav"`,见
* `domain.chat.VoiceClip`);[text] 只有本端**自己做过转写**时才给(给了服务端就跳过 STT),
* 本端不做本地转写 ⇒ 恒省略。
*
* [clientMsgId] 与 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条,重复到达回
* `chat_duplicate`、不再落库不再起回合。所以「这一段没发出去、用户点重发」必须**复用**
* 同一个键(见 `domain.chat.voiceClientMsgIdFor`),换新键就是让服务端把同一句话再答一遍。
*
* [deviceId] 的取值与 `chat` 帧同一份(`ChatViewModel.DEVICE_ID`):服务端据此标记来源。
*/
data class VoiceInput(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val sessionId: String? = null,
val deviceId: String? = null,
val clientMsgId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "voice_input"
@@ -120,6 +134,8 @@ sealed interface WsOutbound {
format?.let { put("format", it) }
text?.let { put("text", it) }
sessionId?.let { put("sessionId", it) }
deviceId?.let { put("deviceId", it) }
clientMsgId?.let { put("clientMsgId", it) }
}
}
@@ -0,0 +1,238 @@
package top.yeij.cyrene.domain.chat
/**
* 一段**要发出去的语音**:录音参数、WAV 封装、体积闸门与幂等键(纯函数,可穷举单测)。
*
* ## 为什么自己封 WAV,而不是用 `MediaRecorder`
* 契约 v0.96 给 `voice_input.format` 的白名单是 **`wav|mp3|ogg`**,而 Android `MediaRecorder`
* 的默认产物是 **`m4a/aac`**——**不在白名单里**。要用它就得另接一个编码器把 aac 转成 mp3/ogg,
* 那是新依赖(项目纪律:加依赖先问)。而 `AudioRecord` 给的 PCM + 一个 44 字节的 RIFF 头
* 就是 WAV:**零依赖、零转码、逐字节可预期**,也能在单测里把头的每个字段钉死。
*
* ## 采样参数:16 kHz / 单声道 / 16-bit
* - **采样率 16 kHz**:语音识别的行业口径就是 16 kHz(电话带宽 8 kHz 会把擦音削掉,44.1 kHz
* 对 STT 没有任何增益)。同一句话的字节数因此只有 44.1 kHz 的 **36%**——这一条在本端格外重要,
* 因为 `voice_input.audio` 是**内联 base64**(4/3 膨胀)走一条 WS 文本帧。
* - **单声道**:麦克风阵列的立体声对 STT 只有副作用(两路几乎相同,白背一倍体积)。
* - **16-bit**:`AudioRecord` 的原生 PCM 编码(`ENCODING_PCM_16BIT`),不做压缩——压缩要依赖。
*
* 由此得出一个**只有一条**的换算:`1 ms = 32 字节`,`1 s = 32 000 字节`。
*
* ## 体积闸门(为什么是 5 MiB)
* 契约 v0.96 的 `voice_input` **没写** `audio` 的体积上限(`chat.image` 写了:解码后 5 MiB /
* 字符串 6990572 字符)。本端按**已知最严的那一档**设计,也就是 [MAX_AUDIO_BYTES] =
* `ChatImageBudget.SERVER_MAX_DECODED_BYTES`(5 MiB,解码后)——同一个部署对「内联 base64 字段」
* 已经明确接受的量级,voice 没有理由比它更宽。超限**明确拒绝**([VOICE_TOO_LARGE_MESSAGE]),
* 绝不静默截断:截断后的音频在服务端听来就是「话说到一半断了」。
*
* 60 秒的录满长度([MAX_RECORDING_MILLIS])只用到这条闸门的 **36%**(1 920 044 字节),所以
* 正常路径永远撞不到它——它是**第二道**闸(硬件偶尔会多给几块缓冲,谁也说不准),不是主判据。
*
* ## 时长下限
* 按下就松手的误触会得到几十毫秒的音频:那不是「说话」,送给服务端只会换来一次空转写。
* 不足 [MIN_RECORDING_MILLIS] 的录音在**本端**就被挡下并说明([VOICE_TOO_SHORT_MESSAGE])。
*/
object VoiceClip {
/** 采样率(Hz)。16 kHz = STT 的行业口径,见类注释。 */
const val SAMPLE_RATE_HZ: Int = 16_000
/** 声道数:单声道(立体声对 STT 无益,只翻倍体积)。 */
const val CHANNEL_COUNT: Int = 1
/** 位深:`AudioRecord` 的原生 16-bit PCM。 */
const val BITS_PER_SAMPLE: Int = 16
/** 每采样的字节数。 */
const val BYTES_PER_SAMPLE: Int = BITS_PER_SAMPLE / 8
/** RIFF/WAVE 的 PCM 头长度:`RIFF`(4) + size(4) + `WAVE`(4) + `fmt `(16+8) + `data`(8)。 */
const val HEADER_BYTES: Int = 44
/** 上行 `format` 的取值。契约白名单 `wav|mp3|ogg` 里我们**只产出**这一个。 */
const val FORMAT: String = "wav"
/** 每秒的 PCM 字节数(= [SAMPLE_RATE_HZ] × [CHANNEL_COUNT] × [BYTES_PER_SAMPLE])。 */
val BYTES_PER_SECOND: Int = SAMPLE_RATE_HZ * CHANNEL_COUNT * BYTES_PER_SAMPLE
/**
* 一段语音(**含头**)的字节上限:与 `chat.image` 解码后同一条线,见类注释。
* 刻意**引用**那个常量而不是再写一遍 `5 * 1024 * 1024`:上限只留一处,改的时候不会漂。
*/
const val MAX_AUDIO_BYTES: Int = ChatImageBudget.SERVER_MAX_DECODED_BYTES
/**
* 一次录音的**最长**时长(毫秒)。60 秒取自 IM 的通行口径(微信 / QQ 都是这个量级),
* 也对得上服务端 STT 的现实:再长的语音转写质量与等待都不划算。
*
* 到点由录音机自己置「满了」标记(见 `VoiceRecorder`),UI 随即收尾发送——
* 不是悄悄截断,用户看到的是「到 60 秒了,这段发出去」。
*/
const val MAX_RECORDING_MILLIS: Int = 60_000
/** 短于这个时长的录音**不送**(按下就松手的误触),见类注释。 */
const val MIN_RECORDING_MILLIS: Int = 1_000
/** 60 秒对应的 PCM 字节数(录音机据此封顶,不在读循环里另算一遍公式)。 */
val MAX_PCM_BYTES: Int = pcmBytesFor(MAX_RECORDING_MILLIS)
/** 这段 PCM 加上 WAV 头之后的**总**字节数。 */
fun totalBytesFor(pcmBytes: Int): Int = if (pcmBytes <= 0) 0 else HEADER_BYTES + pcmBytes
/** 时长 → PCM 字节数(向下取整;`<= 0` 的时长回 0)。 */
fun pcmBytesFor(durationMillis: Int): Int =
if (durationMillis <= 0) 0 else (durationMillis.toLong() * BYTES_PER_SECOND / 1000L).toInt()
/** PCM 字节数 → 时长(毫秒,向下取整)。 */
fun durationMillisOf(pcmBytes: Int): Int =
if (pcmBytes <= 0) 0 else (pcmBytes.toLong() * 1000L / BYTES_PER_SECOND).toInt()
/** 总字节数是否在 [MAX_AUDIO_BYTES] 以内(**等于**算通过:`<=`,与 `withinServerLimit` 同口径)。 */
fun withinLimit(totalBytes: Int): Boolean = totalBytes <= MAX_AUDIO_BYTES
/**
* WAV 头(44 字节,PCM / 单块 / 无扩展)。
*
* 逐字段按 RIFF 规范写:`RIFF` + 文件长 - 8 + `WAVE` + `fmt `(16 字节,PCM=1) + 声道 /
* 采样率 / 字节率 / 块对齐 / 位深 + `data` + 数据长。全部**小端**。
*/
fun wavHeader(pcmBytes: Int): ByteArray {
val data = ByteArray(HEADER_BYTES)
putAscii(data, 0, "RIFF")
putIntLe(data, 4, HEADER_BYTES - 8 + pcmBytes)
putAscii(data, 8, "WAVE")
putAscii(data, 12, "fmt ")
putIntLe(data, 16, 16)
putShortLe(data, 20, AUDIO_FORMAT_PCM)
putShortLe(data, 22, CHANNEL_COUNT)
putIntLe(data, 24, SAMPLE_RATE_HZ)
putIntLe(data, 28, BYTES_PER_SECOND)
putShortLe(data, 32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
putShortLe(data, 34, BITS_PER_SAMPLE)
putAscii(data, 36, "data")
putIntLe(data, 40, pcmBytes)
return data
}
/** 头 + PCM = 一个可直接交给任何 `WAV` 解码器的字节数组。 */
fun toWav(pcm: ByteArray): ByteArray {
val header = wavHeader(pcm.size)
val out = ByteArray(HEADER_BYTES + pcm.size)
header.copyInto(out, 0)
pcm.copyInto(out, HEADER_BYTES)
return out
}
/**
* 这段 PCM 该不该发、发出去长什么样(**唯一**的准入判据,录音机与 VM 共用)。
*
* 判序是刻意的:**空 → 超限 → 太短 → 可发**。
* - 空:一个字节都没有(设备没给 / 权限刚被撤),连「太短」都谈不上;
* - 超限:长度或字节数已经越线,绝不能发(哪怕时长看起来正常——硬件也可能给出异常长的一块);
* - 太短:见 [MIN_RECORDING_MILLIS]。
*/
fun plan(pcm: ByteArray): VoiceClipPlan {
if (pcm.isEmpty()) return VoiceClipPlan.Empty
val total = totalBytesFor(pcm.size)
if (!withinLimit(total)) return VoiceClipPlan.TooLarge(total)
val duration = durationMillisOf(pcm.size)
if (duration < MIN_RECORDING_MILLIS) return VoiceClipPlan.TooShort(duration)
return VoiceClipPlan.Ready(toWav(pcm), duration)
}
/** RIFF 的 `audioFormat`:1 = PCM(整数)。 */
private const val AUDIO_FORMAT_PCM: Int = 1
private fun putAscii(target: ByteArray, offset: Int, text: String) {
for (index in text.indices) target[offset + index] = text[index].code.toByte()
}
private fun putIntLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
target[offset + 2] = ((value ushr 16) and 0xFF).toByte()
target[offset + 3] = ((value ushr 24) and 0xFF).toByte()
}
private fun putShortLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
}
}
/** [VoiceClip.plan] 的四档结论。 */
sealed interface VoiceClipPlan {
/** 一段**可以发**的语音:已经封好 WAV,[durationMs] 是它的实际时长。 */
class Ready(val wav: ByteArray, val durationMs: Int) : VoiceClipPlan
/** 一个字节都没录到。 */
data object Empty : VoiceClipPlan
/** 短于 [VoiceClip.MIN_RECORDING_MILLIS](按下就松手那种误触)。 */
data class TooShort(val durationMs: Int) : VoiceClipPlan
/** 总字节数超过 [VoiceClip.MAX_AUDIO_BYTES]。 */
data class TooLarge(val totalBytes: Int) : VoiceClipPlan
}
/** 空录音的如实告知(**不是错误**:什么都没发生,用户重来一次即可)。 */
const val VOICE_EMPTY_MESSAGE: String = "没有录到声音,再按一次试试"
/** 太短的如实告知。 */
const val VOICE_TOO_SHORT_MESSAGE: String = "录得太短了,说满一秒再发"
/**
* 超限的如实告知。刻意说「没发出去」:那句话必须与事实上一致(这条一个字节都没上行),
* 也不能把责任推给用户(他确实就说了那么久,是本端的闸门挡下的)。
*/
const val VOICE_TOO_LARGE_MESSAGE: String = "这段语音太长,本机没有发出去"
/** 这一条被本端挡下时该说的那句话;`null` = 可以发(纯函数)。 */
fun voiceClipRejection(plan: VoiceClipPlan): String? = when (plan) {
is VoiceClipPlan.Ready -> null
VoiceClipPlan.Empty -> VOICE_EMPTY_MESSAGE
is VoiceClipPlan.TooShort -> VOICE_TOO_SHORT_MESSAGE
is VoiceClipPlan.TooLarge -> VOICE_TOO_LARGE_MESSAGE
}
/**
* 录音中 / 待重发那一段的时长文案(纯函数):`m:ss`,**从 0:00 起就有值**。
*
* 与接收侧的 [voiceDurationLabel] 差一条:那个对「不足一秒」回 `null`(服务端没给准数时宁可
* 不显示),而这里显示的是**本端自己正在数的时间**——0:00 是事实,不是冒充。
* 负数按 0 处理(时钟回拨 / 调用方算错时也不出现 `-1:-1`)。
*/
fun voiceElapsedLabel(elapsedMs: Int): String {
val totalSeconds = elapsedMs.coerceAtLeast(0) / 1000
return "${totalSeconds / 60}:${(totalSeconds % 60).toString().padStart(2, '0')}"
}
/** 语音消息的幂等键前缀:与文本那条(`local-`)/ 分享那条(`share-`)同一手法,见 [voiceClientMsgIdFor]。 */
const val VOICE_CLIENT_MSG_ID_PREFIX: String = "voice-"
/**
* 一条**录好、还没发出去**的语音在本端的记账(只有小字段,audio 不在这里)。
*
* `clientMsgId` 是契约 v0.96 §3.15 的幂等键:同一个非空值服务端**只承认第一条**,重复到达回
* `chat_duplicate`、不落库不起回合。所以「重发这一段」必须**复用**它([voiceClientMsgIdFor]),
* 换了新键就是让服务端把同一句话再答一遍。
*
* `audio`(base64)刻意**不进** [top.yeij.cyrene.ui.chat.ChatUiState]:那是一格会被反复 `copy`
* 并逐字段比较的状态(流式期间每秒几十次),几 MB 的字符串放进去等于每次比较都扫一遍它。
* 字节留在 VM 自己那个字段上(生命周期与 VM 相同,转屏不丢)。
*/
data class VoiceDraftInfo(
val clientMsgId: String,
val durationMs: Int,
val totalBytes: Int,
)
/**
* 这一段该用哪个幂等键(纯函数):
* - [draft] 非空 = **重发刚才没发出去的那一段** ⇒ 原样复用它当时的键(契约要求);
* - [draft] 为空 = 一段**新**录音 ⇒ 现铸一个:前缀 + [mint] 给的随机串(随机而不是自增,
* 理由与文本那条相同:服务端的判重窗口是整个会话历史)。
*/
fun voiceClientMsgIdFor(draft: VoiceDraftInfo?, mint: () -> String): String =
draft?.clientMsgId ?: "$VOICE_CLIENT_MSG_ID_PREFIX${mint()}"
@@ -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,93 @@
package top.yeij.cyrene.domain.chat
/**
* `voice_output` 的**判据**(纯函数,可穷举单测):契约 v0.96 §3.15 下行帧。
*
* 帧形状(后端 `回执三十六`):
* ```json
* { "type": "voice_output",
* "data": { "audio": "<base64>", "format": "wav", "text": "...", "reply": "她的话",
* "sessionId": "web", "deviceId?": "...", "provider?": "silent|emotiontts|..." } }
* ```
*
* ## `text` 与 `reply` 是两个东西(本端怎么用)
* - **[text]**:这一轮**转写出来的话**——也就是我们那段音频被识别成什么。它与
* `voice_input.text`(「已自行转写则跳过 STT」)同一个键名、同一个语义,只是方向相反。
* - **[reply]**:**她说的话**(音频念的就是它),也就是用户真正要听/要看的那一句。
*
* 于是「屏幕上该显示哪一句」的答案是 [voiceReplyText]:**`reply` 优先**,它缺席时才退到 `text`
* (宁可显示转写也不显示空白),两者都缺席就什么都不显示(绝不编一句)。
*
* ## `provider` 是**降级信号**,不是装饰
* `provider == "silent"` = 这一轮**没有真音频**(`audio` 空,但 `text` / `reply` 有)。
* 契约明写「UI 别把它画成播放器」——画了就是一个点下去必然失败的按钮(与接收侧
* `voiceAudio` 的「四个键不齐就不画播放控件」同一条纪律)。
*
* 除此之外 `audio` 空白也按「没有音频」处理([voiceOutputHasAudio]):契约说 silent 时
* `audio` 为空,反过来「空 audio + 没给 provider」也绝不能画出一个播放器。
*
* ## 它与 `voice` 键那条(接收侧放别人发来的语音)**互不相干**
* 见 [isVoiceMessage]/[voiceAudio]:那是消息 DTO 的历史副本,这里是**本端刚发出去的那一段**的
* 实时应答——两者不共用判据,共用只会把两套字段口径搅在一起。
*/
/** `provider` 里那个**降级档**的取值(契约 v0.96)。 */
object VoiceProvider {
/** 这一轮**没有真音频**(`audio` 空、`text`/`reply` 有)。 */
const val SILENT: String = "silent"
}
/**
* 这一帧是不是「没有真音频」那一档(纯函数)。
*
* 只认字面 `silent`(忽略首尾空白与大小写:它是机器可读码,不是文案)。未知取值一律**不当**
* silent——那是另一个 provider,`audio` 在不在由 [voiceOutputHasAudio] 按字节判,不靠猜。
*/
fun isSilentVoiceProvider(provider: String?): Boolean =
provider?.trim().equals(VoiceProvider.SILENT, ignoreCase = true)
/**
* 这一轮**有没有真音频**(纯函数):`provider != silent` **且** `audio` 非空白。
*
* 两个条件都不许省:silent 档的 `audio` 契约上就是空的(万一真给了也不算数,因为服务端已经
* 明说这一轮没发音),而非 silent 档的 `audio` 也可能因为编码失败缺席。
*/
fun voiceOutputHasAudio(audio: String?, provider: String?): Boolean =
!isSilentVoiceProvider(provider) && !audio.isNullOrBlank()
/**
* 屏幕上该显示的那一句(纯函数,见文件头「`text` 与 `reply` 是两个东西」)。
* `reply` 非空白 ⇒ 它;否则 `text` 非空白 ⇒ 它;都没有 ⇒ `null`(一个字符都不显示)。
*/
fun voiceReplyText(reply: String?, text: String?): String? =
reply?.takeIf { it.isNotBlank() } ?: text?.takeIf { it.isNotBlank() }
/**
* 这张卡片下面要补的那一句(纯函数:不需要补时回 `null`)。
*
* 两档**都要解释,但说的不是同一件事**:
* - [silent]:**服务端**这一轮没有真音频(`provider == "silent"`)⇒「她只回了文字」。不解释的话,
* 用户按着麦克风说话、回来的却是一段文字,分不清「她选择只回文字」与「播放器坏了」。
* - 有音频却没落地([hasAudioFile] 为假):那是**本端**解码 / 写缓存失败 ⇒ 说「这段语音本机没
* 准备好」,绝不把本机的故障说成「她只回了文字」(那是替服务端下了一个它没下的结论)。
*/
fun voiceReplyNote(silent: Boolean, hasAudioFile: Boolean): String? = when {
silent -> VOICE_SILENT_NOTE
!hasAudioFile -> VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE
else -> null
}
/**
* 「这一轮没有音频」的说明。刻意说清是**服务端**这一轮没给音频(`provider` 的降级信号),
* 而不是「本机播不了」——后者会把人引向「换个播放器」这种无用的动作。
*/
const val VOICE_SILENT_NOTE: String = "这一轮她只回了文字,没有语音"
/**
* 「音频有、但本机没准备好」的说明(解码 / 写缓存失败)。
*
* 与 [VOICE_SILENT_NOTE] 分开是必须的:这一档的真相是**本端**的问题,而文字照样在
* ([voiceReplyText] 已经把它取出来了),所以用户至少还能读到她说的话。
*/
const val VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE: String = "这段语音本机没能准备好,先看文字"
@@ -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"
@@ -228,6 +228,31 @@ object ShareRules {
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
/**
* 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。
*
* ## 两个事实合起来只剩两种答案
* - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张
* 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断
* (后端口径 `02` F4:他们那边不需要做任何事)。
* - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断
* 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个**
* 这样的意思。
*
* 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。
*
* ## 为什么提成一个纯函数
* 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传
* `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直
* 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是
* 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与
* [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。
*
* @param fromShare 这条(这一批)是不是分享进来的
* @param index 在这次发送切出的消息里的位置(0 = 第一条)
*/
fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0
/**
* 从分享 Intent 的原始字段提取内容。
*
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.chat
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@@ -47,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
@@ -64,6 +66,8 @@ import top.yeij.cyrene.core.session.ChatSessionSlots
import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.data.voice.VoiceRecorder
import top.yeij.cyrene.data.voice.VoiceRecorderState
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DocumentDelivery
@@ -76,7 +80,10 @@ import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button
@@ -93,6 +100,7 @@ import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.File
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */
@@ -110,6 +118,14 @@ private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
private const val SEARCH_NOTICE_MILLIS = 8_000L
/**
* 麦克风相关的告知(没授权 / 起不来)挂多久(毫秒)。
*
* 与同页其它几条「动作刚发生的如实告知」取同一个值:它描述的也是刚刚那一次按键,
* 不是持续状态(权限是不是真的没有,用户再按一次麦克风就知道了)。
*/
private const val VOICE_FAILURE_NOTICE_MILLIS = 8_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
@@ -403,6 +419,120 @@ fun ChatScreen(
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
val voicePlayback by voicePlayer.state.collectAsState()
// —— 语音输入(契约 v0.96 §3.15 ①「用户按住麦克风说话」)——
// 录音机归**这一个会话**:换会话 = 换实例(与上面那条播放通道同一个生命周期纪律),
// 离开聊天页时 `onDispose` 收掉麦克风——一段没发出去的录音不该在后台继续占着它。
//
// ⚠️ 录音中**转屏**会重建组合 ⇒ 这一段丢失。本端不为此加一个「录音仓」:录音是几秒钟的
// 动作,转屏 / 分屏重建时丢掉一次比在 VM 里维护一条 AudioRecord 生命周期便宜得多。
val recorder = remember(sessionId) { VoiceRecorder() }
DisposableEffect(recorder) {
onDispose { recorder.release() }
}
val recording by recorder.state.collectAsState()
val recordingState = recording as? VoiceRecorderState.Recording
// 麦克风起不来 / 没授权时的如实告知(本端的问题,不走 VM 的 `error` 通道:
// 那条通道的语义是「服务端 / 发送」,而这里一次都没上过路)。
var voiceFailure by remember { mutableStateOf<String?>(null) }
LaunchedEffect(voiceFailure) {
if (voiceFailure != null) {
delay(VOICE_FAILURE_NOTICE_MILLIS)
voiceFailure = null
}
}
val requestAudioPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
// 授权刚拿到手就直接开录(用户按麦克风的意思就是「现在说」,不必再按第二次)。
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = VOICE_PERMISSION_DENIED_MESSAGE
}
}
// 开始录音:有权限就直接起;没有就申请。起不来时**如实说一句**,绝不假装在录。
fun startVoiceRecording() {
// 已经在录就不重入(录音中麦克风那枚按钮本来就不在屏幕上,这里是第二道闸)。
if (recorder.state.value is VoiceRecorderState.Recording) return
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = null
requestAudioPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}
// 「这一次收尾已经在飞」:防双击「发送」,也防它和「录满 60 秒自动收尾」撞在同一帧上。
// 第二次进入时录音机已经不在录了,`stop()` 会回 `null`——那会被误读成「没有录到声音」。
var finishingVoice by remember { mutableStateOf(false) }
// 结束录音并交给 VM:准入判据(空 / 太短 / 超限)全在 `VoiceClip.plan` 里,这里一个字节
// 都不自己判——空录音也照旧交给它,好让用户拿到那句「没有录到声音」。
fun finishVoiceRecording() {
if (finishingVoice) return
finishingVoice = true
scope.launch {
try {
val pcm = recorder.stop()
viewModel.sendVoice(pcm ?: ByteArray(0))
} finally {
finishingVoice = false
}
}
}
// 录满 60 秒:录音机只置位不自动发送(见 VoiceRecorder 的 KDoc),收尾由这一层做。
val reachedLimit = recordingState?.reachedLimit == true
LaunchedEffect(reachedLimit) {
if (reachedLimit) finishVoiceRecording()
}
// `voice_output` 到了就放(契约:这一帧只定向回本连接、断线不补投 ⇒ 不播就永远听不到)。
// 键里带 `file`:音频先落成本地文件再进状态,落好那一刻才是真正能播的时刻。
// `autoPlay` 只用一次:这一帧到达时用户可能在别的会话,切回来时不该突然响一段旧语音
// (判据见 VoiceReply.autoPlay)。「已经在放 / 正在载入」时不重新触发——否则第二次状态
// 到达会把正在响的那条停掉。
val voiceReply = state.voiceReply
LaunchedEffect(voiceReply?.id, voiceReply?.file) {
val reply = voiceReply ?: return@LaunchedEffect
val file = reply.file ?: return@LaunchedEffect
if (!reply.autoPlay) return@LaunchedEffect
viewModel.consumeVoiceReplyAutoPlay()
if (!voicePlayer.state.value.isActiveFor(reply.id)) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
}
// 朗读(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 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
@@ -569,8 +699,29 @@ fun ChatScreen(
images = state.draftImages,
documents = state.draftDocuments,
busyText = busyLabel(state),
recordingMs = recordingState?.elapsedMs,
voiceDraft = state.voiceDraft,
voiceReply = state.voiceReply,
voicePlayback = voicePlayback,
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onStartRecording = { startVoiceRecording() },
onStopRecording = { finishVoiceRecording() },
onCancelRecording = { recorder.cancel() },
onRetryVoice = viewModel::retryVoice,
onDiscardVoice = viewModel::discardVoice,
// 播放 / 停止与关闭都在这一个动作里收口:关掉卡片之后再响一秒是明确的 bug。
onToggleVoiceReply = {
val reply = state.voiceReply
val file = reply?.file
if (reply != null && file != null) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
},
onDismissVoiceReply = {
voicePlayer.stop()
viewModel.dismissVoiceReply()
},
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
@@ -635,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)
@@ -650,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(
@@ -753,6 +923,14 @@ fun ChatScreen(
state.sendQueueNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 语音输入被本端挡下(没录到 / 太短 / 太大):与上面几条同档的如实告知。
state.voiceNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 麦克风起不来 / 没授权:这是真错误(功能整个不可用),所以用红色。
voiceFailure?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
}
searchNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
@@ -906,14 +1084,43 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null)
}
}
/**
* 输入栏:文本 / 图片 / 附件 / **语音**(契约 v0.96 §3.15 ①)。
*
* ## 三段语音形态各占一行(从上到下)
* 1. [VoiceReplyRow]——她的语音回复(`voice_output` 的产物)。放在最上面,因为它说的是
* 「刚刚那一轮的答复」,而下面几行是用户还要做的动作;
* 2. [VoiceDraftRow]——一段录好但没发出去的语音(重发 / 丢弃);
* 3. **录音中**:整行换成 [VoiceRecordingRow](录音中 / 取消 / 发送)。
*
* ## 录音中为什么把整行换掉
* 录音是一条有去无回的动作:那一行里同时留着一个「发送」按钮(文本的)与一个「结束录音」按钮,
* 手指落错就是把半句话当成文本发出去。换掉之后**文本发送那条路一个字节都没变**(不录音时
* 逐字与之前相同),而录音中屏幕上有且只有与该动作有关的两个选择。
*
* ## 语音入口为什么放在「发送」左边
* 与图片 / 附件同一侧(都是「加内容」),发送键仍在最右(肌肉记忆不变)。麦克风常驻——
* 它是与打字并列的**另一种输入方式**,不是某个附加能力。
*/
@Composable
private fun InputBar(
value: String,
images: List<String>,
documents: List<DraftDocument>,
busyText: String,
recordingMs: Int?,
voiceDraft: VoiceDraftInfo?,
voiceReply: VoiceReply?,
voicePlayback: VoicePlaybackState,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onCancelRecording: () -> Unit,
onRetryVoice: () -> Unit,
onDiscardVoice: () -> Unit,
onToggleVoiceReply: () -> Unit,
onDismissVoiceReply: () -> Unit,
onPickImage: () -> Unit,
onRemoveImage: (Int) -> Unit,
onPickDocument: () -> Unit,
@@ -926,6 +1133,25 @@ private fun InputBar(
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
// 她的语音回复:最上面一行,因为它说的是「刚刚那一轮的答复」。
voiceReply?.let { reply ->
VoiceReplyRow(
reply = reply,
playback = voicePlayback,
onTogglePlay = onToggleVoiceReply,
onDismiss = onDismissVoiceReply,
modifier = Modifier.padding(bottom = 6.dp),
)
}
// 录好但没发出去的那一段:只在 `voice_input` 没能上行时出现。
voiceDraft?.let { draft ->
VoiceDraftRow(
draft = draft,
onRetry = onRetryVoice,
onDiscard = onDiscardVoice,
modifier = Modifier.padding(bottom = 6.dp),
)
}
DraftImagePreviews(images = images, onRemove = onRemoveImage)
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
@@ -934,46 +1160,64 @@ private fun InputBar(
onRemove = onRemoveDocument,
onClick = onPreviewDocument,
)
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
if (images.size < ShareRules.MAX_IMAGES) {
IconButton(onClick = onPickImage) {
if (recordingMs != null) {
VoiceRecordingRow(
elapsedMs = recordingMs,
onCancel = onCancelRecording,
onSend = onStopRecording,
)
} else {
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
if (images.size < ShareRules.MAX_IMAGES) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.width(4.dp))
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
) {
Text("发送")
Spacer(Modifier.width(4.dp))
// 麦克风(点按开始 / 点按结束,见 [VOICE_RECORD_DESCRIPTION] 的措辞)。
// 换行后的入口与图片 / 附件同档;不读偏好、不占文本发送的任何一条判据。
IconButton(onClick = onStartRecording) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = VOICE_RECORD_DESCRIPTION,
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = onSend,
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
) {
Text("发送")
}
}
}
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
@@ -2,9 +2,11 @@ package top.yeij.cyrene.ui.chat
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Base64
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.io.File
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,9 +30,12 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.DocumentCache
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
@@ -55,6 +60,9 @@ import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.chat.TurnBinding
import top.yeij.cyrene.domain.chat.TurnBucket
import top.yeij.cyrene.domain.chat.VoiceClip
import top.yeij.cyrene.domain.chat.VoiceClipPlan
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.allClosed
import top.yeij.cyrene.domain.chat.closingBucketOf
import top.yeij.cyrene.domain.chat.closingTurn
@@ -64,7 +72,14 @@ 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
import top.yeij.cyrene.domain.chat.voiceOutputHasAudio
import top.yeij.cyrene.domain.chat.voiceReplyText
import top.yeij.cyrene.domain.chat.withSteeredMessage
import top.yeij.cyrene.domain.chat.withTurnBegin
import top.yeij.cyrene.domain.parse.ParsedBubble
@@ -109,6 +124,15 @@ data class PendingMessage(
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
*/
val documents: List<DraftDocument> = emptyList(),
/**
* 这一条是**语音**时它的时长(毫秒);`null` = 不是语音(绝大多数消息)。
*
* 语音的 [text] 恒为空:本端不做本地转写,用户说的话要等服务端 STT 之后才会作为
* user 消息的 `content` 落库。而气泡**必须立刻出现**(用户刚说完,屏幕上没有东西就是
* 「没发出去」的样子)⇒ 这一条消息在正文之外还需要一个「这是语音、多长」的可见凭据。
* 它只影响渲染(`PendingBubble` 画那一枚「语音 · 0:07」),不参与发送与指纹。
*/
val voiceDurationMs: Int? = null,
)
/**
@@ -616,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
@@ -809,6 +903,45 @@ data class ChatUiState(
* `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
*/
val sessionGone: Boolean = false,
/**
* `voice_output` 到达后的那一份「她的语音回复」(契约 v0.96 §3.15);null = 没有要显示的那一条。
*
* 它**不是一条消息**,所以不在 [messages] / [pending] 里:这一帧只定向回发起 `voice_input` 的
* 那条连接、断线不补投(契约明写),回合的正文本来也会经流式 / `message_persisted` 落进历史。
* 这里存的是「这一次语音回合的音频 + 她说的话」这一次性产物,渲染在输入栏上方一张卡片上,
* 用户可以随时关掉([ChatViewModel.dismissVoiceReply])。
*
* 音频先落成本地文件再进这一格([VoiceReply.file]):把 base64 塞进状态会让**每一次**
* 状态 `copy` 都在几 MB 的字符串上做相等比较(流式期间每秒几十次)。
*/
val voiceReply: VoiceReply? = null,
/**
* 一段**录好、但没发出去**的语音(`voice_input` 没能上行);null = 没有等着重发的。
*
* 只有小字段(键 / 时长 / 字节数),audio 本体留在 VM 的私有字段里——理由同上。
* 界面上它是输入栏上方一条带「重发 / 丢弃」的卡片:重发**复用同一个 `clientMsgId`**
* (契约:同一个非空值服务端只承认第一条),所以重发不会让同一句话落成两条。
*/
val voiceDraft: VoiceDraftInfo? = null,
/**
* 语音输入被**本端**挡下时的如实告知(没录到 / 太短 / 太大,见 `domain.chat.voiceClipRejection`);
* null = 没有要说的。
*
* 与 [error] 分开:这些都不是「服务端说不行」,而是本端的准入闸门,且用户要做的事很具体
* (重录 / 说长一点)。它到期自动收走([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>
@@ -918,13 +1051,26 @@ class ChatViewModel(
it.draftDocuments.isNotEmpty() ||
it.pending.isNotEmpty() ||
it.queuedForConnection.isNotEmpty() ||
it.shareConfirm != null
it.shareConfirm != null ||
// 一段录好但没发出去的语音:它只在本端内存里(没有任何服务端副本),
// 交掉这一格 = 让用户重说一遍——与「还没发出去的字」同一条理由。
it.voiceDraft != null
}
private var socket: ChatSocket? = null
private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null
/**
* 一段**录好、但没发出去**的语音的字节(`voice_input` 没能上行)。
*
* 刻意是 VM 的字段而不是 [ChatUiState] 的一格:状态会被反复 `copy` 并逐字段比较(流式期间
* 每秒几十次),几 MB 的 `ByteArray` 放在那里等于每次比较都扫一遍;而它的生命周期与 VM
* 完全相同(转屏不丢、切走会话仍在**这一格**里,见 `ChatSessionSlots`)。
* 状态里只留小字段 [ChatUiState.voiceDraft](键 / 时长 / 字节数)供渲染。
*/
private var voiceRetry: VoiceRetryClip? = null
/**
* **每一路**一个流式正文解析器(键与 `ChatUiState.streams` 同源)。
*
@@ -1480,12 +1626,311 @@ class ChatViewModel(
emitPlannedMessages(
plan = imageSendPlan(text, images, documents),
clearDraft = true,
applySendPreference = true,
fromShare = false,
localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX,
)
}
}
/**
* 语音输入(契约 v0.96 §3.15 的 ①「用户按住麦克风说话」):一段**已录好的 PCM** → `voice_input`。
*
* ## 准入判据只有一处([VoiceClip.plan],纯函数)
* 空 / 太短 / 超限三档都在这里被挡下并**明确说一句**([ChatUiState.voiceNotice],到期自己收走):
* 一段被静默吞掉的录音在用户眼里就是「按了没反应」。封 WAV 也在那里面(44 字节 RIFF 头),
* 本函数只做「发」这一件事。
*
* ## 乐观气泡与文本那条同源
* 上屏走的是**同一个** `pending` 列表([PendingMessage],只是 `text` 空、带上
* [PendingMessage.voiceDurationMs]),失败回滚走的是**同一个** `rollbackPending`——没有另造
* 一套「语音待发」的状态机。用户说完话气泡立刻出现,转写的正文之后由服务端的 user 消息
* 落库回来(按 `clientMsgId` 收编,判据在 [consumePendingFor])。
*
* ## 为什么没有补发队列
* 文本那条会进 [ChatUiState.queuedForConnection] 等连接;语音不进——audio 是几 MB 的内存,
* 钉在队列里等一个可能永远不来的连接不划算,而且「这段还发不发」应当由用户决定。
* 所以发不出去就把这一段**留着**([ChatUiState.voiceDraft]),由用户按「重发」
* ([retryVoice],**复用同一个 `clientMsgId`**)或「丢弃」([discardVoice])。
*
* @param pcm `AudioRecord` 收到的 16-bit / 16 kHz / 单声道 PCM(未封头)。
*/
fun sendVoice(pcm: ByteArray) {
viewModelScope.launch {
// 封 WAV + base64 编码都是几 MB 的活(PCM 复制 + 4/3 膨胀),放默认调度器上做,
// 别占主线程——用户按下「发送」之后这一帧就该没有可感的停顿。
val plan = withContext(Dispatchers.Default) { VoiceClip.plan(pcm) }
when (plan) {
is VoiceClipPlan.Ready -> deliverVoice(plan.wav, plan.durationMs, retry = null)
// Ready 之外的每一档都有一句人话(判定与文案都在 domain,能穷举单测)。
else -> voiceClipRejection(plan)?.let(::showVoiceNotice)
}
}
}
/**
* 重发**刚才没发出去的那一段**(复用它的 `clientMsgId`,契约要求)。
*
* 没有待重发的就什么都不做(按钮本不该出现;这里是第二道闸)。字节取的是 VM 里留着的那一份
* WAV(**不重新封装**),所以重发的是**同一个字节序列**——服务端按幂等键判重之后留下的那份
* 音频与用户眼前这段永远一致。
*/
fun retryVoice() {
val clip = voiceRetry ?: return
viewModelScope.launch { deliverVoice(clip.wav, clip.durationMs, retry = clip) }
}
/** 丢掉那段待重发的语音(用户点了「丢弃」)。 */
fun discardVoice() {
voiceRetry = null
_state.update { it.copy(voiceDraft = null) }
}
/** 关掉「她的语音回复」那张卡片(播放器由渲染层在同一个动作里停掉)。 */
fun dismissVoiceReply() {
val file = _state.value.voiceReply?.file
_state.update { it.copy(voiceReply = null) }
if (file != null) {
viewModelScope.launch { withContext(Dispatchers.IO) { file.delete() } }
}
}
/**
* 这一条语音回复的**自动播放**已经用掉了(渲染层在真的起了播放之后调)。
*
* 置位之后即使组合被重建(转屏 / 切走再回来)也不会再自动响一遍——见 [VoiceReply.autoPlay]。
* 幂等:已经置假时 `copy` 出的是同一个值,不会多一次有意义的变更。
*/
fun consumeVoiceReplyAutoPlay() {
_state.update { state ->
val reply = state.voiceReply
if (reply == null || !reply.autoPlay) state else state.copy(voiceReply = reply.copy(autoPlay = false))
}
}
/**
* 用户点了某条消息上的「朗读」(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] 共用)。
*
* 键由纯函数 [voiceClientMsgIdFor] 决定:**重发复用 [retry] 那个键**,新录音现铸一个。
* 判定「发出去没有」用的是 `sendVoiceInput` 的返回值 + **发出那一刻**的连接状态
* (与文本那条 [sendThroughLadder] 同一手法):连接就绪却仍写不出去,与「还没连上」
* 是两句不同的话,用户要做的事也不同。
*/
private suspend fun deliverVoice(wav: ByteArray, durationMs: Int, retry: VoiceRetryClip?) {
val draft = retry?.let { VoiceDraftInfo(it.clientMsgId, it.durationMs, it.totalBytes) }
val clientMsgId = voiceClientMsgIdFor(draft) { UUID.randomUUID().toString() }
val audio = withContext(Dispatchers.Default) { Base64.encodeToString(wav, Base64.NO_WRAP) }
val totalBytes = wav.size
// 乐观气泡:与文本消息**同一个** pending 列表(正文空、带语音时长)。
_state.update {
it.copy(
pending = it.pending + PendingMessage(
localId = clientMsgId,
text = "",
voiceDurationMs = durationMs,
),
error = null,
voiceDraft = null,
voiceNotice = null,
)
}
val ws = socket
val sent = ws?.sendVoiceInput(sessionId, audio, VoiceClip.FORMAT, DEVICE_ID, clientMsgId) ?: false
if (sent) {
voiceRetry = null
return
}
val connection = ws?.state?.value ?: WsState.Idle
val reason = if (connection == WsState.Connected) {
VOICE_SEND_FAILED_MESSAGE
} else {
VOICE_NOT_CONNECTED_MESSAGE
}
// 回滚走的是**同一个** rollbackPending(摘掉刚才那枚气泡、把原因挂进 error 通道),
// 但不走 [rollback]:语音没有可以退回输入栏的正文 / 图 / 附件,那段字节留在
// [voiceRetry] 里等用户决定(重发或丢弃)。
voiceRetry = VoiceRetryClip(clientMsgId, wav, durationMs, totalBytes)
_state.update {
it.rollbackPending(clientMsgId, reason).copy(
voiceDraft = VoiceDraftInfo(clientMsgId, durationMs, totalBytes),
)
}
}
/**
* `voice_output` 落地(契约 v0.96 §3.15):**她的语音回复**(音频 + 她说的话)。
*
* ## 判据全在 domain(纯函数)
* [isSilentVoiceProvider] / [voiceOutputHasAudio] 决定「这一轮有没有真音频」,
* [voiceReplyText] 决定显示哪一句(`reply` = 她说的,`text` = 转写)。`silent` 档
* **不画播放控件**,只显示文字 + 一句说明(见 `VoiceReplyRow`)。
*
* ## 音频先落成本地文件
* base64 解码 + 写缓存都在 IO 上做(几 MB 的活不能占主线程),解码 / 落盘失败就退化成
* 「只有文字 + 那句『本机没能准备好』」——**绝不**把本机的失败说成「她只回了文字」
* (那是替服务端下一个它没下的结论)。
*
* ## 只定向回原连接、断线不补投
* 本方法**不做任何补救**(不拉历史、不重发 `voice_input`):契约明写这一帧不补投,
* 回合的正文与转写本来就会经流式 / `message_persisted` 落到消息列表里。
*/
private fun onVoiceOutput(event: WsEvent.VoiceOutput) {
val provider = event.provider
val hasAudio = voiceOutputHasAudio(event.audio, provider)
val id = "voice-reply-${UUID.randomUUID()}"
val previous = _state.value.voiceReply?.file
_state.update {
it.copy(
voiceReply = VoiceReply(
id = id,
reply = voiceReplyText(event.reply, event.text),
transcript = event.text?.takeIf { text -> text.isNotBlank() },
format = event.format,
provider = provider,
hasAudio = hasAudio,
file = null,
),
)
}
// 这一轮没有音频 ⇒ 到这里就结束了(正文已经在上面那一格里)。
val audio = event.audio?.takeIf { hasAudio } ?: return
viewModelScope.launch {
val file = withContext(Dispatchers.IO) {
previous?.delete()
storeVoiceReply(audio, event.format)
}
_state.update { state ->
// 只有「这一格还是我刚放进去的那一条」才补文件:迟到的解码不许覆盖更新的那一条。
if (state.voiceReply?.id == id) {
state.copy(voiceReply = state.voiceReply.copy(file = file))
} else {
file?.delete()
state
}
}
}
}
/**
* 把 `voice_output` 的 base64 音频落成 `cacheDir/media/` 下一个文件(**IO 线程调用**)。
*
* 目录与写入纪律直接复用 [VoiceStore] / [DocumentCache]:同一个 `media/` 根(与接收侧的
* 语音副本同一层,与 `voice.url` 的 `/api/v1/media/` 同源)、同样的「先写 `.part` 再改名」。
* 名字是现铸的随机名(`reply-<uuid>.<ext>`)而不是内容寻址:这一份是**一次性**的实时产物,
* 没有第二个人会来按名字取它。
*
* @return 可直接交给 `MediaPlayer` 的文件;任何一步失败回 `null`(调用方据此退化成文字)。
*/
private fun storeVoiceReply(base64: String, format: String?): File? = runCatching {
val bytes = Base64.decode(base64, Base64.NO_WRAP)
if (bytes.isEmpty()) return null
// 扩展名只影响可读性:`MediaPlayer` 按文件头嗅探,不认扩展名。不认识的格式一律 `.bin`。
val extension = format?.trim()?.lowercase()?.takeIf { it in VOICE_REPLY_FORMATS } ?: "bin"
val file = File(
VoiceStore.directory(container.appContext.cacheDir),
"reply-${UUID.randomUUID()}.$extension",
)
DocumentCache.write(file, bytes)
file
}.getOrNull()
/** 记一条语音输入的如实告知,到期自动收走(与 [showAttachmentNotice] 同一形态)。 */
private fun showVoiceNotice(text: String) {
_state.update { it.copy(voiceNotice = text) }
viewModelScope.launch {
kotlinx.coroutines.delay(VOICE_NOTICE_MILLIS)
_state.update { if (it.voiceNotice == text) it.copy(voiceNotice = null) else it }
}
}
/**
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
@@ -1509,7 +1954,7 @@ class ChatViewModel(
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
*
* ## 分享不声明(`applySendPreference` 恒为 false)
* ## 分享不声明(`fromShare = true` ⇒ [ShareRules.declaresSendPreference] 恒为 false)
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递,
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
* 两种声明在这里**都**是错的:
@@ -1570,7 +2015,7 @@ class ChatViewModel(
emitPlannedMessages(
plan = imageSendPlan(text, share.images, files),
clearDraft = false,
applySendPreference = false,
fromShare = true,
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
)
}
@@ -1734,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)
@@ -1754,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,
)
}
@@ -1766,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])——**正文因此一个字节都不用动**
@@ -1781,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]):空内容不上屏、不排队、不发帧。
@@ -1861,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)
@@ -2285,7 +2734,7 @@ class ChatViewModel(
* 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发
* `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时**
* 才调(分享那条批量路径传 false,见 [sendShared])。
* 才调(那个值出自 [ShareRules.declaresSendPreference],分享那条批量路径恒为 false,见 [sendShared])。
*
* [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**:
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
@@ -2903,6 +3352,12 @@ class ChatViewModel(
is WsEvent.MessageDeleted -> applyMessageDeleted(event.id)
// 语音应答(契约 v0.96 §3.15):会话归属由解析层从 `data` 提到帧上(帧壳没有
// sessionId),所以上面那道 `isForSession` 闸对它**真的生效**——别的会话的语音
// 不会在本会话响起来。未知归属(坏帧)按同一道闸的既有口径放行(「无法判定」≠
// 「不属于本会话」)。判据与取舍见 [onVoiceOutput]。
is WsEvent.VoiceOutput -> onVoiceOutput(event)
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = event.mode).withActiveTurns(event.activeTurns) }
}
@@ -3382,6 +3837,46 @@ private const val SHARE_TEXT_NOT_STAGED = "这段内容没能存成文件,已
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
/** 语音输入被本端挡下时那句告知停留的时长(同其它几条「动作刚发生」的告知)。 */
private const val VOICE_NOTICE_MILLIS = 8 * 1000L
/**
* 语音**还没连上**、因此一个字节都没发出去时的如实告知(走 `error` 通道,与「发送失败」同档)。
*
* 说清三件事:没连上(所以确实没发出去)、这段没丢(留在 `voiceDraft` 那一行)、下一步按「重发」。
* 刻意不写「发送失败」:失败的是连接,而这一段一次都没上过路——这与文本那条
* [SEND_QUEUED_NOTICE] 的措辞纪律同源,只是语音的字节留在本端等**用户**决定(不进自动补发队列)。
*/
private const val VOICE_NOT_CONNECTED_MESSAGE = "还没连上,这段语音没发出去;连上后点「重发」再试一次"
/**
* 连接就绪却仍写不出去(socket 对象已空 / 序列化抛异常)时的如实告知。
*
* 与 [VOICE_NOT_CONNECTED_MESSAGE] 分开:这一档说的不是连接,而是这一次写帧真的失败了,
* 所以不提「连上后」——那时候它已经连着。
*/
private const val VOICE_SEND_FAILED_MESSAGE = "这段语音没发出去,点「重发」再试一次"
/**
* `voice_output` 的音频落盘时**允许照抄**的扩展名(契约白名单 `wav|mp3|ogg`,服务端目前恒 `wav`)。
* 白名单之外的取值一律用 `.bin`:扩展名只用来让文件看得懂,**不**参与解码判定。
*/
private val VOICE_REPLY_FORMATS: Set<String> = setOf("wav", "mp3", "ogg")
/**
* 一段「录好、还没发出去」的语音的**完整**内容(只在 VM 私有字段里,不进界面状态)。
*
* [wav] 是已经封好 RIFF 头的那份字节:重发时原样再编码一次 base64,**不重新封装**——
* 重发的必须是同一段字节,否则「同一个 `clientMsgId`」在服务端判重之后留下的那份音频
* 与用户眼前这段就对不上了。
*/
private data class VoiceRetryClip(
val clientMsgId: String,
val wav: ByteArray,
val durationMs: Int,
val totalBytes: Int,
)
/**
* 「并行没排上」告知在界面上停留的时长(同上:描述刚发生的那一次发送)。
*
@@ -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
@@ -585,6 +650,9 @@ fun PendingBubble(
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
)
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
// 语音:正文是空的(本端不转写,用户说的话要等服务端 STT 之后才落库),
// 所以这一条消息至少要有「这是语音、多长」这一枚可见凭据——与接收侧的语音形态同一个外观。
item.voiceDurationMs?.let { durationMs -> VoiceClipLabel(durationMs) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
@@ -748,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(
@@ -760,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),
@@ -773,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,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
@@ -789,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,349 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failureOf
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.voiceElapsedLabel
import top.yeij.cyrene.domain.chat.voiceReplyNote
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 输入栏那几个**语音**形态(录音中 / 待重发 / 她的语音回复),以及乐观气泡里那一枚「语音」。
*
* ## 为什么单独一个文件
* 它们四个共用同一套尺寸与配色,而这个文件之外没人需要知道这些细节:`ChatScreen` 只负责接线,
* `MessageBubble` 只调用最后一枚。放在一起,改一眼就能看全。
*
* ## 图标从哪来(查过 AAR,不是猜的)
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚,**没有麦克风**;**extended** 包里有真的
* `Mic` / `MicSlash`(`javap` 确认过 `MicKt.getMic(MiuixIcons)`),而本模块本来就在用 extended
* (`VoiceBubble.kt` 的 `Play` / `Pause`、`ChatScreen.kt` 的 `File` / `Image`)。
*
* ## 读屏名字即设备验收的 `content-desc`
* 四枚可点控件的 `contentDescription` 都抽成了常量([VOICE_RECORD_DESCRIPTION] 等):
* `uiautomator dump` 靠它们定位,改文案等于改验收口径,两处一起改。
*/
/** 「开始录音」那枚麦克风的读屏名字。 */
const val VOICE_RECORD_DESCRIPTION: String = "按住说话(点按开始录音)"
/** 录音中那枚麦克风(点它 = 结束并发送)。 */
const val VOICE_RECORD_STOP_DESCRIPTION: String = "结束录音并发送"
/** 录音中的「取消」。 */
const val VOICE_RECORD_CANCEL_DESCRIPTION: String = "取消这段录音"
/** 待重发那一段的「重发」。 */
const val VOICE_DRAFT_RETRY_DESCRIPTION: String = "重发这段语音"
/** 待重发那一段的「丢弃」。 */
const val VOICE_DRAFT_DISCARD_DESCRIPTION: String = "丢弃这段语音"
/** 她的语音回复的播放 / 停止。 */
const val VOICE_REPLY_PLAY_DESCRIPTION: String = "播放她的语音回复"
const val VOICE_REPLY_STOP_DESCRIPTION: String = "停止播放她的语音回复"
/** 关掉她的语音回复那一张卡片。 */
const val VOICE_REPLY_DISMISS_DESCRIPTION: String = "关闭这条语音回复"
/** 乐观气泡里那枚「语音」的字样(与接收侧的 [VOICE_LABEL] 同一个词,不另造)。 */
const val VOICE_CLIP_LABEL: String = VOICE_LABEL
/**
* 麦克风**没授权**时的如实告知。
*
* 说「到系统设置里允许」而不是再申请一次:本端只在第一次点麦克风时申请(平台规范),
* 被拒之后重复申请只会被系统静默挡回(Android 不再弹框),那样用户点十次都毫无反应。
*/
const val VOICE_PERMISSION_DENIED_MESSAGE: String =
"没有麦克风权限,到系统设置里允许后再试"
/**
* 有权限、但麦克风**现在起不来**(被别的应用占用 / 设备不支持这组采样参数)。
*
* 与 [VOICE_PERMISSION_DENIED_MESSAGE] 分开:那一档用户要去设置页,这一档要等一会或换个应用;
* 说成同一句就是把人引向一个解决不了问题的动作。
*/
const val VOICE_UNAVAILABLE_MESSAGE: String = "麦克风现在打不开,可能被别的应用占着"
/** 图标尺寸:与接收侧语音形态同一档。 */
private val VOICE_ICON_SIZE = 18.dp
/** 卡片底色与圆角:与 `VoiceMessageRow` 那一枚同形(同一个产品里的语音长一个样)。 */
private val VOICE_CARD_SHAPE = RoundedCornerShape(12.dp)
/**
* 录音中那一行:`🎤 录音中 · 0:07 [取消] [发送]`。
*
* 时长由**已收到的字节数**反算(见 `VoiceRecorder`),所以屏幕上这个数字与发出去的音频长度
* 严格一致。到 60 秒时 `VoiceRecorder` 会置「满了」标记、由调用方直接收尾——这里不做倒计时:
* 一个还在数的计时器在「已经满了」那一刻是假话。
*
* 两枚按钮分开(不是「点头像就发」那种手势):录音是一条**有去无回**的动作(这段 bytes 一旦
* 发出就落进服务端编排),取消必须和发送一样容易按到。
*/
@Composable
fun VoiceRecordingRow(
elapsedMs: Int,
onCancel: () -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.error,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "录音中 · ${voiceElapsedLabel(elapsedMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onCancel,
modifier = Modifier.semantics { contentDescription = VOICE_RECORD_CANCEL_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
Button(onClick = onSend) { Text("发送") }
}
}
/**
* 一段**录好但没发出去**的语音:`🎤 语音 · 0:07 [重发] [丢弃]`。
*
* 它只在一件事之后出现:`voice_input` 没能上行(连接没就绪 / 写帧失败)。此时那段字节**只在
* 内存里**,界面上必须给两条出路——重发(**复用同一个 `clientMsgId`**,服务端据此判重)或丢弃。
* 不给「退回到输入栏」那种出路:语音没有可以退回到输入框的形态。
*/
@Composable
fun VoiceDraftRow(
draft: VoiceDraftInfo,
onRetry: () -> Unit,
onDiscard: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(draft.durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
TextButton(
text = "重发",
onClick = onRetry,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_RETRY_DESCRIPTION },
)
TextButton(
text = "丢弃",
onClick = onDiscard,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_DISCARD_DESCRIPTION },
)
}
}
/**
* 她的语音回复:`[▶] 她的语音回复 · 0:03 [×]` + 她说的话([VoiceReply.reply])。
*
* ## 只有文字那一档
* `provider == "silent"`(或这一轮根本没有 audio)时**不画播放控件**,只画文字 + 一句
* [voiceSilentNote](「这一轮她只回了文字,没有语音」)。契约的硬要求是「UI 别把它画成播放器」;
* 补那句解释是为了让「她选择只用文字」与「播放器坏了」在屏幕上分得开。
*
* ## 可播放但播不了
* 音频在、本机解码 / 落盘失败时**照样画**播放控件:点了会走到 [failureOf] 那一行如实说
* 「播不了」——这比一个看起来什么都没有的控件诚实。
*/
@Composable
fun VoiceReplyRow(
reply: VoiceReply,
playback: VoicePlaybackState,
onTogglePlay: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val active = playback.isActiveFor(reply.id)
val loading = playback.isLoadingFor(reply.id)
val failure = playback.failureOf(reply.id)
val text = reply.reply
Column(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (reply.playable) {
// 整枚可点(不只是 18dp 的图标),读屏名字说清「点它会怎样」。
Row(
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onTogglePlay)
.semantics {
contentDescription = if (active) {
VOICE_REPLY_STOP_DESCRIPTION
} else {
VOICE_REPLY_PLAY_DESCRIPTION
}
}
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
} else {
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
// 「现在在干什么」与「点它会怎样」分开说(与接收侧语音形态同一条纪律):
// 只有真的在取字节时才说「准备中…」,正在响时不冒充加载。
if (loading) {
Text(
text = "准备中…",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = onDismiss,
modifier = Modifier.semantics { contentDescription = VOICE_REPLY_DISMISS_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
}
}
text?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.padding(start = 2.dp, end = 8.dp),
)
}
// 「只有文字」与「本机没准备好」是两件事,各说各的那句(判据是纯函数)。
voiceReplyNote(silent = reply.silent, hasAudioFile = reply.file != null)?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
failure?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
)
}
}
}
/**
* 乐观气泡里那一枚「语音」:`🎤 语音 · 0:07`。
*
* 用户按住说话之后,气泡**立刻**出现(与文本消息的乐观路径同一个 `pending` 列表、同一个回滚),
* 只是正文还没到——转写要等服务端。所以这里只画媒体类型与时长,**不画播放控件**:
* 本地已经发出去的那段音频不在播放通道里(它已经在服务端了,回放应当走历史消息那条路)。
*/
@Composable
fun VoiceClipLabel(durationMs: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
}
@@ -108,6 +108,36 @@ class VoicePlayer(
}
}
/**
* 放一段**已经在手上**的音频(`voice_output` 那条路:base64 就在帧里,不需要下载)。
*
* 与 [toggle] 走**同一条通道**、同一套身份比对与释放纪律(同一台喇叭上永远只有一条语音在响),
* 差别只有两个:音频不是「按消息取字节」而是由调用方现给([load]),而且键是
* `voice_output` 那一帧自己铸的 [clipId](它不是历史消息,没有 `messageId` 可用)。
*
* 同样遵守「再点一下 = 停」:`clipId` 正在放 / 正在载入时,这一下是**停**。
*
* @param load 取这份本地文件(写缓存 / 解码都在里面,调用方应切到 IO)。
*/
fun toggleClip(clipId: String, load: suspend () -> Result<File>) {
if (_state.value.isActiveFor(clipId)) {
stop()
return
}
stop()
_state.value = _state.value.pressed(clipId)
loading = scope.launch {
load().fold(
onSuccess = { file ->
if (_state.value.isActiveFor(clipId)) play(clipId, file)
},
onFailure = { error ->
_state.value = _state.value.failed(clipId, voicePlaybackFailureMessage(error))
},
)
}
}
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
fun stop() {
loading?.cancel()
@@ -0,0 +1,49 @@
package top.yeij.cyrene.ui.chat
import java.io.File
/**
* `voice_output` 在界面上的那一份「她的语音回复」(只管**这一条实时应答**,不进历史)。
*
* ## 为什么它不在消息列表里
* 契约 v0.96 §3.15:这一帧**只定向回发起 `voice_input` 的那条连接**、**连接断了不补投**,
* 而且回合的正文本来就会经流式 / `message_persisted` 落进消息列表。所以它不是一条消息,
* 是「这一次语音回合的音频 + 她说的话」这一次性产物:放在输入栏上方一条卡片里,
* 用户随时可以关掉([ChatViewModel.dismissVoiceReply]),它不参与排序、不占历史。
*
* ## [hasAudio] 与 [file] 是两件事
* - [hasAudio]:**服务端**这一轮给了真音频(`provider != "silent"` 且 `audio` 非空白)。
* 为假 ⇒ 界面上**一个播放控件都不画**(画了就是点下去必然失败的按钮)。
* - [file]:音频在本机的那份文件。`hasAudio` 为真而它是 `null` = 本机解码 / 落盘失败
* ——这一档**照样画**播放控件(点了会如实报「播不了」),**绝不**把本机的故障说成
* 「她这一轮只回了文字」(见 `domain.chat.voiceReplyNote`)。
*
* @property id 播放通道的键:每条 `voice_output` 现铸一个(它不是历史消息,没有 `messageId`)。
* @property reply 她说的那一句(音频念的就是它),见 `domain.chat.voiceReplyText`。
* @property transcript 转写(载荷里的 `text`):那段音频被识别成什么;只作兜底与诊断。
* @property format 服务端给的音频格式;本端**不照抄它去解码**(`MediaPlayer` 按文件头嗅探)。
*/
data class VoiceReply(
val id: String,
val reply: String?,
val transcript: String?,
val format: String?,
val provider: String?,
val hasAudio: Boolean,
val file: File?,
/**
* 这一条还该不该**自动播放**(默认 true,播过就置假,见 [ChatViewModel.consumeVoiceReplyAutoPlay])。
*
* 为什么要这一位:这一帧**只在到达时**有意义(断线不补投),而到达的那一刻用户可能正在别的
* 会话 / 别的页面里。若只按「音频落好了就放」来触发,等他切回来时会**突然响一段旧语音**
* ——那不是他刚做的事。所以自动播放只认**第一次**落好的那一下,之后靠卡片上的播放键。
*/
val autoPlay: Boolean = true,
) {
/** 能不能画「播放」那枚控件:这一轮**有**音频,而且它已经落到本机。 */
val playable: Boolean get() = hasAudio && file != null
/** 这一轮是不是「没有音频」(= `provider == "silent"` 或 `audio` 缺席)。 */
val silent: Boolean get() = !hasAudio
}
@@ -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
}
}
@@ -1,5 +1,7 @@
package top.yeij.cyrene.ui.sessions
import top.yeij.cyrene.data.repository.MessageIncrement
/**
* 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。
*
@@ -18,11 +20,15 @@ internal enum class PersistedRoute {
/**
* **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数**
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,问服务端要那个数。
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地问服务端要。
*
* 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。
* 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处,
* 于是红点必须靠用户开一次抽屉(那次静默重拉)才补上。
* 于是红点必须靠用户开一次抽屉才补上。
*
* 「要」的是什么,W77 换过一次:原来是整表 `GET /sessions`,现在是**这条会话的消息增量**
* `GET /messages?after=`(见 [incrementCursors] / [applyIncrement]);整表退回成「增量给不出
* 可信答案」时的回落。判据本身不受影响——这一档该不该被窗口挡住,与问的是哪种口径无关。
*/
InactiveSession,
}
@@ -190,3 +196,117 @@ internal fun silentRefreshStep(
*/
internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle =
SilentRefreshThrottle(lastPullAt = nowMillis)
// ---------------------------------------------------------------------------------------------
// W77:非激活会话的静默刷新,从「整表重拉 GET /sessions」换成「前向增量 GET /messages?after=」
//
// 后端明确批了这一换(回执二十八第 3 条:`?after=` 毫秒 `createdAt` 早就在册)。换掉的理由是**量**:
// 整表一次要回**所有**会话(每条一行预览 + 未读计数),而这里只需要回**真的变了的那一个**会话的新消息。
// 下面三个纯函数就是这一换的全部判据,放在这里而不是 VM 里,理由与 [silentRefreshStep] 一样:
// 纯函数才穷举得了、才不用在 JVM 上摆一台假服务器。
// ---------------------------------------------------------------------------------------------
/**
* 某个会话现在该从哪个 `createdAt` 起问增量(纯函数);`null` = **这一行问不了**。
*
* 游标就取 [SessionItem.lastMessageAt]——最近一次权威 `GET /sessions` 给的、本端已知最新一条的
* `createdAt`——**不另立账本**:
* - 服务端的 `after` 是 `created_at > ?`(**严格**大于),而 [SessionItem.lastMessageAt] 正是
* 「已经见过的最新一条」⇒ 两边语义天然对齐,多立一个账本只会多一个会漂移的真相源;
* - 增量自己会把 [SessionItem.lastMessageAt] 推到新游标([applyIncrement]),于是账本自洽地滚动,
* 不需要第二处推进。
*
* 两种「问不了」都落到 `null`,它们其实是同一件事——**本端不知道这条会话看到哪儿了**:
* - 行不在列表里(会话在别处新建、或本端那份列表还没拉到它)⇒ 没有游标可用;
* - 行在但 `lastMessageAt` 为 `null`(本端认为它一条消息都没有,却收到了它的落库广播)⇒
* 手里那份列表已经陈旧到不可信。
*
* 两种都由调用方回落到整表(唯一权威),见 `SessionsViewModel.flushPendingIncrements`。
*/
internal fun incrementCursorFor(item: SessionItem?): Long? = item?.lastMessageAt
/**
* 一批待问增量的会话 id → 各自的 `after` 游标(纯函数)。
*
* `null` = **这批里有一个问不了**([incrementCursorFor] 给了 `null`)⇒ 调用方**整批**回落整表。
* 不混着来是刻意的:一半靠增量、一半靠整表,得到的那份列表没有任何一处能说得清「它是什么时刻的」,
* 而未读面上最不能出的就是这种「说不清」。
*
* 返回 `emptyMap` = 这批本来就是空的(没有事要做),**不是**失败。
*/
internal fun incrementCursors(
sessionIds: Collection<String>,
sessions: List<SessionItem>,
): Map<String, Long>? {
if (sessionIds.isEmpty()) return emptyMap()
val byId = sessions.associateBy { it.id }
val cursors = LinkedHashMap<String, Long>(sessionIds.size)
for (id in sessionIds) {
cursors[id] = incrementCursorFor(byId[id]) ?: return null
}
return cursors
}
/**
* 把一次增量折进列表(纯函数):**只动那一行**,其余原样返回。
*
* ## 未读数怎么算:先过已读游标,再加
* 增量条数**不是**直接往角标上加的,要先按本地已知的已读游标 [readAt](`readCursors[sessionId]`)筛一遍。
* **过滤口径**与服务端同源:`session_read.go` 的 `unreadCountQuery` 是 `count(created_at > read_at)`,
* 而 `read_at` 是**账号级单一游标**,可能比本端已知的最新消息还新——用户在这台机器上点开过它
* ([SessionsViewModel.select] 推的就是「现在」),随后别的设备又发了消息。那一刻
* `(lastMessageAt, readAt]` 之间的消息**服务端算已读**,一律加进角标就会多出来一个红点。所以只数
* `createdAt > readAt` 的那几条。
*
* 已有的 [SessionItem.unreadCount] 直接保留相加是安全的:它来自服务端,而这次增量里的每一条都严格新于
* [from](服务端的 `after` 就是 `created_at > ?`),两者没有交集,不会重复计数。
*
* ## 已知边界:同源的只是**过滤口径**,基数仍是上次整表的旧值
* 相加的底数 [SessionItem.unreadCount] 来自上一次整表刷新,而 `read_at` 是账号级的、会被别的设备改写。
* 断线期间他端把某个会话读掉时,那条 `session_read` 帧本地**永久收不到**(断线不补帧),于是底数偏大,
* 这一行会一直多算那几条。这是**已接受的边界**,不是漏网:红点本来就只承诺「大概有几条新的」,漂移
* 到下一次整表刷新(打开抽屉 / 初始化 / 任何一次回落)就自愈。
*
* 这里**不**去检测它(例如「[readAt] > [from] ⇒ 底数可疑 ⇒ 回落整表」):本端刚点开过这个会话时
* `readAt` 本来就被推到「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量
* 路径在正常使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门
* 不如不装。
*
* ## 并发的那一份真相
* [from] 不是「这次用了什么游标」的装饰:行上的 [SessionItem.lastMessageAt] **已经不等于** [from],
* 就说明在这次请求飞在天上的时候,另一份更权威的结果(一次整表刷新、或另一次增量)已经推进过这一行。
* 那些条数已经被别人数过一遍了,再加一次就是重复计数 ⇒ 整行不动。
*
* 一行都没被改动时返回**同一个列表实例**(与 `applyBusyFrame` 同一习惯):这条路径跑在后台,
* 没有必要为了「什么都没变」让整个抽屉重组一遍。
*
* @param from 这次请求用的 `after` 游标。
* @param readAt 本端已知的已读游标;`null` = 不知道(当作 `0`,即「一条都还没读过」)。
* @param increment 升序的增量(`MessageRepository.loadIncrement` 的产物)。
*/
internal fun List<SessionItem>.applyIncrement(
sessionId: String,
from: Long,
readAt: Long?,
increment: MessageIncrement,
): List<SessionItem> {
if (increment.messages.isEmpty()) return this
val floor = readAt ?: 0L
val unseen = increment.messages.count { it.createdAt > floor }
// 升序 ⇒ 最后一条就是最新的一条(`loadIncrement` 保证这一点)。
val preview = increment.messages.last().content.toPreviewText().takeIf { it.isNotBlank() }
var touched = false
val updated = map { item ->
if (item.id != sessionId || item.lastMessageAt != from) {
item
} else {
touched = true
item.copy(
unreadCount = item.unreadCount + unseen,
lastMessage = preview,
lastMessageAt = increment.cursor,
)
}
}
return if (touched) updated else this
}
@@ -28,7 +28,8 @@ data class SessionItem(
val lastMessage: String?,
/**
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
* 两处用:已读游标比对(见 [SessionItem.withReadCursor]),以及增量追尾的 `after` 游标
* (见 `incrementCursorFor`,由 `applyIncrement` 推进到这次增量的 `cursor`)。不直接显示。
*/
val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
@@ -82,9 +83,12 @@ data class SessionsUiState(
*
* **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带
* `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**:
* 收到非激活会话的落库广播就**节流地静默重拉**([requestSilentRefresh],窗口
* [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次抽屉**就会出现
* (S6,登记见 `devdocs/05-开发进度与交接.md`)。
* 收到非激活会话的落库广播就**节流地问一次那条会话的消息增量**([requestSilentRefresh] →
* [flushPendingIncrements],窗口 [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次
* 抽屉**就会出现(S6,登记见 `devdocs/05-开发进度与交接.md`)。
*
* 取数走的是 `GET /messages?after=<ms>`(前向增量)而不是整表 `GET /sessions`——W77 换的,理由与回落
* 规则都写在 [flushPendingIncrements] 上。整表仍是**唯一权威**,只是不再需要为一条新消息付它的价钱。
*
* ## WS 连接:全 App 共享一条,而**本类是它唯一的主人**
* `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条
@@ -136,6 +140,17 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
*/
private var trailingRefreshJob: Job? = null
/**
* 窗口里攒下的「要问增量的会话 id」(W77)。
*
* 为什么是一个**集合**而不是一个 id:节流窗口把同一段时间里的落库广播合并成一次拉取,而那几个
* 会话**未必是同一个**(主会话说一句、另一个会话也说一句)。只记住最后一条的话,前面那个会话的
* 红点就永远不会出现——那正是 W49 修过的同一类病,只是换了个成因。
*
* 只在 [requestSilentRefresh] / [flushPendingIncrements] 两处写;登出时清空(它属于上一个身份)。
*/
private val pendingIncrements = mutableSetOf<String>()
/**
* 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。
*
@@ -201,6 +216,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
trailingRefreshJob?.cancel()
trailingRefreshJob = null
silentRefreshThrottle = SilentRefreshThrottle()
// 攒下的会话 id 同理:它们是上一个身份的会话,重登后那些 id 要么不存在、
// 要么属于别人;留着只会让重登后的第一次窗口拿旧 id 去问增量。
pendingIncrements.clear()
pulledForIdentity = null
busyTurns = emptyMap()
_state.value = SessionsUiState()
@@ -316,42 +334,54 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
/**
* 节流地静默重拉一次列表 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
* 节流地静默对齐一次 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
* 必须开一次抽屉才补上」(S6)。
*
* 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理
* 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。
*
* ## 为什么是「重拉」而不是「本地把未读数加一」
* `message_persisted` 帧**不带 `unreadCount`**:本地没有逐条时间线,自己数出来的数会与编辑 /
* ## 为什么是「问服务端」而不是「本地把未读数加一」
* `message_persisted` 帧**不带 `unreadCount`**:本端没有逐条时间线,靠帧自己数出来的数会与编辑 /
* 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**,
* 只是把「服务端才是唯一真源」这件事提前做一次 —— 走的是**与抽屉打开时那次完全相同**的路径
* ([refresh],含已读游标遮罩),只是不需要用户先开一次抽屉。
* 只是把「服务端才是唯一真源」这件事提前做一次。
*
* ## W77:问的是**那一条会话的增量**,不再是整表
* 曾经的落点是 `refresh(silent = true)`(整表 `GET /sessions`),一次要为**所有**会话付一行预览 +
* 未读计数的价钱,而真正变了的只有一个会话。现在落到 [flushPendingIncrements]:
* `GET /messages?after=<已见最大 createdAt>`,只回那几条新消息。整表仍是唯一权威,但只在
* 「增量给不出可信答案」时才请它出场(回落规则写在 [flushPendingIncrements] 上)。
*
* ## 三条纪律
* 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次
* 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。
* 2. **静默**:`silent = true`,不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。
* 失败时做什么**完全沿用既有行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;
* 以及「网络异常,显示 x 分钟前的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,
* 不该比用户自己打开抽屉时的那次刷新更吵。
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。[refresh] 内部唯一会动
* `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回主会话),
* 那是既有的、正确的收敛,不是重置。
* 2. **静默**:不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。失败时做什么**完全沿用既有
* 行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;以及「网络异常,显示 x 分钟前
* 的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,不该比用户自己打开抽屉时的那次
* 刷新更吵。
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。回落那条路([refresh])
* 内部唯一会动 `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回
* 主会话),那是既有的、正确的收敛,不是重置。
*
* @param cause 这次为什么要拉;决定它走不走窗口(见 [SilentRefreshCause])。
* @param sessionId 这一帧说的是**哪个**会话;`null` = 没有具体会话([SilentRefreshCause.ContextChanged]),
* 那一档只能走整表。窗口里攒下的 id 由 [pendingIncrements] 记着,因为一次窗口可能合并**几个不同**会话。
*/
private fun requestSilentRefresh(cause: SilentRefreshCause) {
private fun requestSilentRefresh(cause: SilentRefreshCause, sessionId: String? = null) {
val step = silentRefreshStep(cause, System.currentTimeMillis(), silentRefreshThrottle)
silentRefreshThrottle = step.throttle
// 没有具体会话时不留 id:那一次整表会把所有会话都对上,攒着的 id 一个也不缺。
if (sessionId != null) pendingIncrements += sessionId
when (step.action) {
// 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。
// (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档
// 走到这里时 trailingPending 必为 false,补拉早跑完了。)
//
// 撤掉补拉**不等于**可以丢掉它攒下的 id:[pendingIncrements] 是独立的一份账本,
// 这一趟会把它们一并做掉。
SilentRefreshAction.PullNow -> {
trailingRefreshJob?.cancel()
trailingRefreshJob = null
refresh(silent = true)
flushPendingIncrements(fullTable = sessionId == null)
}
// 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串
@@ -365,15 +395,75 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
trailingRefreshJob = viewModelScope.launch {
delay(step.delayMillis)
silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis())
refresh(silent = true)
flushPendingIncrements(fullTable = false)
}
}
// 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。
// (但这一帧的会话 id 已经在上面记进 [pendingIncrements] 了 —— 补拉不会漏掉它。)
SilentRefreshAction.Ignore -> Unit
}
}
/**
* 把 [pendingIncrements] 里攒下的会话**逐个问一次消息增量**,问不了就整批回落整表(W77)。
*
* 这就是 W49 那条「非激活会话落了新消息 ⇒ 整表重拉 `GET /sessions`」的替代品:整表一次要回
* **所有**会话(每条一行预览 + 未读计数),而这里只回**真的变了的那一个**会话的新消息。
*
* ## 回落的三条理由(都落到同一条路:整表)
* 1. **问不了增量**:[incrementCursors] 给 `null` —— 行不在列表里,或本端不知道它看到哪儿了;
* 2. **追不到尾**:`complete = false` —— 整个追尾里拿过整页(服务端的 `LIMIT` 切过一刀,那一刀
* 可能劈开了同一毫秒,而服务端只给严格 `>`,被劈开的尾巴再也追不回来;此后的空页与不满页
* 都说不清),或翻满了 `MessageRepository.MAX_INCREMENT_PAGES`。那时增量只覆盖了积压的一部分,
* 拿它当「全部」就是把红点做小;
* 3. **请求失败**:含会话已在别处被删掉的 `404` —— 那一档要靠整表把这一行从列表里去掉。
*
* 三条都是「增量给不出可信答案」⇒ 去问唯一权威。**一次批次最多回落一次**:整批共用这一个失败
* 出口,否则 N 个会话同时失败就是 N 次整表。
*
* ## 刻意不做的一件事:不写快照
* [refresh] 成功时会 `prefs.saveSnapshot`,这里**不写**。快照的类型是 `List<SessionSummaryDto>`
* (整表响应),而增量只回了消息——拿它去拼一份 DTO 列表,缺的字段(`type` / `createdAt` /
* `pinned` / `personaId`)只能靠猜,那等于用一个更差的东西覆盖掉一份好快照。代价是冷启动第一帧
* 可能显示上一次整表时的未读与预览,紧接着 `init` 里那次 `refresh()` 就会把它修正。
*
* ## 也不顺带刷角色目录
* [refresh] 会捎带 `loadPersonas()`,这条路径不会。角色目录的刷新点是 init / 新建 / 删除 / 抽屉
* 打开(那几个都是用户动作或一次性事件),「别的会话来了条消息」从来不是它的刷新时机——此前它只是
* 跟着整表搭了便车。W77 把便车取消,角色目录少掉的是几次没人要的请求。
*
* @param fullTable `true` = 这一趟没有具体会话(或已判明必须整表),直接走 [refresh]。
*/
private fun flushPendingIncrements(fullTable: Boolean) {
val targets = pendingIncrements.toList()
pendingIncrements.clear()
if (fullTable) {
refresh(silent = true)
return
}
val cursors = incrementCursors(targets, _state.value.sessions)
if (cursors == null) {
refresh(silent = true)
return
}
// 空批次 = 没有事要做(不是失败):不回落、也不发请求。
if (cursors.isEmpty()) return
viewModelScope.launch {
for ((sessionId, from) in cursors) {
val increment = container.messageRepository.loadIncrement(sessionId, after = from).getOrNull()
if (increment == null || !increment.complete) {
refresh(silent = true)
return@launch
}
val readAt = readCursors[sessionId]
_state.update {
it.copy(sessions = it.sessions.applyIncrement(sessionId, from, readAt, increment))
}
}
}
}
/**
* 切换 / 进入会话。
*
@@ -594,23 +684,24 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 两半共用同一条纪律:**本端哪里都不替服务端数未读**(逐条累加会与编辑 / 删除 / 别端操作漂移)。
* 区别只在「那它靠什么显示」:
* - [PersistedRoute.ActiveSession] ⇒ 就地清零 + 推进游标(这段一个字没改);
* - [PersistedRoute.InactiveSession] ⇒ **节流地静默重拉**([requestSilentRefresh]),
* 用服务端下发的 `unreadCount` 说话。这正是 W49 修的那一幕:此前这一档直接 `return`,于是
* 「App 停在主会话、次级会话来了新消息」没有任何人会去发现,红点要等用户打开一次抽屉(那次
* 静默重拉)才补上。
* - [PersistedRoute.InactiveSession] ⇒ **节流地问一次这条会话的消息增量**([requestSilentRefresh]
* 带上 `sessionId` → [flushPendingIncrements] → `MessageRepository.loadIncrement`),未读由
* 「增量里越过已读游标的条数」说话(W77;W49 那一档原本整表重拉 `GET /sessions`)。
* 这正是 W49 修的那一幕:此前这一档直接 `return`,于是「App 停在主会话、次级会话来了新消息」
* 没有任何人会去发现,红点要等用户打开一次抽屉才补上。
*
* 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做),
* 而「重拉一次列表」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
* 而「问一次增量」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
* 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的
* 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。
*/
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,
// 红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地问一次**这条会话**的消息
// 增量,红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。
// 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时,
@@ -634,9 +725,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 判据也共用 [persistedRoute](只是会话 id 由这一帧自己钉:它不带 `sessionId`)。
*
* 为什么必须在这里补一句:服务端的主动推送 Web 路由只发这一帧,**不补** `message_persisted`
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流静默重拉」根本不会被
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流刷新」根本不会被
* 触发——抽屉里主会话的红点就停在她开口之前,直到用户自己开一次抽屉。这里复用**同一条**路
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage]),
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage],会话 id 取主会话),
* 不新写取数路径,也不动节流判据本身。
*
* `id` 缺席 = 服务端那一刻**没有落库行** ⇒ 历史里没有这一行,未读面上没有任何东西要更新
@@ -647,7 +738,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
val sessionId = SessionSummaryDto.MAIN_SESSION_ID
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是主会话:她这句话已经显示在聊天流里(ChatViewModel 那侧并入)⇒ 与
// message_persisted 的同一档一样「看到即已读」,免得抽屉为她刚说的话亮一个红点。
@@ -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,234 @@
package top.yeij.cyrene.data.repository
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SessionsApi
import java.net.InetSocketAddress
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 增量追尾(W77 `MessageRepository.loadIncrement`)的**数据层**。
*
* 与 `MessageAfterRepositoryTest` 同一套做法(真 OkHttp + 真 Retrofit + JDK 自带的
* `com.sun.net.httpserver`,不引新依赖),只是假后端从「一份固定响应」换成**响应队列**:
* 追尾是一次循环,翻页语义只有在「第一页满、第二页空」这种**按次序**的响应下才验得出来。
*
* 断言分两类,都不看实现细节:
* - **线上证据**:每次请求的原始 URI(`after=` 只能有一个值,且随游标前进)、请求次数;
* - **结论**:[MessageIncrement.complete] 与 `cursor` —— 这两项是调用方决定「要不要回落整表」的
* 唯一依据,错了就是红点做小或做多。
*/
class MessageIncrementRepositoryTest {
private val backend = FakeMessagesBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = MessageRepository(
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
)
@Test
fun `a short page is the tail and only the forward cursor goes on the wire`() = runBlocking {
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
val increment = repository().loadIncrement("web", after = 100).getOrThrow()
val uri = backend.uris.single()
assertTrue(uri, uri.contains("after=100"))
assertTrue(uri, uri.contains("limit=50"))
assertFalse(uri, uri.contains("before="))
assertFalse(uri, uri.contains("q="))
// 不满一页 ⇒ 真的追到尾巴了,游标停在最新一条。
assertTrue(increment.complete)
assertEquals(200L, increment.cursor)
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
}
@Test
fun `an empty first page is a clean tail`() = runBlocking {
backend.enqueue(200, """{"data":[]}""")
val increment = repository().loadIncrement("web", after = 300).getOrThrow()
// 第一页就空 ⇒ 一条新消息都没有,没有可少给的:这是真的追到尾巴,游标原地不动。
assertEquals(1, backend.uris.size)
assertTrue(backend.uris.single(), backend.uris.single().contains("after=300"))
assertTrue(increment.complete)
assertEquals(300L, increment.cursor)
assertTrue(increment.messages.isEmpty())
}
@Test
fun `an empty page after a full page is not proof of a clean tail`() = runBlocking {
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
backend.enqueue(200, """{"data":[]}""")
val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow()
// 满页 ⇒ 多问一次,第二次拿到空页。但客户端**分不出**这次空页是「干净的尾巴」还是
// 「空页底下还压着同一毫秒的几条」:服务端只给严格 `> cursor`,被页边界劈开的那一毫秒
// 再也追不回来。分不出来就只能说「没追完」,把「要不要回落整表」交回调用方。
assertEquals(2, backend.uris.size)
assertTrue(backend.uris[1], backend.uris[1].contains("after=200"))
assertFalse(increment.complete)
assertEquals(200L, increment.cursor)
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
}
@Test
fun `a short page after a full page is not proof of a clean tail either`() = runBlocking {
// 页宽 2、积压 m1@500 / m2@500 / m3@500 / m4@600。第一页给 m1、m2 —— LIMIT 恰好把 500 那一
// 毫秒劈开;第二页 after=500 只能给 > 500 的,于是 [m4@600]、不满页。不满只证明「> 500 的行
// 就这一条」,对切点那一毫秒还有没有行一无所知:m3@500 既不 > 500,也就永远拿不到。
// 空页档与不满页档是同一个洞的两个出口,都要说「没追完」。
backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""")
backend.enqueue(200, """{"data":[${message("m4", 600)}]}""")
val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow()
assertEquals(2, backend.uris.size)
assertTrue(backend.uris[1], backend.uris[1].contains("after=500"))
assertFalse(increment.complete)
assertEquals(600L, increment.cursor)
assertEquals(listOf("m1", "m2", "m4"), increment.messages.map { it.id })
}
@Test
fun `a full page stuck on one millisecond is reported as incomplete`() = runBlocking {
// 真后端的形状:m1 / m2 / m3 三条落在同一毫秒,页宽 2。第一页给 m1、m2,第二页问
// `after=500` —— 严格 `>` 下后端只会回空页,m3@500 永远拿不到(这里不入队 m3:队列里
// 那份空响应就是真后端在 `after=500` 时会给的答案)。
backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""")
backend.enqueue(200, """{"data":[]}""")
val increment = repository().loadIncrement("web", after = 100, pageSize = 2).getOrThrow()
assertFalse(increment.complete)
// 游标停在那一毫秒(推不动就不装作推得动)。
assertEquals(500L, increment.cursor)
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
// 线上证据:第二次真的带上了 `after=500`,即「m3 拿不到」的那一问。
assertEquals(2, backend.uris.size)
assertTrue(backend.uris[1], backend.uris[1].contains("after=500"))
}
@Test
fun `the page budget stops the chase instead of chasing forever`() = runBlocking {
// 永远满页:预算只有 1 页时必须停下来并如实说「没追完」。
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
val increment = repository()
.loadIncrement("web", after = 0, pageSize = 2, maxPages = 1)
.getOrThrow()
assertEquals(1, backend.uris.size)
assertFalse(increment.complete)
assertEquals(200L, increment.cursor)
}
@Test
fun `a session deleted elsewhere surfaces as session_not_found with 404`() = runBlocking {
backend.enqueue(
404,
"""{"error":{"code":"session_not_found","message":"session \"web:gone\" not found"}}""",
)
val error = repository().loadIncrement("web:gone", after = 0).exceptionOrNull() as CyreneException
val server = error.error as CyreneError.Server
assertEquals("session_not_found", server.code)
assertEquals(404, server.httpStatus)
}
@Test
fun `one increment costs one request and k messages instead of the whole table`() = runBlocking {
// 10 个会话的整表长什么样(每条一行预览 + 未读计数):这是被换掉的那条路。
val wholeTable = """{"data":[${sessions(10)}]}"""
backend.enqueue(200, """{"data":[${message("new", 9_000)}]}""")
val increment = repository().loadIncrement("web", after = 8_000).getOrThrow()
assertEquals(1, backend.uris.size)
assertEquals(1, increment.messages.size)
assertTrue(increment.complete)
// 量级证据:增量回的字节数远小于整表(这里只有一条新消息 vs 十行会话预览)。
val incrementBytes = backend.responseBytes.single()
assertTrue(
"increment=$incrementBytes wholeTable=${wholeTable.toByteArray().size}",
incrementBytes * 4 < wholeTable.toByteArray().size,
)
}
/** 一条 `GET /messages` 形状的消息(字段与 `HistoryMessageDto` 对齐,只留断言用得到的)。 */
private fun message(id: String, createdAt: Long): String =
"""{"id":"$id","sessionId":"web","role":"user","content":"内容","createdAt":$createdAt}"""
/** 一段 `GET /sessions` 形状的会话行(约 60 rune 预览 + 未读计数),只用于量级对比。 */
private fun sessions(count: Int): String = (1..count).joinToString(",") { i ->
val preview = "这是第 $i 个会话的最后一条消息预览,长度大致与真实数据同量级,用来量字节数。"
"""{"id":"s$i","type":"secondary","title":"会话 $i","createdAt":${i * 1000},""" +
""""pinned":false,"personaId":null,"unreadCount":$i,""" +
""""lastMessage":{"id":"p$i","sessionId":"s$i","role":"assistant",""" +
""""content":"$preview","createdAt":${i * 1000}}}"""
}
/**
* 按**入队顺序**逐次回响应的假后端:追尾是一次循环,固定响应验不出翻页。
*
* 队列见底时重复最后一份 —— 这样「永远满页」那种用例只需要入队一次。
*/
private inner class FakeMessagesBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
/** 每次请求的原始 URI(含 query),按到达顺序。 */
val uris = CopyOnWriteArrayList<String>()
/** 每次响应体(UTF-8)的字节数,按到达顺序 —— 量化对比用。 */
val responseBytes = CopyOnWriteArrayList<Int>()
private val queue = ConcurrentLinkedQueue<Pair<Int, String>>()
private val last = java.util.concurrent.atomic.AtomicReference(200 to """{"data":[]}""")
fun enqueue(status: Int, body: String) {
queue += status to body
}
private fun handle(exchange: HttpExchange) {
uris += exchange.requestURI.toString()
val (status, body) = queue.poll() ?: last.get()
last.set(status to body)
val bytes = body.toByteArray()
responseBytes += bytes.size
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
override fun close() {
server.stop(0)
}
}
}
@@ -88,12 +88,13 @@ class MessageMergeTest {
}
@Test
fun `a later page carrying voice upgrades the earlier copy of the same message`() {
// 契约 v0.79(api-client §4.4 覆盖率):`voice` **只在四个 REST 读取面**填充,
// `message_persisted` / 握手 `history` 回放**不带**它(入站语音的 voice 列是落库之后回填的)。
// ⇒ 设计上「实时到达的语音先按普通文本显示,等下一次拉取自动升级成语音气泡」,
// 而这条设计**完全依赖**下面这条既有规则:按 id 合并时**新到的那一份胜出**。
// 这条用例存在的意义是:谁哪天把「incoming 胜出」改成「保留旧的」,语音气泡就永远不会出现。
fun `a later copy carrying voice wins over the earlier copy of the same message`() {
// 同一条消息会从**多路**到本端:落库帧、握手 `history` 回放、REST 分页、编辑帧。
// 按 id 合并时「新到的那一份胜出」是这几路唯一的收口规则——不然后到的那份白到。
// 契约 v0.97 之后 `voice` **每一条路都带**(后端 M44 `fb55769`:落库顺序 INSERT →
// 写 voice 列 → notify),所以这条规则**不再是**「语音升级的唯一通路」;
// 但它照旧必需:谁哪天把「incoming 胜出」改成「保留旧的」,编辑过的正文与任何后补字段
// 都会永远不生效。
val withoutVoice = msg("m1", 100, content = "明天几点出发")
val withVoice = withoutVoice.copy(
voice = VoiceDto(
@@ -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)
}
}
@@ -0,0 +1,167 @@
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`;`failed` / `fallbackReason` 见 M57-M59)。
*
* ## 这条钉子钉的是「撤掉兜底」之后剩下的判据
* W73 时期的旧口径是「`voice` 只在四个 REST 读取面填充,实时帧与握手回放缺席 ⇒ 实时到达的语音
* 先按普通文本显示,等下一次拉取自动升级」。后端把落库顺序改成 **INSERT → 写 `voice` 列 →
* notify** 之后,语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,
* 于是那条兜底**整条撤掉**。判据剩下两条:`voice` 键存在(`hasVoice`)**且** `status != "failed"`
* (`isVoiceMessage`)—— 降级行没有可播的声音,画成语音形态就是给一个点下去必然失败的按钮。
*
* 钉在这里的四件事,任何一件被将来的改动破坏都会让语音消息静默退回纯文本(或反过来,
* 把一条降级行画成播不出来的语音):
* 1. `message_persisted` 载荷里的 `voice` **真的被解出来**(`PersistedData` 只解 `message`,
* 而生产 `Json` 是 `ignoreUnknownKeys = true` ⇒ 漏解不报错,只是判据永远为假);
* 2. `channel_persisted` 同样——两条帧共用一个载荷类型;
* 3. `voice` 键**缺席**仍是「不是语音」(不为「实时帧可能不带」留任何分支);
* 4. `status = "failed"` 的降级行**不是**语音形态,但也不是纯文本:正文 + 一行降级小标
* (`voiceFallbackNote`),且 `voiceAudio` 为 null ⇒ 没有播放控件。
*
* 帧形状照服务端实现:`data:{message:{…}}`(`channel_persisted` 同形;与 `message_edited`
* 那条「`data` 就是消息本身」**不同**)。
*/
class WsEventPersistedVoiceTest {
private fun frame(type: String, data: String) = """{"type":"$type","sessionId":"web","data":$data}"""
private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
/** 一条真实的入站语音行:转写文本在 `content` 里,`voice` 四键齐全(有音频副本)。 */
private val voiceRow = """
{"id":"1371","sessionId":"web","role":"user","content":"明天几点出发",
"voice":{"source":"asr","status":"transcribed","name":"$copyName","mime":"audio/ogg",
"size":12345,"durationMs":3200,"url":"/api/v1/media/$copyName"},
"createdAt":1791088571785}
""".trimIndent().replace("\n", " ")
/** 微信那条真实的无副本形状:平台自带转写 ⇒ 只有 🎤 标记,四个音频键全缺席。 */
private val noCopyRow = """
{"id":"1372","sessionId":"web","role":"user","content":"晚上回来吃饭吗",
"voice":{"source":"platform","status":"transcribed"},
"createdAt":1791088571786}
""".trimIndent().replace("\n", " ")
private val plainRow = """
{"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
val message = event.message!!
val voice = message.voice!!
assertEquals("asr", voice.source)
assertEquals("transcribed", voice.status)
assertEquals(copyName, voice.name)
assertEquals("audio/ogg", voice.mime)
assertEquals(12_345L, voice.size)
assertEquals(3_200, voice.durationMs)
assertEquals("/api/v1/media/$copyName", voice.url)
// 判据:键存在即语音——这条帧到达的那一刻就该是语音气泡,不需要任何后续拉取。
assertTrue("落库帧上的语音必须在第一帧就判成语音", isVoiceMessage(message))
assertEquals(copyName, voiceAudio(voice)?.name)
}
@Test
fun `a channel persisted frame carries voice too`() {
// 隔离会话那条帧与 message_persisted 共用 `PersistedData`:给载荷加字段不许只覆盖其中一条。
val event = WsEvent.parse(frame("channel_persisted", """{"message":$voiceRow}""")) as WsEvent.ChannelPersisted
val message = event.message!!
assertTrue(isVoiceMessage(message))
assertEquals(3_200, message.voice?.durationMs)
}
@Test
fun `a transcribed voice without an audio copy is still a voice message`() {
// 「是语音」与「能不能播」是两件事:判据只看键在不在,播放控件另看四键是否齐全。
val event = WsEvent.parse(frame("message_persisted", """{"message":$noCopyRow}""")) as WsEvent.MessagePersisted
val message = event.message!!
assertTrue(isVoiceMessage(message))
assertEquals("platform", message.voice?.source)
assertNull("没有副本就不许画播放控件", voiceAudio(message.voice!!))
}
@Test
fun `an absent voice key still means not a voice message`() {
// 判据只有一个方向上的推论:缺席 = 不是语音。这里刻意不留「实时帧可能不带,先按文本显示」
// 那条分支——M44 之后缺席就只意味着「这条本来就不是语音」。
val event = WsEvent.parse(frame("message_persisted", """{"message":$plainRow}""")) as WsEvent.MessagePersisted
assertNull(event.message?.voice)
assertFalse(isVoiceMessage(event.message!!))
}
@Test
fun `an edited frame keeps the voice of the message it replaces`() {
// 编辑帧的 `data` **就是**消息本身(没有 `message` 包一层),但它同样是 `voice` 的读面之一:
// 编辑正文不许把已渲染的语音气泡打回纯文本。
val event = WsEvent.parse(
"""{"type":"message_edited","sessionId":"web","data":$voiceRow}""",
) as WsEvent.MessageEdited
val message = event.message!!
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))
}
}
@@ -0,0 +1,74 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 下行帧 `voice_output`(契约 v0.96 §3.15)。
*
* 两个容易踩空的地方在这里钉死:
* 1. **帧壳没有 `sessionId`**(契约明写,会话 id 在 `data` 里)——解析层必须把它提到帧上,
* 否则 `isForSession` 会把本帧**放行给每一个会话的 VM**(空值 = 无法判定),切到别的会话
* 时也会响一段本属于别的会话的语音;
* 2. `text` / `reply` / `provider` 三个键**一个都不能丢**:`reply` 是她说的话、`text` 是转写、
* `provider == "silent"` 是「这一轮没有音频」的唯一信号(判据在 `domain.chat`)。
*/
class WsEventVoiceOutputTest {
private val realFrame = """
{"type":"voice_output","data":{"audio":"UklGRg==","format":"wav","text":"你今天在吗","reply":"我在呢","sessionId":"web","deviceId":"voiceassist:cyrene:v1","provider":"emotiontts"}}
""".trimIndent()
@Test
fun `the real frame decodes with every payload key`() {
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
assertEquals("voice_output", event.type)
assertEquals("UklGRg==", event.audio)
assertEquals("wav", event.format)
assertEquals("你今天在吗", event.text)
assertEquals("我在呢", event.reply)
assertEquals("voiceassist:cyrene:v1", event.deviceId)
assertEquals("emotiontts", event.provider)
// 帧壳没有轮次 / 序号(这一帧不属任何流式段)。
assertNull(event.turnId)
assertNull(event.seq)
}
@Test
fun `the payload session id is lifted onto the frame so the session gate works`() {
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
assertEquals("web", event.sessionId)
assertTrue("本会话放行", event.isForSession("web"))
assertFalse("别的会话必须挡下(否则会在别的会话里响起来)", event.isForSession("web:other"))
}
@Test
fun `a silent frame keeps the marker and the text`() {
val frame = """
{"type":"voice_output","data":{"audio":"","format":"wav","text":"嗯","reply":"嗯,我在","sessionId":"web","provider":"silent"}}
""".trimIndent()
val event = WsEvent.parse(frame) as WsEvent.VoiceOutput
assertEquals("silent", event.provider)
assertEquals("", event.audio)
assertEquals("嗯,我在", event.reply)
assertEquals("web", event.sessionId)
}
@Test
fun `a frame without a payload still forms the event instead of vanishing`() {
// 坏帧(没有 data):会话归属退化成「无法判定」,但「这一帧来过」必须留下痕迹——
// 整帧丢掉就是「她回了语音而屏幕上什么都没有」,那比一张空卡片糟得多。
val event = WsEvent.parse("""{"type":"voice_output"}""") as WsEvent.VoiceOutput
assertNull(event.audio)
assertNull(event.reply)
assertEquals("", event.sessionId)
}
}
@@ -0,0 +1,52 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
/**
* 上行帧 `voice_input`(契约 v0.96 §3.15)。
*
* 两个键是**纯增量**:[WsOutbound.VoiceInput.deviceId] 与 `clientMsgId`。骨架里本来只有
* `audio` / `format` / `text` / `sessionId` 四个——那四者的线上形状在这里逐字钉死(老帧一个
* 字节都不许变),新增的两个只在有值时才出现(与后端 `omitempty` 同义)。
*
* `clientMsgId` 与文本 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条。所以真正的
* 「幂等」不在这一层(它只负责照发),而在 `ChatViewModel` + 纯函数 `voiceClientMsgIdFor`:
* 重发复用同一个键(那边有穷举单测)。
*/
class WsOutboundVoiceTest {
@Test
fun `the original four key frame is byte for byte what it was`() {
assertEquals(
"""{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web"}}""",
WsOutbound.VoiceInput(audio = "QUJD", format = "wav", sessionId = "web").toJson(),
)
}
@Test
fun `device id and client message id are carried next to the audio`() {
val frame = WsOutbound.VoiceInput(
audio = "QUJD",
format = "wav",
sessionId = "web",
deviceId = "voiceassist:cyrene:v1",
clientMsgId = "voice-9f3c",
).toJson()
val expected = """{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web","deviceId":"voiceassist:cyrene:v1","clientMsgId":"voice-9f3c"}}"""
assertEquals(expected, frame)
}
@Test
fun `omitting the idempotency key omits the key itself rather than sending null`() {
val json = WsOutbound.VoiceInput(audio = "QUJD", format = "wav").toJson()
assertFalse("省略时不该出现 clientMsgId 键:$json", json.contains("clientMsgId"))
assertFalse("省略时不该出现 deviceId 键:$json", json.contains("deviceId"))
assertNull(WsOutbound.VoiceInput(audio = "QUJD").reqId)
}
}
@@ -0,0 +1,234 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 语音**上行**那一半的纯函数(契约 v0.96 §3.15 ①):录音参数、WAV 封装、体积闸门、幂等键。
*
* 这一组钉的是「发出去的字节长什么样」——WAV 头一旦有一个字段写错,服务端的解码器与 STT
* 会**静默**给出垃圾(或直接拒),而客户端这边完全看不出问题。所以头的每个字段都在这里逐个
* 读出来断言,而不是只比长度。
*/
class VoiceClipTest {
// —— 换算:16 kHz / 单声道 / 16-bit ⇒ 1 ms = 32 字节 ——
@Test
fun `one second of pcm is 32 000 bytes at the chosen parameters`() {
assertEquals(32_000, VoiceClip.BYTES_PER_SECOND)
assertEquals(32_000, VoiceClip.pcmBytesFor(1_000))
assertEquals(1_000, VoiceClip.durationMillisOf(32_000))
}
@Test
fun `duration and byte count round trip for typical clips`() {
assertEquals(1_920_000, VoiceClip.MAX_PCM_BYTES)
assertEquals(VoiceClip.MAX_RECORDING_MILLIS, VoiceClip.durationMillisOf(VoiceClip.MAX_PCM_BYTES))
// 不足一毫秒的零头一律向下取整(绝不把 0 字节说成 1 毫秒)。
assertEquals(0, VoiceClip.durationMillisOf(31))
assertEquals(0, VoiceClip.durationMillisOf(0))
assertEquals(0, VoiceClip.durationMillisOf(-5))
assertEquals(0, VoiceClip.pcmBytesFor(-5))
}
// —— WAV 头:逐字段 ——
@Test
fun `the wav header carries the riff wav fmt data chunks of pcm`() {
val pcmBytes = 32_000
val header = VoiceClip.wavHeader(pcmBytes)
assertEquals("RIFF-WAVE 头恒为 44 字节", 44, header.size)
assertEquals("RIFF", header.ascii(0, 4))
// RIFF 的 size 字段 = 文件总长 - 8(不含 "RIFF" 与 size 自己)。
assertEquals(44 - 8 + pcmBytes, header.intLe(4))
assertEquals("WAVE", header.ascii(8, 4))
assertEquals("fmt ", header.ascii(12, 4))
assertEquals("fmt 子块长度 = 16(PCM)", 16, header.intLe(16))
assertEquals("audioFormat = 1(PCM)", 1, header.shortLe(20))
assertEquals("单声道", 1, header.shortLe(22))
assertEquals("采样率 16 kHz", 16_000, header.intLe(24))
assertEquals("字节率 = 采样率 × 声道 × 位深/8", 32_000, header.intLe(28))
assertEquals("块对齐 = 声道 × 位深/8", 2, header.shortLe(32))
assertEquals("16-bit", 16, header.shortLe(34))
assertEquals("data", header.ascii(36, 4))
assertEquals("data 子块长度 = PCM 字节数", pcmBytes, header.intLe(40))
}
@Test
fun `toWav length is header plus pcm and the payload is untouched`() {
val pcm = ByteArray(64) { (it + 1).toByte() }
val wav = VoiceClip.toWav(pcm)
assertEquals("字节数 = 44 + PCM", 44 + pcm.size, wav.size)
assertEquals(44 - 8 + pcm.size, wav.intLe(4))
assertEquals(pcm.size, wav.intLe(40))
// 头之后一个字节都不许动(重采样 / 量化是录音机的事,这里只搬字节)。
assertArrayEquals(pcm, wav.copyOfRange(44, wav.size))
}
@Test
fun `an empty pcm still yields a self consistent 44 byte wav`() {
val wav = VoiceClip.toWav(ByteArray(0))
assertEquals(44, wav.size)
assertEquals(44 - 8, wav.intLe(4))
assertEquals(0, wav.intLe(40))
assertEquals("RIFF", wav.ascii(0, 4))
assertEquals("data", wav.ascii(36, 4))
}
// —— 准入:空 / 太短 / 超限 / 可发 ——
@Test
fun `an empty recording is rejected as empty rather than too short`() {
assertEquals(VoiceClipPlan.Empty, VoiceClip.plan(ByteArray(0)))
assertEquals(VOICE_EMPTY_MESSAGE, voiceClipRejection(VoiceClipPlan.Empty))
}
@Test
fun `a few milliseconds of audio is rejected as too short`() {
// 100 字节 = 3 ms:按下就松手的那一下。
val plan = VoiceClip.plan(ByteArray(100))
assertTrue("极短录音判 TooShort,实际 $plan", plan is VoiceClipPlan.TooShort)
assertEquals(3, (plan as VoiceClipPlan.TooShort).durationMs)
assertEquals(VOICE_TOO_SHORT_MESSAGE, voiceClipRejection(plan))
}
@Test
fun `exactly one second is the shortest acceptable clip`() {
val plan = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(1_000)))
assertTrue("满一秒就该放行,实际 $plan", plan is VoiceClipPlan.Ready)
assertEquals(1_000, (plan as VoiceClipPlan.Ready).durationMs)
assertEquals(44 + 32_000, plan.wav.size)
}
@Test
fun `one millisecond under the minimum is still rejected`() {
val pcm = ByteArray(VoiceClip.pcmBytesFor(1_000) - 32) // 32 字节 = 1 ms
val plan = VoiceClip.plan(pcm)
assertTrue("999 ms 仍算太短,实际 $plan", plan is VoiceClipPlan.TooShort)
assertEquals(999, (plan as VoiceClipPlan.TooShort).durationMs)
}
// —— 体积闸门(含「等于」那一格)——
@Test
fun `the byte ceiling equals the server's decoded inline cap`() {
// 与 chat.image 解码后同一条线:契约没给 voice_input 的上限,本端按已知最严的那一档设计。
assertEquals(ChatImageBudget.SERVER_MAX_DECODED_BYTES, VoiceClip.MAX_AUDIO_BYTES)
assertEquals(5 * 1024 * 1024, VoiceClip.MAX_AUDIO_BYTES)
}
@Test
fun `a clip exactly at the ceiling is accepted`() {
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES)
val total = VoiceClip.totalBytesFor(pcm.size)
assertEquals("上界取等于(<=)", VoiceClip.MAX_AUDIO_BYTES, total)
assertTrue(VoiceClip.withinLimit(total))
assertTrue("恰好到顶应当放行", VoiceClip.plan(pcm) is VoiceClipPlan.Ready)
}
@Test
fun `a clip one byte over the ceiling is refused with a named reason`() {
// 43 字节:total = 44 + 43 = MAX + 1。
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES + 1)
val total = VoiceClip.totalBytesFor(pcm.size)
assertEquals(VoiceClip.MAX_AUDIO_BYTES + 1, total)
assertFalse(VoiceClip.withinLimit(total))
val plan = VoiceClip.plan(pcm)
assertTrue("超限判 TooLarge,实际 $plan", plan is VoiceClipPlan.TooLarge)
assertEquals(total, (plan as VoiceClipPlan.TooLarge).totalBytes)
assertEquals(VOICE_TOO_LARGE_MESSAGE, voiceClipRejection(plan))
}
@Test
fun `a 60 second recording stays well under the ceiling`() {
// 录满长度只用到闸门的 36%:撞上它说明硬件给了异常的量,不是正常路径。
val total = VoiceClip.totalBytesFor(VoiceClip.MAX_PCM_BYTES)
assertTrue(VoiceClip.withinLimit(total))
assertEquals(1_920_044, total)
}
@Test
fun `the rejection of a sendable clip is null`() {
val ready = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(2_000)))
assertNull(voiceClipRejection(ready))
}
// —— 录音中的时长文案 ——
@Test
fun `the elapsed label starts at zero and pads seconds`() {
assertEquals("0:00", voiceElapsedLabel(0))
assertEquals("0:00", voiceElapsedLabel(999))
assertEquals("0:07", voiceElapsedLabel(7_000))
assertEquals("1:05", voiceElapsedLabel(65_000))
// 负数(时钟回拨 / 调用方算错)不显示成 -1:-1。
assertEquals("0:00", voiceElapsedLabel(-1_000))
}
// —— 幂等键:重发必须复用(契约 v0.96:同一个非空值只承认第一条)——
@Test
fun `a fresh recording mints a new prefixed key`() {
val key = voiceClientMsgIdFor(null) { "9f3c" }
assertEquals("voice-9f3c", key)
assertTrue("前缀写进键里,好让服务端与日志一眼看出这条是语音", key.startsWith(VOICE_CLIENT_MSG_ID_PREFIX))
}
@Test
fun `retrying the same clip reuses its client message id`() {
val draft = VoiceDraftInfo(clientMsgId = "voice-keep", durationMs = 1_800, totalBytes = 57_644)
// 用户连点三次「重发」:每一次都必须拿同一个键(否则服务端会把同一句话答三遍)。
val attempts = List(3) {
voiceClientMsgIdFor(draft) { error("重发不该铸新键") }
}
assertEquals(listOf("voice-keep", "voice-keep", "voice-keep"), attempts)
}
@Test
fun `a different recording never inherits the old key`() {
val old = VoiceDraftInfo("voice-old", durationMs = 1_200, totalBytes = 38_444)
// 丢弃之后再录一段:状态里那一格已经清掉 ⇒ 现铸新键(否则新录音会被服务端当成重发吞掉)。
val fresh = voiceClientMsgIdFor(null) { "fresh" }
assertEquals("voice-fresh", fresh)
assertTrue(fresh != old.clientMsgId)
}
}
/** 从字节数组里取一段 ASCII(断言 WAV 头的四个 chunk 名用)。 */
private fun ByteArray.ascii(offset: Int, length: Int): String =
String(this, offset, length, Charsets.US_ASCII)
/** 小端有符号整数(WAV 头里全是 32 位小端)。 */
private fun ByteArray.intLe(offset: Int): Int =
(this[offset].toInt() and 0xFF) or
((this[offset + 1].toInt() and 0xFF) shl 8) or
((this[offset + 2].toInt() and 0xFF) shl 16) or
((this[offset + 3].toInt() and 0xFF) shl 24)
/** 小端 16 位无符号整数。 */
private fun ByteArray.shortLe(offset: Int): Int =
(this[offset].toInt() and 0xFF) or ((this[offset + 1].toInt() and 0xFF) shl 8)
@@ -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,86 @@
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
/**
* `voice_output` 的呈现判据(契约 v0.96 §3.15):两件事必须判对,否则 UI 一定画错。
*
* 1. **这一轮有没有真音频**——`provider == "silent"` 是明文降级信号(`audio` 空、`text`/`reply`
* 有)。判错成「有」就会画出一个点下去必然失败的播放器;判错成「没有」则让本来能放的语音
* 变成一段文字。
* 2. **屏幕上该显示哪一句**——`reply` 是**她说的话**(音频念的就是它),`text` 是**转写**
* (我们那段音频被识别成什么)。两者的顺序反过来,用户看到的就是自己说的话冒充她的回答。
*/
class VoiceOutputTest {
// —— provider 降级信号 ——
@Test
fun `the silent provider is recognised literally`() {
assertTrue(isSilentVoiceProvider(VoiceProvider.SILENT))
// 机器可读码:容忍首尾空白与大小写,但不容忍别的词。
assertTrue(isSilentVoiceProvider(" SILENT "))
assertFalse(isSilentVoiceProvider("emotiontts"))
assertFalse(isSilentVoiceProvider(""))
assertFalse(isSilentVoiceProvider(null))
}
@Test
fun `silent frames never count as having audio`() {
// 契约:silent ⇒ audio 空。万一服务端还是塞了字节,也不能画播放器(它自己说这轮没发音)。
assertFalse(voiceOutputHasAudio(audio = null, provider = VoiceProvider.SILENT))
assertFalse(voiceOutputHasAudio(audio = "QUJD", provider = VoiceProvider.SILENT))
}
@Test
fun `a normal provider needs actual bytes to be playable`() {
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = "emotiontts"))
// 没给 provider(老服务端 / 省略该键)、但有字节 ⇒ 能放。
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = null))
// 有 provider 却没字节 ⇒ 没有音频(空串与 null 同一档)。
assertFalse(voiceOutputHasAudio(audio = "", provider = "emotiontts"))
assertFalse(voiceOutputHasAudio(audio = null, provider = "emotiontts"))
}
// —— text vs reply ——
@Test
fun `reply wins because it is what she actually said`() {
assertEquals("我在呢", voiceReplyText(reply = "我在呢", text = "你今天在吗"))
}
@Test
fun `a missing reply falls back to the transcript`() {
assertEquals("你今天在吗", voiceReplyText(reply = null, text = "你今天在吗"))
assertEquals("你今天在吗", voiceReplyText(reply = " ", text = "你今天在吗"))
}
@Test
fun `both missing means show nothing instead of inventing a line`() {
assertNull(voiceReplyText(reply = null, text = null))
assertNull(voiceReplyText(reply = "", text = " "))
}
// —— 那一句说明:两档说的不是同一件事 ——
@Test
fun `the silent note says only text this round`() {
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = false))
// silent 档即便本地碰巧有个文件也不该说「本机没准备好」。
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = true))
}
@Test
fun `a local decode failure is reported as a local problem not as silence`() {
assertEquals(VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE, voiceReplyNote(silent = false, hasAudioFile = false))
}
@Test
fun `a playable reply needs no note`() {
assertNull(voiceReplyNote(silent = false, hasAudioFile = true))
}
}
@@ -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,58 @@
package top.yeij.cyrene.domain.share
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 批量投递的「声明」边界([ShareRules.declaresSendPreference])。
*
* 钉住的是一件**不许再走回去**的事:分享进来的 N 张图会落成 N 条消息(契约里 `chat.image` 是单图
* 字段),而这 N 条是**一次**投递动作——它们之间绝不许互相打断 / 互相取消(`02` F4 的后端口径、
* `05` 的多图自相打断一节)。一旦某一条在忙时声明了 `interrupt`,第 2 条就会把第 1 条刚起的回合打
* 掉;声明 `parallel` 同样把「一次倒进去」变成「几条并行回合」。
*
* 纯函数 ⇒ 不需要组合界面、不需要设备,就能把**整批**(0 到 [ShareRules.MAX_IMAGES] - 1)逐条穷举。
*/
class ShareSendDeclarationTest {
@Test
fun `share never declares on any message of the batch`() {
(0 until ShareRules.MAX_IMAGES).forEach { index ->
assertFalse(
"分享的第 $index 条不许声明(声明就会打断同一批里的上一条)",
ShareRules.declaresSendPreference(fromShare = true, index = index),
)
}
}
@Test
fun `share stays silent even past the image limit`() {
// 超限那张本来就不发(见 truncatedImagesNotice),但判据对它也不许翻成 true:
// 一旦有人把上限放宽而忘了这条,越界位置不该成为唯一声明的那一条。
(ShareRules.MAX_IMAGES..ShareRules.MAX_IMAGES + 3).forEach { index ->
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = index))
}
}
@Test
fun `the typed send declares on its first message only`() {
assertTrue(
"手打的第一条仍按「她忙时发消息」偏好投递",
ShareRules.declaresSendPreference(fromShare = false, index = 0),
)
(1 until ShareRules.MAX_IMAGES).forEach { index ->
assertFalse(
"手打的第 $index 条声明就是在打断自己刚起的上一轮",
ShareRules.declaresSendPreference(fromShare = false, index = index),
)
}
}
@Test
fun `a single message batch is the same rule`() {
// 只有一条时两种入口的差别正是全部差别:手打声明、分享不声明。
assertTrue(ShareRules.declaresSendPreference(fromShare = false, index = 0))
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = 0))
}
}
@@ -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())
}
}
@@ -0,0 +1,212 @@
package top.yeij.cyrene.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.MessageIncrement
/**
* W77 的判据层:非激活会话的静默刷新从「整表重拉 `GET /sessions`」换成「`GET /messages?after=`」
* 之后,**什么时候问得了增量、问回来的数怎么折进列表**。
*
* 与 `InactiveSessionPullTest` 同一套理由:这三件都是纯函数,穷举得了,不用在 JVM 上摆一台假服务器
* (那条路由 `MessageIncrementRepositoryTest` 从线上证据那一侧验)。VM 里剩下的只有「拼装」——
* 取游标、逐个发请求、回落 —— 每一步的分支判据都在下面。
*/
class SessionIncrementTest {
private fun item(
id: String = "web",
lastMessageAt: Long? = null,
unreadCount: Int = 0,
lastMessage: String? = null,
) = SessionItem(
id = id,
title = "标题",
isMain = id == "web",
pinned = false,
lastMessage = lastMessage,
lastMessageAt = lastMessageAt,
unreadCount = unreadCount,
)
private fun message(id: String, createdAt: Long, content: String = "内容") = HistoryMessageDto(
id = id,
sessionId = "web",
role = "assistant",
content = content,
createdAt = createdAt,
)
private fun increment(
messages: List<HistoryMessageDto>,
cursor: Long = messages.lastOrNull()?.createdAt ?: 0L,
complete: Boolean = true,
) = MessageIncrement(messages = messages, cursor = cursor, complete = complete)
// —— 游标从哪来 ——
@Test
fun `no row means no cursor`() {
// 会话在别处新建 / 本端那份列表还没拉到它:不知道它看到哪儿了,只能回落整表。
assertNull(incrementCursorFor(null))
}
@Test
fun `a row without a last message is not a cursor either`() {
// 本端认为它一条消息都没有,却收到了它的落库广播 ⇒ 手里那份列表已经陈旧到不可信。
assertNull(incrementCursorFor(item(lastMessageAt = null)))
}
@Test
fun `the cursor is the last message time already on the row`() {
// 不另立账本:`after` 是严格大于,而 `lastMessageAt` 正是「已见的最新一条」。
assertEquals(500L, incrementCursorFor(item(lastMessageAt = 500)))
}
// —— 一批 id → 一批游标 ——
@Test
fun `an empty batch is nothing to do, not a failure`() {
assertEquals(emptyMap<String, Long>(), incrementCursors(emptyList(), listOf(item(lastMessageAt = 1))))
}
@Test
fun `every id in the batch gets its own cursor`() {
val sessions = listOf(item(id = "web", lastMessageAt = 100), item(id = "s2", lastMessageAt = 200))
assertEquals(mapOf("web" to 100L, "s2" to 200L), incrementCursors(listOf("web", "s2"), sessions))
}
@Test
fun `an unknown id rolls the whole batch back to the table`() {
// 一半靠增量、一半靠整表得到的列表说不清是哪个时刻的 —— 未读面上最不能出这种「说不清」。
val sessions = listOf(item(id = "web", lastMessageAt = 100))
assertNull(incrementCursors(listOf("web", "gone"), sessions))
}
@Test
fun `one row without a cursor rolls the whole batch back too`() {
val sessions = listOf(item(id = "web", lastMessageAt = 100), item(id = "s2", lastMessageAt = null))
assertNull(incrementCursors(listOf("web", "s2"), sessions))
}
// —— 折进列表 ——
@Test
fun `an empty increment changes nothing and keeps the same instance`() {
val sessions = listOf(item(id = "web", lastMessageAt = 100))
assertSame(sessions, sessions.applyIncrement("web", from = 100, readAt = null, increment = increment(emptyList())))
}
@Test
fun `only the target row moves and the cursor advances to the increment tail`() {
val sessions = listOf(
item(id = "web", lastMessageAt = 100, unreadCount = 2, lastMessage = "旧预览"),
item(id = "s2", lastMessageAt = 900, unreadCount = 7, lastMessage = "别人的预览"),
)
val updated = sessions.applyIncrement(
"web",
from = 100,
readAt = null,
increment = increment(listOf(message("m1", 200), message("m2", 300, "她<action>轻轻</action>说"))),
)
val web = updated.first { it.id == "web" }
// 已有的 unreadCount 直接相加是安全的:增量的每一条都严格新于 from,两者没有交集。
assertEquals(4, web.unreadCount)
assertEquals(300L, web.lastMessageAt)
// 预览取最新一条,且剥掉 `<action>` 这类标记(与整表那条路同一个 `toPreviewText`)。
assertEquals("她 轻轻 说", web.lastMessage)
// 别的行一个字节都不动。
assertEquals(sessions[1], updated.first { it.id == "s2" })
}
@Test
fun `messages at or before the read cursor are already read on the server`() {
// 与服务端**同源的只是过滤口径**(`count(created_at > read_at)`),不是基数:
// `read_at` 是账号级单一游标,这台机器点开过它(游标推到「现在」)之后别的设备又发了消息,
// 那段区间服务端算已读 —— 加进角标就多出一个红点,所以先按 readAt 筛。
val sessions = listOf(item(id = "web", lastMessageAt = 100))
val updated = sessions.applyIncrement(
"web",
from = 100,
readAt = 250,
increment = increment(listOf(message("m1", 200), message("m2", 250), message("m3", 300))),
)
// 只有 300 那一条越过游标。
assertEquals(1, updated.single().unreadCount)
}
@Test
fun `a read elsewhere while offline over-counts until the next whole table`() {
// 已接受的边界(见 `applyIncrement` 的 KDoc),钉在这里是为了它不会被当成「已修好」:
// 底数 `unreadCount` 来自上一次整表刷新,而 `read_at` 是账号级的。断线期间他端把这个会话
// 读掉了,那条 `session_read` 帧本地**永久收不到**(断线不补帧)⇒ 本端 readAt 还是 null,
// 整批都进了角标(4 + 3 = 7),而服务端此刻算 0。漂移到下一次整表刷新自愈,这里不改行为。
val sessions = listOf(item(id = "web", lastMessageAt = 100, unreadCount = 4))
val updated = sessions.applyIncrement(
"web",
from = 100,
readAt = null,
increment = increment(listOf(message("m1", 200), message("m2", 300), message("m3", 400))),
)
assertEquals(7, updated.single().unreadCount)
}
@Test
fun `an unknown read cursor counts everything in the increment`() {
val sessions = listOf(item(id = "web", lastMessageAt = 100))
val updated = sessions.applyIncrement(
"web",
from = 100,
readAt = null,
increment = increment(listOf(message("m1", 200), message("m2", 300))),
)
assertEquals(2, updated.single().unreadCount)
}
@Test
fun `a row already advanced by a more authoritative result is left alone`() {
// 请求飞在天上时,一次整表刷新(或另一次增量)已经推进过这一行 ⇒ 那些条数已经被数过一遍了,
// 再加一次就是重复计数。
val sessions = listOf(item(id = "web", lastMessageAt = 500, unreadCount = 3))
val updated = sessions.applyIncrement(
"web",
from = 100,
readAt = null,
increment = increment(listOf(message("m1", 200))),
)
assertSame(sessions, updated)
}
@Test
fun `a blank newest message clears the preview the same way the table would`() {
// 与 `toSessionItem`(整表那条路)同一个口径:内容折叠后为空 ⇒ 预览为 null,不留一句空话。
val sessions = listOf(item(id = "web", lastMessageAt = 100, lastMessage = "旧预览"))
val updated = sessions.applyIncrement(
"web",
from = 100,
readAt = null,
increment = increment(listOf(message("m1", 200, " "))),
)
assertNull(updated.single().lastMessage)
assertEquals(200L, updated.single().lastMessageAt)
}
}
+4 -1
View File
@@ -474,8 +474,11 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&docum
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
+81 -8
View File
@@ -281,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
return "<redacted sha256:{}>".format(digest)
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
BODY_SUMMARY_STRING_MAX = 200
def summarize_body(raw) -> dict | None:
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
数组长度),只把超长字符串换成 `<N chars>`。
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
也不记大块内容。
"""
if not raw:
return None
try:
parsed = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return {"bytes": len(raw), "note": "not json"}
return _summarize_value(parsed)
def _summarize_value(value):
if isinstance(value, dict):
return {key: _summarize_value(item) for key, item in value.items()}
if isinstance(value, list):
return [_summarize_value(item) for item in value]
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
return "<{} chars>".format(len(value))
return value
class JsonlLog:
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
@@ -288,11 +319,17 @@ class JsonlLog:
def __init__(self, path: str):
self.path = path
self.lock = threading.Lock()
os.makedirs(os.path.dirname(path), exist_ok=True)
self.dir = os.path.dirname(path)
os.makedirs(self.dir, exist_ok=True)
def append(self, record: dict) -> None:
line = json.dumps(record, ensure_ascii=False)
with self.lock:
# 目录**每次写都补一次**:README 说「删掉 `out/` 即清空」,而人在服务跑着的时候删掉
# 它就再也不会自己回来——之后每一条请求都会在写日志时抛 FileNotFoundError,
# 表现成「假后端活着但什么都不回」(W76 实测踩到)。重建目录只是一次 stat,
# 代价可以忽略;让它自己长回来,比要求人先停服务再删要可靠。
os.makedirs(self.dir, exist_ok=True)
with open(self.path, "a", encoding="utf-8", newline="\n") as handle:
handle.write(line + "\n")
@@ -599,11 +636,15 @@ class Store:
with self.lock:
self.downstream_count += 1
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers) -> dict:
"""把收到的请求头**记下来**(鉴权头不校验,但要看得见)。
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
"""把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
也没有理由把它原文写进一个可能被 commit 的文件。
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
`out/http.jsonl` 里读得出来,不用再去猜。
"""
recorded_headers = {}
for key, value in headers.items():
@@ -623,6 +664,9 @@ class Store:
"query": query,
"headers": recorded_headers,
}
summary = summarize_body(body)
if summary is not None:
record["body"] = summary
self.http_log.append(record)
with self.lock:
self.http_count += 1
@@ -1046,17 +1090,38 @@ class Handler(BaseHTTPRequestHandler):
query = parse_qs(parts.query, keep_blank_values=True)
return parts.path, query
def handle_one_request(self):
"""每个请求进来时**先把 body 缓存清掉**。
`protocol_version` 是 HTTP/1.1,同一条 keep-alive 连接上的多个请求由**同一个 Handler 实例**
依次处理(`handle_one_request` 循环),所以 `_read_body` 的缓存必须按请求隔离。少了这一步,
第 2 个请求会拿到第 1 个请求的 body(摘要记成上一份),而它自己那份**根本没人从 `rfile` 读走**
——残留字节留在内核缓冲里,被下一个请求的请求行一并吃掉,`BaseHTTPRequestHandler` 于是回
`501 Unsupported method ('{"sessionId":...}POST')`。客户端表现成「3 张图只投出去 2 张」,
而真正的锅在这里。
"""
self._body_read = False
self._body_cache = b""
super().handle_one_request()
def _read_body(self):
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
if getattr(self, "_body_read", False):
return self._body_cache
self._body_read = True
self._body_cache = b""
length = self.headers.get("Content-Length")
if not length:
return b""
return self._body_cache
try:
size = int(length)
except ValueError:
return b""
return self._body_cache
if size <= 0:
return b""
return self.rfile.read(size) or b""
return self._body_cache
self._body_cache = self.rfile.read(size) or b""
return self._body_cache
def _parse_json_body(self):
raw = self._read_body()
@@ -1103,7 +1168,15 @@ class Handler(BaseHTTPRequestHandler):
def _handle(self, method: str):
path, query = self._split()
self.store.record_http(self._label(), method, path, query, self.headers)
# 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
body = (
self._read_body()
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
else b""
)
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
try:
if path == "/ws/chat":
self._handle_ws(method, query)