17 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
29 changed files with 3390 additions and 138 deletions

No files matched your search

@@ -0,0 +1,67 @@
package top.yeij.cyrene.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Streaming
/**
* 一段待朗读的文本(`POST /api/v1/audio/speech` 的请求体,api-client.md §1.2 U6 / 行 126)。
*
* ## 只有两个字段,是**刻意的**
* 契约给了五个键 `{text, voice?, format?, instruction?, sessionId?}`,但其中三个在本端**不生效**:
* - `voice`:接受但不生效 —— 发音人**不由它决定**;
* - `format`:不生效 —— 产物恒为 WAV(响应也明说「别照抄 `Content-Type`」,见 [SpeechApi]);
* - `instruction`:只影响表达方式,本端当前的产品口径里没有「怎么说」这一档输入。
*
* 发一个服务端会忽略的键,只会让排查时多一条**看起来有含义、实际没有**的字段。所以这里只发
* 真正起作用的那两个 —— 尤其是 [sessionId]:发音人由它**那条会话的生效人格**解析
* (会话绑定人格 → 全局默认人格 → 都没有就取服务端配置的 default),**省略它不等于没有角色**。
*
* [sessionId] 为 `null` 时字段**整个缺席**(`ApiClientFactory.json` 的 `explicitNulls = false`),
* 而不是发一个 `"sessionId": null`。
*/
@Serializable
data class SpeechRequestDto(
@SerialName("text") val text: String,
@SerialName("sessionId") val sessionId: String? = null,
)
/**
* 语音合成(契约 **v0.96 增量 / v0.97 补写**,api-client.md §1.2 **U6** `POST /api/v1/audio/speech`)。
*
* ## 鉴权:owner-only,双凭据门
* `Authorization: Bearer <owner JWT>` **或** `X-API-Key`,两个分支在服务端落到同一个
* `authorize(RoleOwner)`。本端只走前者(主人 2026-10-26 拍板:不设 API Key 输入框),令牌由
* [ApiClientFactory.create] 挂在 OkHttpClient 上的 `AuthInterceptor` 自动注入,本接口不碰它。
* 无 / 无效凭据 → `401`,错误体是 §4.1 那个 `{error:{code,message}}` 信封。
*
* ## 响应**不是信封**
* 成功是 `200` + **原始音频字节**(与 [MediaApi] 同一个形状),所以返回 [ResponseBody] 由上层
* 读字节并负责关闭,[Streaming] 让它不一次性读进内存。
*
* ⚠️ **产物恒为 WAV,按字节嗅探判定,不要照抄 `Content-Type`** —— 那条头的实际值不在契约里
* 承诺(§4.1 行 561 的同句旁注就是这个意思)。嗅探在 [top.yeij.cyrene.data.voice.SpeechClient]。
*
* 失败时的错误码(全部是 `{error:{code}}` 信封):
* `400`(空 text)、`500 voice_unavailable`(未接语音)、`500 tts_failed`、
* `500 tts_persona_unmapped`(人格没有发音人映射)、`500 tts_no_character`(连生效人格都没有)、
* `500 tts_empty`(拿到的是静默空音频,同时响应头带 `X-Voice-Provider: silent`)。
*
* @see top.yeij.cyrene.data.voice.SpeechClient
*/
interface SpeechApi {
/**
* 把 [body] 合成为一段 WAV 字节。
*
* 路径**不带前导 `/`**(与 [ChatApi] 一致):Retrofit 的 `baseUrl` 已含 `/` 结尾,
* 带前导 `/` 会把它当成根路径、把部署在子路径下的服务端地址整段丢掉。
*/
@Streaming
@POST("api/v1/audio/speech")
suspend fun speech(@Body body: SpeechRequestDto): Response<ResponseBody>
}
@@ -9,8 +9,9 @@ import kotlinx.serialization.Serializable
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型 * 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。 * (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
* *
* ⚠️ [voice] 是这条「一处覆盖三条路」的**例外**:它只在四个 REST 读取面填充,实时帧与握手回放 * [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
* 上缺席(理由写在 [voice] 的 KDoc 里)。 * `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
* (前提与理由写在 [voice] 的 KDoc 里)。
*/ */
@Serializable @Serializable
data class HistoryMessageDto( data class HistoryMessageDto(
@@ -64,18 +65,30 @@ data class HistoryMessageDto(
val turnId: String? = null, val turnId: String? = null,
/** /**
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]); * 这条消息的语音元数据(契约 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}` 的响应与 * `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` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。
* 这个值。所以**不要在实时帧上等 `voice`**(本端也刻意不为它加任何重试 / 轮询):实时到达的 *
* 语音消息先按普通文本显示(转写文本本来就在 [content] 里),**等下一次拉取自动升级**成语音气泡。 * ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify;
* 这是设计,不是缺陷。 * ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
* 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。
* *
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN), * ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
* 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一 * 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一
@@ -6,17 +6,23 @@ import kotlinx.serialization.Serializable
* 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**, * 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**,
* `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。 * `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。
* *
* ## 🎤 判据 = 本键存在 * ## 🎤 判据 = 本键存在 **且** `status != "failed"`
* `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。 * `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。
* 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]` * 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]`
* 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。 * 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。
* 判据落在一处:`domain.chat.isVoiceMessage`。 *
* 键在只说明「这条来自一段声音」;要不要画语音形态还看 `status`(见下)。
* 判据落在一处:`domain.chat.isVoiceMessage`(`domain.chat.hasVoice` 答的是前半个问题)。
* *
* ## 三态 `status`(闭集) * ## 三态 `status`(闭集)
* `transcribed`(正文即转写文本)/ `empty`(转写成功但没识别出文字)/ `failed`(没拿到转写文本 * `transcribed`(正文即转写文本;出站方向 = 合成成功)/ `empty`(转写成功但没识别出文字)/
* ——**正文是服务端给的那行中文诚实提示**)。客户端**按 `status` 分支,绝不按正文文案分支**; * `failed`(**没有可播的语音**——入站没拿到转写文本,出站是合成降级回文字)。客户端**按 `status`
* 设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / * 分支,绝不按正文文案分支**;设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 /
* 无字节一律只进服务端日志),所以本端**不猜**细分。 * 限流 / 超时 / 格式不支持 / 无字节一律只进服务端日志),所以本端**不猜**细分。
*
* ## `fallbackReason` 只在 `failed` 时有
* 出站方向(`source = "tts"`)合成降级时,服务端在这个键里给出降级原因;本端**原样**小字展示
* (`domain.chat.voiceFallbackNote`),**不猜闭集**——取值集合后端尚未定稿,见那个函数的 KDoc。
* *
* ## `name` / `mime` / `size` / `url` 四者同生共死 * ## `name` / `mime` / `size` / `url` 四者同生共死
* 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。 * 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。
@@ -39,7 +45,11 @@ import kotlinx.serialization.Serializable
data class VoiceDto( data class VoiceDto(
/** /**
* `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 * `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的
* `voice_item.text`)。见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。 * `voice_item.text`)/ `"tts"`(我们自己合成的出站语音,后端 M57-M59)。
* 见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
*
* 接收侧**不按 `source` 分支**:合成成功的那条与入站语音同形(`status = transcribed` +
* 四键副本),走同一条渲染路径。
*/ */
val source: String = "", val source: String = "",
/** /**
@@ -47,6 +57,9 @@ data class VoiceDto(
* *
* 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值 * 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值
* (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。 * (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。
*
* ⚠️ 只有 `"failed"` 是**没有可播的语音**的那一态(`domain.chat.isPlayableVoice`);认不出的
* 取值一律当可播(fail-open,理由写在那个函数的 KDoc 里)。
*/ */
val status: String = "", val status: String = "",
/** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */ /** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */
@@ -57,6 +70,17 @@ data class VoiceDto(
val size: Long? = null, val size: Long? = null,
/** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */ /** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */
val durationMs: Int? = 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>`)。**不要自己拼这条路**,也不要当绝对地址用。 */ /** 副本的服务端相对路径(`/api/v1/media/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
val url: String? = null, val url: String? = null,
) )
@@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext
* 三条读取出口共用同一个端点,只是给的查询参数不同: * 三条读取出口共用同一个端点,只是给的查询参数不同:
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回); * - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
* - [search]:`q` 会话内搜索(也是倒序); * - [search]:`q` 会话内搜索(也是倒序);
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。 * - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
* *
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。 * 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
*/ */
@@ -87,7 +88,9 @@ class MessageRepository(
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。 * `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
* *
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)—— * 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。 * 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
*/ */
suspend fun loadAfter( suspend fun loadAfter(
sessionId: String, sessionId: String,
@@ -100,6 +103,87 @@ class MessageRepository(
.map { raw -> toPagedMessages(raw, pageSize) } .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}`,成功返回**服务端更新后的 * 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
* 完整消息 DTO**。 * 完整消息 DTO**。
@@ -152,6 +236,15 @@ class MessageRepository(
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */ /** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
const val DEFAULT_SEARCH_PAGE_SIZE = 30 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( private val EMPTY_PAGE = PagedMessages(
messages = emptyList(), messages = emptyList(),
hasMore = false, hasMore = false,
@@ -190,3 +283,18 @@ private fun toPagedMessages(
newestCreatedAt = ascending.lastOrNull()?.createdAt, 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"
}
}
}
@@ -10,13 +10,19 @@ import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
/** /**
* `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得; * `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 { object VoiceSource {
const val ASR: String = "asr" const val ASR: String = "asr"
const val PLATFORM: String = "platform" const val PLATFORM: String = "platform"
const val TTS: String = "tts"
} }
/** /**
@@ -28,17 +34,22 @@ object VoiceSource {
*/ */
object VoiceStatus { object VoiceStatus {
/** 正文**就是**转写文本。 */ /** 正文**就是**转写文本(出站方向 = 合成成功、有副本可播)。 */
const val TRANSCRIBED: String = "transcribed" const val TRANSCRIBED: String = "transcribed"
/** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */ /** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */
const val EMPTY: String = "empty" const val EMPTY: String = "empty"
/** /**
* 没拿到转写文本 ⇒ **正文是服务端给的那行中文诚实提示**,照常显示它。 * 这一条**没有可播的语音**,正文是一句话(见 [isPlayableVoice])。
* *
* 设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 * 两个方向同形、语义统一(后端 M57-M59 预告,契约 v0.98 待正式回执):
* 一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。 * - **入站**:没拿到转写文本 ⇒ 正文是服务端给的那行中文诚实提示;
* - **出站**:她本想用语音、合成降级回文字 ⇒ 正文是普通文字,另带 `fallbackReason`
* (见 [voiceFallbackNote])。
*
* 入站方向设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
* 无字节一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
*/ */
const val FAILED: String = "failed" const val FAILED: String = "failed"
} }
@@ -53,6 +64,19 @@ object VoiceStatus {
*/ */
const val VOICE_EMPTY_NOTE: String = "没有识别出文字" const val VOICE_EMPTY_NOTE: String = "没有识别出文字"
/**
* `status == "failed"` 的那一行**灰色小标**的固定前半句(后面可能跟服务端给的 `fallbackReason`)。
*
* 为什么要有这一行:这一态的消息 `voice` 键**在**、但**没有可播的语音**(见 [isPlayableVoice]),
* 所以屏幕上不会有语音形态。少了这一行,一条「她本想发语音、合成降级成文字」的消息与一条普通文字
* 消息**逐像素相同**——看的人无从知道这条本来是一段声音。它只说本端能确定的那件事(这条没有可播的
* 声音),**不解释原因**(原因归服务端,见 [voiceFallbackNote] 里那段原话)。
*
* ⚠️ 措辞要同时站得住两个方向(入站转写失败 / 出站合成降级),所以**不写**「转写失败」也**不写**
* 「合成失败」:本端分不清,也不该替服务端选一个说法。
*/
const val VOICE_FALLBACK_NOTE: String = "这条语音没有可播放的声音"
/** /**
* 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。 * 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。
* *
@@ -80,12 +104,50 @@ data class VoiceAudio(
) )
/** /**
* 🎤 **判据 = `voice` 键存在**(契约 §4.4)。 * `voice` 键在不在(纯函数)—— 「这条消息**来自**一条语音」。
* *
* 只有一个条件,而且**永远只有这一个**:`content` 里有没有 `[语音]` 这类字样与本判据无关 * 这是契约 §4.4 给的**唯一**来源判据:非语音消息整键缺席(不是 `{}` 也不是 `null`),
* (QQ 的占位标记自 v0.67 起不再下发,别人也可以自己打出一模一样的字)。 * `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`。 * 这条语音的**可播放副本**;四个键没凑齐(或名字 / MIME / URL 是空白)就回 `null`。
@@ -128,18 +190,45 @@ fun voiceDurationLabel(durationMs: Int?): String? {
} }
/** /**
* 这一条在**正文之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。 * 语音形态**之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
* *
* 只对 `status == "empty"` 且**正文为空**时成立: * 只对 `status == "empty"` 且**正文为空**时成立:
* - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补); * - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补);
* - `failed`:正文**就是**服务端那行诚实提示,替它加一句等于替它改文案——**不做**;
* - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾** * - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾**
* (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话); * (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话);
* - 未知 `status`(含空串):**不猜**,与 `failed` 一样只显示正文。 * - 未知 `status`(含空串):**不猜**,只显示正文。
*
* ⚠️ `failed` 那一态**不走这里**(它连语音形态都不画):正文由服务端给,本端另加一行小标
* ([voiceFallbackNote])。本函数仍对 `failed` 回 `null`,因为「语音形态旁边多说一句」这个位置
* 对那一态根本不存在。
*/ */
fun voiceNote(status: String, content: String): String? = fun voiceNote(status: String, content: String): String? =
if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null 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] 上(纯函数)。 * 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。
* *
@@ -0,0 +1,167 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
/**
* 「把这条消息念出来」这条路上,**本端要说的那几句话**,以及失败该分到哪一档。
*
* 这个文件里全是纯函数 / 常量,没有 Android、没有网络、没有状态:单测直接调(见
* `VoiceSpeakTest`)。真正的取字节在 `data.voice.SpeechClient`,播放复用 `ui.chat.VoicePlayer`。
*/
/**
* 从一条消息解析出来的气泡里抽出**要念的那段纯文本**。
*
* ## 为什么只念 `body`
* [ParsedBubble] 把一条气泡拆成三段:`head`(说话人抬头)、`body`(正文)、`tail`(旁白 / 动作)。
* 念出来时**只念正文**:
* - `head` 是「昔涟:」这种抬头,念出来像在念字幕;
* - `tail` 里的旁白(`*她笑了笑*`)与动作(`[动作]`)是**舞台指示**,语音合成念它只会变成一串
* 莫名其妙的字面量。
*
* ## 为什么按 `\n` 拼、且空就回 `null`
* 一条消息可能被切成多个气泡(连续说两段);`\n` 让合成的停顿落在段落之间,比空格自然。
* 全都为空(纯附件消息、纯旁白消息、或者解析完什么都不剩)时回 `null` —— 调用方据此**不显示**
* 朗读入口,而不是让用户点一个必然拿到 `400` 的按钮。
*
* 取完 `trim`:首尾空白会变成合成音频开头 / 结尾的静音,没有任何用处。
*/
fun ttsTextOf(bubbles: List<ParsedBubble>): String? =
bubbles.asSequence()
.map { it.body }
.filter { it.isNotBlank() }
.joinToString("\n")
.trim()
.takeIf { it.isNotEmpty() }
/**
* 一条消息(`content` + `mode`)里**要念的那段文本**;`null` = 没有可念的正文。
*
* 这是「这条消息能不能朗读」的**唯一判据**:`ChatViewModel.speak` 用它决定发不发请求,
* `MessageBubble` 用它决定画不画那枚图标。两处必须是同一个答案 —— 画了却发不出去、或者能发
* 却不给入口,都是 bug。
*
* ## 为什么要看 `mode`
* `chat`(或缺席)是「按 markup 切气泡的聊天式渲染」;其余 mode 下气泡里放的是**原文**,
* 一条 markup 都不解析(`MessageBubble` 就是这么画的)。朗读要念的显然也是**用户看到的那段字**:
* - 聊天式 ⇒ 走 [ttsTextOf] 剥掉抬头 / 旁白;
* - 其余 ⇒ 原文原样,只 `trim`。
*
* 判据本身与 `MessageBubble` 里那句 `isChat` 一字不差,刻意留在这里一份:多写一份就会出现
* 「界面按一种切法、朗读按另一种切法」的分叉。
*/
fun ttsTextOf(message: HistoryMessageDto): String? =
if (message.mode.isNullOrBlank() || message.mode == "chat") {
ttsTextOf(parse(message.content).filterNot { it.isEmpty })
} else {
message.content.trim().takeIf { it.isNotEmpty() }
}
/**
* 一次朗读失败该归到哪一档 —— 判据只看**服务端到底说了什么**,不看 HTTP 码的表面。
*
* ## 为什么分两档
* 这两档对用户的意思完全不同,混成一句就等于什么都没说:
* - [Soft]:**这会儿**没读出来(网络断了、令牌过期、网关抽风)。过一会儿再点是**有意义**的,
* 所以文案只陈述事实、不诱导、也不吓人;
* - [Unconfigured]:**配置**上就没这条路的。服务端明说了是哪种缺(`tts_persona_unmapped`
* 没有发音人映射 / `tts_no_character` 连生效人格都没有),重试**永远不会**成功 —— 所以文案要说
* 清「缺什么」,并且**不提重试**(提了就是在骗人点)。
*/
sealed interface TtsFailure {
/** 要说给用户的那一句话。 */
val message: String
/** 再点一次**可能**有用吗?([Unconfigured] 恒 `false`:重试永远不会成功。) */
val retryable: Boolean
/** 这会儿没读出来:不解释原因(本端确实不知道是哪一段断了),也不诱导重试。 */
data object Soft : TtsFailure {
override val message: String = TTS_SOFT_FAILURE_MESSAGE
override val retryable: Boolean = true
}
/**
* 服务端明说了配置缺一块。[code] 就是服务端那个错误码(原样留着,排查时对得上号)。
*/
data class Unconfigured(val code: String) : TtsFailure {
override val message: String = when (code) {
TTS_CODE_PERSONA_UNMAPPED -> TTS_PERSONA_UNMAPPED_MESSAGE
TTS_CODE_NO_CHARACTER -> TTS_NO_CHARACTER_MESSAGE
// 兜底:服务端回了一个本端还不认识的「配置缺失」类错误码。不猜它的意思,
// 但也不装作是临时故障——它毕竟带着一个明确的 code。
else -> TTS_UNCONFIGURED_MESSAGE
}
override val retryable: Boolean = false
}
}
/**
* 把一次朗读的异常分到 [TtsFailure] 两档里。
*
* 只有「服务端明确回了一个**配置缺失**类的 `code`」才进 [TtsFailure.Unconfigured];其余一切
* ——网络失败、解析失败、401(令牌过期,续期后还能用)、`voice_unavailable` / `tts_failed` /
* `tts_empty`(对面这会儿不行,不是本端的配置问题)——都进 [TtsFailure.Soft]。
*
* `tts_empty`(静默空音频)刻意归到柔和档:那是**这一次**合成出来的东西不能用,换个时间再试
* 完全可能成功,说成「配置没配好」就是错的。
*/
fun ttsFailureOf(error: Throwable): TtsFailure {
val cause = (error as? CyreneException)?.error ?: return TtsFailure.Soft
return when (cause) {
is CyreneError.Server -> when (cause.code) {
TTS_CODE_PERSONA_UNMAPPED,
TTS_CODE_NO_CHARACTER,
-> TtsFailure.Unconfigured(cause.code)
else -> TtsFailure.Soft
}
is CyreneError.Unauthorized -> TtsFailure.Soft
is CyreneError.Local -> TtsFailure.Soft
}
}
/**
* 朗读在某一条播放通道上的**键**:`tts:<messageId>`。
*
* 播放通道只有一条(`ui.chat.VoicePlayer` 的那个实例,与语音副本共用),所以键必须与语音副本
* (键 = `voice` 的 `messageId`)分得开:前缀就是那道分界线。同一条消息的朗读与它的语音副本
* 因此**互斥**(点其中一个会停掉另一个)——这是对的,同一时刻只该有一个声音。
*/
fun ttsClipIdOf(messageId: String): String = TTS_CLIP_PREFIX + messageId
/** 朗读入口那枚图标的读屏名字(也是设备验收时 `uiautomator` 的 `content-desc`)。 */
const val TTS_SPEAK_DESCRIPTION: String = "朗读这条消息"
/** 正在朗读时那枚图标的读屏名字(点它 = 停)。 */
const val TTS_STOP_DESCRIPTION: String = "停止朗读"
/** 正在取合成音频时那枚图标的读屏名字。 */
const val TTS_LOADING_DESCRIPTION: String = "正在准备朗读"
/** 柔和档:这会儿没读出来。**不说原因**(本端确实不知道断在哪一段),也不提重试。 */
const val TTS_SOFT_FAILURE_MESSAGE: String = "这会儿没能把这段读出来"
/** `tts_persona_unmapped`:人格有,但这个人格没有配发音人。 */
const val TTS_PERSONA_UNMAPPED_MESSAGE: String = "这个角色还没配好发音人"
/** `tts_no_character`:连生效人格都没有(会话没绑定、也没有全局默认人格)。 */
const val TTS_NO_CHARACTER_MESSAGE: String = "这个会话还没有配好发音人"
/** 服务端回了配置缺失类错误码、但本端还不认识那一个时的兜底文案。 */
const val TTS_UNCONFIGURED_MESSAGE: String = "这个角色还没有配好朗读的发音人"
/** 朗读播放通道的键前缀(与语音副本的键 `messageId` 区分开)。 */
const val TTS_CLIP_PREFIX: String = "tts:"
/** 服务端错误码:人格没有发音人映射。 */
const val TTS_CODE_PERSONA_UNMAPPED: String = "tts_persona_unmapped"
/** 服务端错误码:连生效人格都没有。 */
const val TTS_CODE_NO_CHARACTER: String = "tts_no_character"
@@ -228,6 +228,31 @@ object ShareRules {
fun truncatedImagesNotice(kept: Int, dropped: Int): String? = fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null 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 的原始字段提取内容。 * 从分享 Intent 的原始字段提取内容。
* *
@@ -506,6 +506,33 @@ fun ChatScreen(
} }
} }
// 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:<messageId>`,
// 与语音副本天然互斥——点一个停另一个)。键里带 `file`:`null → 文件` 那一刻才是真正能播的
// 时刻。`autoPlay` 只用一次,理由与上面那条完全相同(用户可能已经切走了)。
//
// ⚠️ `autoPlay` 必须进键:用户「放 → 停 → 再点同一条」时 `(clipId, file)` 一对**没变**
// (同一份缓存文件),只按那一对做键就再也不会起播。置假那一次会把键改成 `false`、下一轮
// 点击改回 `true`,于是每一点都重新点着一次;置假本身触发的重入在第一行就返回了,不成环。
//
// 起播之后这一格**不再变**:播放走到哪一步是播放通道的事,VM 不需要知道(见 `TtsClip` 的
// 类 KDoc)。
val ttsClip = state.ttsClip
LaunchedEffect(ttsClip?.clipId, ttsClip?.file, ttsClip?.autoPlay) {
val clip = ttsClip ?: return@LaunchedEffect
val file = clip.file ?: return@LaunchedEffect
if (!clip.autoPlay) return@LaunchedEffect
viewModel.consumeTtsAutoPlay()
if (!voicePlayer.state.value.isActiveFor(clip.clipId)) {
voicePlayer.toggleClip(clip.clipId) { Result.success(file) }
}
}
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
DisposableEffect(viewModel) {
onDispose { viewModel.dropPendingTtsAutoPlay() }
}
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。 // 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上, // 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上 // 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
@@ -759,6 +786,11 @@ fun ChatScreen(
// 语音播放(接收侧):状态来自**这一个会话**的那条播放通道, // 语音播放(接收侧):状态来自**这一个会话**的那条播放通道,
// 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。 // 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。
voicePlayback = voicePlayback, voicePlayback = voicePlayback,
// 朗读(W75):**只有这一条**的朗读状态进来(对不上的当没点过)。
// 播放状态仍是同一条通道——朗读的键是 `tts:<messageId>`,所以两枚
// 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。
ttsClip = state.ttsClip?.takeIf { it.messageId == item.message.id },
ttsPlayback = voicePlayback,
onDocumentClick = { ref -> onDocumentClick = { ref ->
previewNotice = null previewNotice = null
previewTarget = DocumentTarget.of(ref) previewTarget = DocumentTarget.of(ref)
@@ -774,6 +806,20 @@ fun ChatScreen(
onEdit = viewModel::startEdit, onEdit = viewModel::startEdit,
onDelete = viewModel::startDelete, onDelete = viewModel::startDelete,
onVoiceToggle = voicePlayer::toggle, 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( is ChatDisplayItem.Pending -> PendingBubble(
@@ -35,6 +35,7 @@ import top.yeij.cyrene.data.repository.MessageEditDraft
import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.PagedMessages import top.yeij.cyrene.data.repository.PagedMessages
import top.yeij.cyrene.data.repository.VoiceStore 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.applyMessageEdit
import top.yeij.cyrene.data.repository.historyForSession import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages import top.yeij.cyrene.data.repository.mergeMessages
@@ -71,6 +72,9 @@ import top.yeij.cyrene.domain.chat.parallelForSend
import top.yeij.cyrene.domain.chat.parallelWireValue import top.yeij.cyrene.domain.chat.parallelWireValue
import top.yeij.cyrene.domain.chat.readBounded import top.yeij.cyrene.domain.chat.readBounded
import top.yeij.cyrene.domain.chat.toDocumentFailure 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.turnBucketOf
import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor
import top.yeij.cyrene.domain.chat.voiceClipRejection import top.yeij.cyrene.domain.chat.voiceClipRejection
@@ -636,6 +640,76 @@ fun ChatUiState.applyMessageActionFailure(
} }
} }
/**
* 点「朗读」这一步的状态迁移(纯函数,VM 与测试共用)。
*
* 返回 `null` = **这一次点击该被忽略**:同一条消息的请求还在飞([TtsClip.pending])。这是防抖的
* 唯一判据。已经落好(`file != null`)或已经失败过(`failure != null`)的**不算**在飞 ——
* 前者是「再放一遍」,后者是用户手动重试,两种都该放行。
*
* 返回新状态 = 放行,并且这一格已经换成「请求在飞」。`autoPlay` 保持默认的真:字节到手那一刻
* 要起播一次(见 [TtsClip.autoPlay]),置假只在真的起播之后(`ChatViewModel.consumeTtsAutoPlay`)。
*/
fun ChatUiState.beginTtsSpeak(messageId: String, clipId: String): ChatUiState? {
val current = ttsClip
if (current != null && current.clipId == clipId && current.pending) return null
return copy(ttsClip = TtsClip(messageId, clipId, null, null, false))
}
/**
* 请求回来了,把结论落到这一格(纯函数,VM 与测试共用)。
*
* 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、
* 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。
*
* `autoPlay` **从旧那一格继承**而不是重置成真:用户可能在请求落地之前就离开了这一屏
* ([ChatUiState.dropPendingTtsAutoPlay] 把这一位置了假),结果晚到之后若又把它抬回真,
* 切回来时就会突然响一段刚才点的朗读 —— 那正是要拦的那件事。
*
* 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的
* 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。
*/
fun ChatUiState.applyTtsOutcome(
messageId: String,
clipId: String,
result: Result<File>,
): ChatUiState {
val current = ttsClip
if (current == null || current.clipId != clipId) return this
val autoPlay = current.autoPlay
return result.fold(
onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false, autoPlay)) },
onFailure = { error ->
val failure = ttsFailureOf(error)
copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable, autoPlay))
},
)
}
/**
* 这一屏要走了:把**还没到手**的那一次自动起播作废(纯函数,VM 与测试共用)。
*
* ## 为什么需要它
* 起播闸门活在渲染层那个 `LaunchedEffect` 里(见 `ChatScreen`),而它只在**这一屏在的时候**
* 才会被跑一次。用户点了朗读之后立刻切走(换会话 / 进详情页),合成还在飞;结果落地的瞬间这一屏
* 不在,闸门没被消费过;等他切回来,那个 effect 一挂上就以「文件到了 + `autoPlay` 还是真」为由
* 起播 —— 突然响一段**已经过去**的朗读,不是他此刻的动作。
*
* ## 为什么在「离开」这一刻判,而不是给 clip 记一个发起时刻再按超龄丢
* 时间不是这件事的判据:合成一段长文本本来就可能花上十几秒,按秒数丢会把**人就在看着屏幕**
* 的那一次也一起丢掉(点了却不出声)。「这一屏还在不在」才是真正的分界 —— 离开时把没消费的
* 自动起播划掉,正好只拦掉「他不在」的那些。
*
* ## 只动「还没到手」的那一格
* 文件已经在手(`file != null`)说明闸门要么已经跑过(`autoPlay` 已被 [consumeTtsAutoPlay]
* 置假)、要么就是正在起播的这一刻:这两种情况一律原样返回,绝不打断一次真的播放。
*/
fun ChatUiState.dropPendingTtsAutoPlay(): ChatUiState {
val clip = ttsClip ?: return this
if (!clip.autoPlay || clip.file != null) return this
return copy(ttsClip = clip.copy(autoPlay = false))
}
sealed interface ChatDisplayItem { sealed interface ChatDisplayItem {
val key: String val key: String
@@ -857,6 +931,17 @@ data class ChatUiState(
* (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。 * (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。
*/ */
val voiceNotice: String? = null, 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)。 */ /** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
val items: List<ChatDisplayItem> val items: List<ChatDisplayItem>
@@ -1541,7 +1626,7 @@ class ChatViewModel(
emitPlannedMessages( emitPlannedMessages(
plan = imageSendPlan(text, images, documents), plan = imageSendPlan(text, images, documents),
clearDraft = true, clearDraft = true,
applySendPreference = true, fromShare = false,
localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX, localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX,
) )
} }
@@ -1622,6 +1707,92 @@ class ChatViewModel(
} }
} }
/**
* 用户点了某条消息上的「朗读」(W75,`POST /api/v1/audio/speech`)。
*
* ## 念什么
* 只念**她**的消息(`assistant`):`user` 那一侧点朗读没有意义,而且服务端的发音人由
* **会话的生效人格**决定 —— 念主人自己说的话会用她的声音,那是错的。判据就是角色这一条,
* 其余(要念哪段字)全交给纯函数 [ttsTextOf]:它剥掉抬头与旁白,只留正文;一个字都不剩
* (纯附件 / 纯旁白)时**直接不发请求** —— 服务端对空 `text` 回 `400`,本端没有理由先跑一趟。
*
* ## 防重入
* 同一条消息的请求**在飞的时候再点一次**直接忽略:`_state.ttsClip` 已经是那条消息的
* `pending` 态。用户点**另一条**则照常发起(旧那条被整格替换,它的音频文件留在缓存里,
* 下次点它命中、不发请求 —— 见 `data.voice.SpeechClient`)。
*
* 播放那半边**不在这里**:这里只把「字节到手」写进 [ChatUiState.ttsClip],起播由渲染层那个
* `LaunchedEffect` 交给唯一的播放通道(`ui.chat.VoicePlayer`)。分开是必须的 —— VM 不该碰
* `MediaPlayer`,而播放通道要能在「换了一条语音副本」时独立停下,不必回头改这一格。
*
* ## 失败
* 分类是纯函数 [ttsFailureOf]:`tts_persona_unmapped` / `tts_no_character` 这两档是**配置**缺失
* (重试永远不会成功,文案要说清缺什么),其余一律柔和提示。**不重试**:这条路上没有任何
* 自动重试 —— 用户再点一次就是那次重试,而配置缺失那一档连这句话都不该说。
*
* 失败**不弹窗、不走 [ChatUiState.error] 横幅**:那是「这一次请求失败了」的全局通道,而朗读
* 是一次附带的小动作,把它抬成横幅会盖住真正的发送失败。文案挂在**这条消息**下面(见
* `MessageBubble`)。
*/
fun speak(message: HistoryMessageDto) {
if (message.role == "user") return
val text = ttsTextOf(message) ?: return
val clipId = ttsClipIdOf(message.id)
// 判据与落位都在纯函数里([beginTtsSpeak] / [applyTtsOutcome]):同一条正在飞 ⇒ 这里
// 拿回 null,直接返回。用 compareAndSet 而不是 update,是因为「放行与否」和「写进哪一
// 格」必须是同一次读写的结论——分开写会让并发的两次点击都以为自己放行了。
while (true) {
val current = _state.value
val begun = current.beginTtsSpeak(message.id, clipId) ?: return
if (_state.compareAndSet(current, begun)) break
}
viewModelScope.launch {
val result = speechClient.speak(text, sessionId)
_state.update { it.applyTtsOutcome(message.id, clipId, result) }
}
}
/**
* 这一次朗读的**自动起播**已经用掉了(渲染层在真的起了播放之后调)。
*
* 与 [consumeVoiceReplyAutoPlay] 同一条纪律、同一个理由:请求发出去之后用户可能已经切到别的
* 会话 / 别的页面,切回来时不该突然响一段刚才点的朗读(见 [TtsClip.autoPlay])。
*/
fun consumeTtsAutoPlay() {
_state.update { state ->
val clip = state.ttsClip
if (clip == null || !clip.autoPlay) state else state.copy(ttsClip = clip.copy(autoPlay = false))
}
}
/**
* 这一屏走了:把**还没到手**的那一次自动起播作废(判据与取舍全在纯函数
* [dropPendingTtsAutoPlay] 里)。
*
* 由渲染层的 `DisposableEffect.onDispose` 调:起播闸门只在这一屏活着的时候跑得到,人走了
* 而结果后到,切回来时就不该突然响一段旧朗读。
*/
fun dropPendingTtsAutoPlay() {
_state.update { it.dropPendingTtsAutoPlay() }
}
/**
* 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造
* (与 `AppContainer` 那些按需 getter 同一手法)。
*
* `baseUrlProvider` / `tokenProvider` 都传**函数**而不是值:换服务器 / 令牌续期之后,
* 下一次朗读自动用新的(与 `mediaApi` 同源同纪律)。凭据只走 owner Bearer ——
* 主人 2026-10-26 拍板不设 API Key 输入框(见 `data.voice.SpeechClient` 的类 KDoc)。
*/
private val speechClient: SpeechClient by lazy {
SpeechClient(
baseUrlProvider = { container.authManager.currentBaseUrl },
tokenProvider = container.authManager::currentToken,
unauthorizedPolicy = container.authManager.unauthorizedPolicy,
cacheDir = container.appContext.cacheDir,
)
}
/** /**
* 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。 * 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。
* *
@@ -1783,7 +1954,7 @@ class ChatViewModel(
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟; * - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。 * - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
* *
* ## 分享不声明(`applySendPreference` 恒为 false) * ## 分享不声明(`fromShare = true` ⇒ [ShareRules.declaresSendPreference] 恒为 false)
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递, * 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递,
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。 * 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
* 两种声明在这里**都**是错的: * 两种声明在这里**都**是错的:
@@ -1844,7 +2015,7 @@ class ChatViewModel(
emitPlannedMessages( emitPlannedMessages(
plan = imageSendPlan(text, share.images, files), plan = imageSendPlan(text, share.images, files),
clearDraft = false, clearDraft = false,
applySendPreference = false, fromShare = true,
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX, localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
) )
} }
@@ -2008,16 +2179,20 @@ class ChatViewModel(
* 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则 * 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则
* 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。 * 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。
* *
* 判据**只有一处**:[ShareRules.declaresSendPreference](纯函数,穷举整批的测试在
* `ShareSendDeclarationTest`)。这里只把两条入口的事实([fromShare])与位置([index])喂给它,
* 不在本函数里再写一遍 `&& index == 0`——那种写法正是 W76 之前两处各半个判据的由来。
*
* @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送, * @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送,
* 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。 * 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。
* @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的 * @param fromShare 这一批是不是分享进来的:分享落地那条传 true([sendShared]),输入栏那条传
* 那条传 false(分享不是用户按的发送键,从不声明)。 * false([send])。它是 [ShareRules.declaresSendPreference] 的入参之一——分享从不声明。
* @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。 * @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。
*/ */
private suspend fun emitPlannedMessages( private suspend fun emitPlannedMessages(
plan: ImageSendPlan, plan: ImageSendPlan,
clearDraft: Boolean, clearDraft: Boolean,
applySendPreference: Boolean, fromShare: Boolean,
localIdPrefix: String, localIdPrefix: String,
) { ) {
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages) ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages)
@@ -2028,7 +2203,7 @@ class ChatViewModel(
imageUri = message.imageUri, imageUri = message.imageUri,
documents = message.documents, documents = message.documents,
clearDraft = clearDraft && index == 0, clearDraft = clearDraft && index == 0,
applySendPreference = applySendPreference && index == 0, applySendPreference = ShareRules.declaresSendPreference(fromShare, index),
localIdPrefix = localIdPrefix, localIdPrefix = localIdPrefix,
) )
} }
@@ -2040,9 +2215,9 @@ class ChatViewModel(
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条; * @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。 * 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明: * @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false: * `interrupt` 与 `parallel`)。它是**已经算好的结果**,由 [emitPlannedMessages] 按
* [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些 * [ShareRules.declaresSendPreference] 逐条判出来(唯一调用方就是它)——本函数不再自己推导,
* ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。 * 也没有默认值:判据只能有一个出处,给个 `= true` 的默认值等于把口径又抄了一遍。
* @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认), * @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认),
* [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在 * [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在
* 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动** * 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动**
@@ -2055,7 +2230,7 @@ class ChatViewModel(
imageUri: String?, imageUri: String?,
documents: List<DraftDocument>, documents: List<DraftDocument>,
clearDraft: Boolean, clearDraft: Boolean,
applySendPreference: Boolean = true, applySendPreference: Boolean,
localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX, localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX,
) { ) {
// 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。 // 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。
@@ -2135,10 +2310,10 @@ class ChatViewModel(
// 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。 // 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。 // 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
// //
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。 // 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,且是这一批的第一条)。
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍 // 这个值由 [emitPlannedMessages] 按 [ShareRules.declaresSendPreference] 逐条算好后传进来——
// 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true // 分享那条入口([ChatViewModel.sendShared])因此一次都不声明:它是**批量**投递,两种声明在那儿
// (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。 // 都是错的(理由与取舍见 [sendShared] 的「分享不声明」一段)。
val mode = currentInterruptMode() val mode = currentInterruptMode()
val busy = _state.value.busyMode != null val busy = _state.value.busyMode != null
if (applySendPreference) interruptRunningTurn(mode, busy) if (applySendPreference) interruptRunningTurn(mode, busy)
@@ -2559,7 +2734,7 @@ class ChatViewModel(
* 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发 * 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发
* `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支 * `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时** * 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时**
* 才调(分享那条批量路径传 false,见 [sendShared])。 * 才调(那个值出自 [ShareRules.declaresSendPreference],分享那条批量路径恒为 false,见 [sendShared])。
* *
* [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**: * [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**:
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。 * 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
@@ -47,9 +47,18 @@ import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageHostRebase 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.TurnPhase
import top.yeij.cyrene.domain.chat.VoicePlaybackState 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.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.chat.voiceNote
import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart 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.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.More 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 top.yukonga.miuix.kmp.theme.MiuixTheme
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -106,10 +117,15 @@ private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传
* *
* 判据刻意**窄**,只有 `user` 消息才可能成立: * 判据刻意**窄**,只有 `user` 消息才可能成立:
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白); * - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
* - **语音消息一律不成立**(`voice != null`):语音的转写文本可能为空(`status = empty`, * - **`voice` 键在就一律不成立**([hasVoice]):这一条问的是「这条里**有没有东西可看**」,不是
* 或 `transcribed` 却什么都没有),但那条消息**不是什么都没有** —— 它有一个语音形态 * 「有没有语音形态」,所以两档都要挡住:
* (`VoiceMessageRow` 会把它画出来)。少了这一条,一条「微信语音、没认出发音、也没有音频副本」 * - `status = empty` / `transcribed`(语音形态画得出来):转写文本可能为空,但那条消息**不是
* 的消息会被扣上「内容没有被传过来」这顶帽子,而那句话是**假的**; * 什么都没有** —— 它有一个语音形态(`VoiceMessageRow` 会把它画出来)。少了这一档,一条
* 「微信语音、没认出发音、也没有音频副本」的消息会被扣上「内容没有被传过来」这顶帽子,
* 而那句话是**假的**;
* - `status = failed`(入站转写失败 / 出站合成降级):**没有**语音形态,但有一条正文
* (服务端那行诚实提示 / 降级后的原文本)和那行降级小标([voiceFallbackNote] 的产物)——
* 同样不是「什么都没有」;
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说), * - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
* 顺手改会波及所有助手气泡,不在本次范围内。 * 顺手改会波及所有助手气泡,不在本次范围内。
* *
@@ -123,8 +139,10 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
message.image.isNullOrBlank() && message.image.isNullOrBlank() &&
message.documents.isEmpty() && message.documents.isEmpty() &&
message.thinking.isNullOrBlank() && message.thinking.isNullOrBlank() &&
// 判据只有一处(domain.chat.isVoiceMessage):绝不在这里另写一个 `voice != null`。 // 判据只有一处(domain.chat.hasVoice):绝不在这里另写一个 `voice != null`。
!isVoiceMessage(message) // ⚠️ 用 hasVoice 而不是 isVoiceMessage:failed 行(转写失败 / 合成降级)也**不是**什么都没有
// —— 它有正文(服务端那行诚实提示 / 降级后的原文本)与那行降级小标(voiceFallbackNote)。
!hasVoice(message)
/** /**
* 一条落库消息渲染成一个或多个气泡: * 一条落库消息渲染成一个或多个气泡:
@@ -133,9 +151,12 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* - 其它 mode 原样整条渲染,不解析标签; * - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片; * - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画); * - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `voice` 非空时(🎤 判据 = **这个键存在**,契约 §4.4)在正文之外多画一枚语音形态 * - `voice` 键在时(🎤 判据 = **这个键存在且 `status != "failed"`**,契约 §4.4 + 后端 M57-M59
* ([VoiceMessageRow]:播放 / 暂停 + 时长,有副本时才有播放控件);正文照常渲染 * 预告,见 `isVoiceMessage`)在正文之外多画一枚语音形态([VoiceMessageRow]:播放 / 暂停 + 时长,
* ——转写文本 / 服务端那行诚实提示都在里面,一个字都不改; * 有副本时才有播放控件);正文照常渲染——转写文本 / 服务端那行诚实提示都在里面,一个字都不改;
* `status = "failed"` 那一档(入站转写失败 / 出站合成降级)**没有**语音形态可画,改为在正文之外
* 多画一行灰色小标([VoiceFallbackNote],文案由 `voiceFallbackNote` 给:固定前半句 +
* 服务端 `fallbackReason` 原样);
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过 * - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
* [ImageHostRebase](见 [imageBaseUrl]); * [ImageHostRebase](见 [imageBaseUrl]);
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明 * - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
@@ -149,7 +170,8 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。 * 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
* *
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档); * 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音形态) * 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音那一块
* ——语音形态或 `failed` 行的降级小标,`voice` 键在就必画其一)
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为 * ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间** * `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
* (W72 补的就是它)。 * (W72 补的就是它)。
@@ -189,6 +211,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认 * 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认
* [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。 * [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。
* @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。 * @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。
* @param ttsClip 这一条(或**别的一条**)的朗读状态;只有 `messageId` 对得上时才影响本条。
* 见 [TtsClip]:`null` / 对不上 / 本条没点过朗读 ⇒ 与加这一块之前逐像素相同。
* @param ttsPlayback 那条**播放通道**此刻的状态。朗读与语音副本共用它,所以朗读的键是
* `ttsClipIdOf(message.id)` 而不是 `message.id` —— 两枚图标的「正在响」互不串台。
* @param onSpeak 用户点了「朗读」:交给上层发请求(本组件不自己发)。
*/ */
@Composable @Composable
fun MessageBubble( fun MessageBubble(
@@ -197,11 +224,14 @@ fun MessageBubble(
phase: TurnPhase = TurnPhase.Sent, phase: TurnPhase = TurnPhase.Sent,
legBadge: String? = null, legBadge: String? = null,
voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle, voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle,
ttsClip: TtsClip? = null,
ttsPlayback: VoicePlaybackState = VoicePlaybackState.Idle,
onDocumentClick: (DocumentRefDto) -> Unit = {}, onDocumentClick: (DocumentRefDto) -> Unit = {},
onImageClick: (String) -> Unit = {}, onImageClick: (String) -> Unit = {},
onEdit: (HistoryMessageDto) -> Unit = {}, onEdit: (HistoryMessageDto) -> Unit = {},
onDelete: (HistoryMessageDto) -> Unit = {}, onDelete: (HistoryMessageDto) -> Unit = {},
onVoiceToggle: (HistoryMessageDto) -> Unit = {}, onVoiceToggle: (HistoryMessageDto) -> Unit = {},
onSpeak: (HistoryMessageDto) -> Unit = {},
) { ) {
val isUser = message.role == "user" val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同: // 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
@@ -237,6 +267,20 @@ fun MessageBubble(
// 那一行根本没被画出来。 // 那一行根本没被画出来。
val timeCarrier = messageTimeCarrier(message, bubbles) 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( MessageGroup(
isUser = isUser, isUser = isUser,
footer = bubbleFooter( footer = bubbleFooter(
@@ -246,6 +290,10 @@ fun MessageBubble(
shared = isSharedMessage(message), shared = isSharedMessage(message),
), ),
onOpenMenu = openMenu, onOpenMenu = openMenu,
onSpeak = if (speakable) { { onSpeak(message) } } else null,
speakActive = speakActive,
speakPending = speakPending,
ttsFailure = ownClip?.failure,
) { ) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) } message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文 // 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
@@ -277,19 +325,34 @@ fun MessageBubble(
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示 // 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示 // 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。 // 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
BubbleWithTime( if (isVoiceMessage(message)) {
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice }, BubbleWithTime(
isUser = isUser, time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
) { side -> isUser = isUser,
VoiceMessageRow( ) { side ->
voice = voice, VoiceMessageRow(
messageId = message.id, voice = voice,
playback = voicePlayback, messageId = message.id,
note = voiceNote(voice.status, message.content), playback = voicePlayback,
alignEnd = isUser, note = voiceNote(voice.status, message.content),
onToggle = { onVoiceToggle(message) }, alignEnd = isUser,
modifier = side, 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)) { if (isContentlessUserMessage(message)) {
@@ -500,7 +563,7 @@ sealed interface MessageTimeCarrier {
/** 图片(`image` 非空才会画)。 */ /** 图片(`image` 非空才会画)。 */
data object Image : MessageTimeCarrier data object Image : MessageTimeCarrier
/** 语音形态(`voice` 键在才会画)。 */ /** 语音那一块(`voice` 键在才会画:语音形态,或 `failed` 行的降级小标)。 */
data object Voice : MessageTimeCarrier data object Voice : MessageTimeCarrier
} }
@@ -509,16 +572,18 @@ sealed interface MessageTimeCarrier {
* *
* ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西 * ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西
* *
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音形态 → 正文气泡。 * [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音那一块 → 正文气泡。
* 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。 * 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。
* *
* 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」: * 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就 * 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。 * 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音形态 → 图片 → 附件芯片排。这一档就是 * 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音那一块 → 图片 → 附件芯片排。这一档就是
* **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡 * **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡
* `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来** * `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来**
* (主人 2026-10-07 验收时发现的缺口)。 * (主人 2026-10-07 验收时发现的缺口)。
* 「语音那一块」按 `voice` 键判([hasVoice])而**不**按 [isVoiceMessage]:`failed` 行不画语音
* 形态,但会画那行降级小标([VoiceFallbackNote])——它也占着一块,也照样能挂时间。
* 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。 * 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。
* *
* ## 谁**不**走这一档 * ## 谁**不**走这一档
@@ -544,7 +609,7 @@ fun messageTimeCarrier(
if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody) if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody)
// 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。 // 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。
return when { return when {
message.voice != null -> MessageTimeCarrier.Voice hasVoice(message) -> MessageTimeCarrier.Voice
!message.image.isNullOrBlank() -> MessageTimeCarrier.Image !message.image.isNullOrBlank() -> MessageTimeCarrier.Image
message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments
else -> null else -> null
@@ -751,6 +816,10 @@ private fun MessageGroup(
isUser: Boolean, isUser: Boolean,
footer: String? = null, footer: String? = null,
onOpenMenu: (() -> Unit)? = null, onOpenMenu: (() -> Unit)? = null,
onSpeak: (() -> Unit)? = null,
speakActive: Boolean = false,
speakPending: Boolean = false,
ttsFailure: String? = null,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Column( Column(
@@ -763,7 +832,7 @@ private fun MessageGroup(
content() content()
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。 // 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。 // 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
if (footer != null || onOpenMenu != null) { if (footer != null || onOpenMenu != null || onSpeak != null) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP), horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
@@ -776,6 +845,31 @@ private fun MessageGroup(
modifier = Modifier.padding(start = 6.dp, end = 6.dp), 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) { if (onOpenMenu != null) {
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的 // 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了 // 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
@@ -792,6 +886,18 @@ private fun MessageGroup(
} }
} }
} }
// 朗读没成功时那一句。挂在角标行**下面**、自成一行:塞进那一行会把来源角标挤成两截,
// 而这句话是给用户看的一整句,不该跟角标抢横向空间。
// 颜色用 `error` 而不是灰:它是一条**如实告知**(不是装饰),但也不弹窗、不打断——
// 用户的眼睛本来就在他刚点的那条消息上。
if (ttsFailure != null) {
Text(
text = ttsFailure,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
} }
} }
@@ -30,7 +30,10 @@ import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = `voice` 键存在),不是内容。 */ /**
* 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = 这条画的是语音形态,
* 见 `isVoiceMessage`),不是内容。
*/
const val VOICE_LABEL: String = "语音" const val VOICE_LABEL: String = "语音"
/** 加载中那一句(与 [VOICE_LABEL] 同一行,替换掉时长那一格)。 */ /** 加载中那一句(与 [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`) * - **无副本 ⇒ 不画播放控件**([voiceAudio] 回 `null`):四个键(`name`/`mime`/`size`/`url`)
* 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示 * 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示
* (时长与副本**无关**); * (时长与副本**无关**);
@@ -171,3 +176,44 @@ fun VoiceMessageRow(
} }
} }
} }
/**
* `status == "failed"` 那条消息**没有语音形态**,但要多说的一句话(`voice` 键在、没有可播的声音)。
*
* ## 为什么这一行必须存在
* 这一态的消息正文照常按文字渲染(入站是服务端那行诚实提示,出站是合成降级后的原文本)。少了这一行,
* 一条「她本想用语音、合成降级成文字」的消息与一条普通文字消息**逐像素相同**——看的人无从知道这条
* 本来是一段声音,也看不出它与别的话有什么不一样。所以这枚小标不是装饰,它是这一态**唯一**的可见
* 凭据([VoiceMessageRow] 在这里根本不画,见 `isVoiceMessage`)。
*
* ## 样式为什么抄 [VoiceMessageRow] 里 note 那一行
* `footnote2` + `onSurfaceVariantSummary`:与语音形态旁边那几句说明(「没有识别出文字」/ 加载中 /
* 时长)**同一档**。这三处说的都是「关于这段声音的一句脚注」,同一个外观才让人读成一类东西;
* 换成正文那一档字级会被误读成消息内容的一部分,换成 `error` 又会把一次**降级**说成一次**失败**
* ——降级本身是服务端做对的一件事(她的话一个字没丢,只是没变成声音)。
*
* ## 不判 `status`,也不拼文案
* [text] 由调用方用纯函数 `voiceFallbackNote` 算好传进来(固定前半句 + 服务端 `fallbackReason` 原样)。
* 本组件只负责画:判据与措辞都在 domain 层单测钉着,这里再判一次就多出「两处判据不一致」的可能
* ——与 [VoiceMessageRow] 对 `note` 的处理同一条纪律。
*
* @param text 已经算好的那一整句(本组件不改一个字符)。
* @param alignEnd 用户侧(气泡在右):整行贴右;否则贴左。
*/
@Composable
fun VoiceFallbackNote(
text: String,
alignEnd: Boolean = false,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
@@ -0,0 +1,103 @@
package top.yeij.cyrene.ui.chat
import java.io.File
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.ttsClipIdOf
/**
* 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。
*
* ## 为什么它住在 VM 的状态里,而不是气泡自己的 `remember`
* 一次朗读要经过一段**跨组合重建**的等待(发请求、等字节、落盘):转屏 / 滚出屏幕再滚回来
* 都不该让已经落好的那段音频消失,也不该让「正在准备」这件事凭空重置。所以请求发起、失败分类、
* 落盘结果都在 `ChatViewModel.speak` 里,气泡只是这份状态的视图。
*
* ## 一次只有一个:`ttsClip` 是**单格**
* 与 [VoiceReply] 同一取舍:同一时刻用户点的是**一条**消息,多留几条既占内存又没人看。
* 用户点了另一条 ⇒ 这一格被新的那条整个替换(旧那条的音频文件留在缓存里,下次点它命中不发请求)。
*
* ## 播放不在这一格里
* 「正在响 / 已经停了」由**唯一的播放通道**(`ui.chat.VoicePlayer` 的 `state`)说,键是
* [clipId](`tts:<messageId>`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、
* 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道
* `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事
* 本来就不是一件事。
*
* @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。
* @property clipId 播放通道上的键(`tts:<messageId>`):与语音副本的键(= 它的 `messageId`)
* 分得开,于是「念这一条」与「放这条消息的语音副本」天然互斥(点一个停另一个)。
* @property file 合成音频在本机的那份 WAV;`null` = 还没到手(请求在飞,或已经失败了)。
* @property failure 上一次没成功时给用户看的那句话(`domain.chat.TtsFailure.message`);
* `null` = 没失败过 / 还没结果。
* @property retryable 再点一次**可能**有用吗(`domain.chat.TtsFailure.retryable`)。为假时图标
* 仍然可点 —— 但那是「重来一次」而不是「重试」,文案上不提重试(见 `TtsFailure.Unconfigured`)。
* @property autoPlay 这一次结果还该不该**自动起播**(默认 true,起播过就置假,见
* `ChatViewModel.consumeTtsAutoPlay`)。
*
* 为什么要这一位:请求发出去之后用户可能已经切到别的会话 / 别的页面。若只按「文件到了就放」
* 来触发,等他切回来时会**突然响一段刚才点的朗读** —— 那不是他此刻的动作。所以自动起播只认
* **第一次**落好的那一下,之后靠那枚图标。
*/
data class TtsClip(
val messageId: String,
val clipId: String,
val file: File?,
val failure: String?,
val retryable: Boolean,
val autoPlay: Boolean = true,
) {
/** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */
val pending: Boolean get() = file == null && failure == null
}
/**
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
*
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
* 判据跨了三处状态(这一格的 [TtsClip]、那条唯一播放通道的 [VoicePlaybackState]、以及
* `ttsClipIdOf` 铸的键),而它们谁都不该在自己的类里替对方下结论。
*/
sealed interface TtsSpeakIntent {
/** 这一条**正在响**,手上也有它的音频 ⇒ 这一下是「停」。 */
data object Stop : TtsSpeakIntent
/** 其余一切(没在响、响的是别的、失败过、缓存已命中想再听一遍)⇒ 交给 VM。 */
data object Speak : TtsSpeakIntent
}
/**
* 这一下点击是「停」还是「(重新)念」。
*
* ## 为什么必须有这条判据:否则「停止朗读」是空操作
* 图标在播放中画的是暂停符、读屏名字是 `TTS_STOP_DESCRIPTION`(见 `MessageBubble`),所以
* 用户点它时**意思是停**。但播放中 `TtsClip.pending` 是假的(文件早就到手了),于是
* `ChatViewModel.beginTtsSpeak` 的防抖放行、整格被换成新的 pending、缓存命中秒回、
* 起播 effect 又因为「已经在放同一条」而跳过 `toggleClip` —— 三处各自都「没错」,合起来就是
* 点了没反应。判据落在**播放通道**这一处(它是「有没有在响」的唯一真相),才不会有第二处
* 各自解释「播放中」是什么意思。
*
* ## 为什么要 `file != null`
* 「正在响」与「音频在手上」是两件事:通道处于 [VoicePlaybackState.Loading] 时它也算 active,
* 而那一刻 [TtsClip.file] 可能还是 `null`(字节在 VM 那边取)。那时把点击解释成「停」会停掉
* 一次**还没起播**的载入,而用户点它的本意是「念」——所以只有文件确实在手、通道确实在响时才判
* [TtsSpeakIntent.Stop];其余一律 [TtsSpeakIntent.Speak],由 VM 的 pending 分支去防抖。
*
* @param playback 那条唯一播放通道此刻的状态(`VoicePlayer.state`)。
* @param messageId 被点的那条消息。
* @param clip 这一条自己的朗读格(`messageId` 对不上的要当没点过,判据里再核一次)。
*/
fun ttsSpeakIntent(
playback: VoicePlaybackState,
messageId: String,
clip: TtsClip?,
): TtsSpeakIntent {
val file = clip?.takeIf { it.messageId == messageId }?.file
return if (file != null && playback.isActiveFor(ttsClipIdOf(messageId))) {
TtsSpeakIntent.Stop
} else {
TtsSpeakIntent.Speak
}
}
@@ -1,5 +1,7 @@
package top.yeij.cyrene.ui.sessions package top.yeij.cyrene.ui.sessions
import top.yeij.cyrene.data.repository.MessageIncrement
/** /**
* 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。 * 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。
* *
@@ -18,11 +20,15 @@ internal enum class PersistedRoute {
/** /**
* **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数** * **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数**
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,问服务端要那个数。 * (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地问服务端要。
* *
* 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。 * 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。
* 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处, * 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处,
* 于是红点必须靠用户开一次抽屉(那次静默重拉)才补上。 * 于是红点必须靠用户开一次抽屉才补上。
*
* 「要」的是什么,W77 换过一次:原来是整表 `GET /sessions`,现在是**这条会话的消息增量**
* `GET /messages?after=`(见 [incrementCursors] / [applyIncrement]);整表退回成「增量给不出
* 可信答案」时的回落。判据本身不受影响——这一档该不该被窗口挡住,与问的是哪种口径无关。
*/ */
InactiveSession, InactiveSession,
} }
@@ -190,3 +196,117 @@ internal fun silentRefreshStep(
*/ */
internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle = internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle =
SilentRefreshThrottle(lastPullAt = nowMillis) 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?, val lastMessage: String?,
/** /**
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。 * 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。 * 两处用:已读游标比对(见 [SessionItem.withReadCursor]),以及增量追尾的 `after` 游标
* (见 `incrementCursorFor`,由 `applyIncrement` 推进到这次增量的 `cursor`)。不直接显示。
*/ */
val lastMessageAt: Long? = null, val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */ /** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
@@ -82,9 +83,12 @@ data class SessionsUiState(
* *
* **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带 * **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带
* `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**: * `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**:
* 收到非激活会话的落库广播就**节流地静默重拉**([requestSilentRefresh],窗口 * 收到非激活会话的落库广播就**节流地问一次那条会话的消息增量**([requestSilentRefresh] →
* [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次抽屉**就会出现 * [flushPendingIncrements],窗口 [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次
* (S6,登记见 `devdocs/05-开发进度与交接.md`)。 * 抽屉**就会出现(S6,登记见 `devdocs/05-开发进度与交接.md`)。
*
* 取数走的是 `GET /messages?after=<ms>`(前向增量)而不是整表 `GET /sessions`——W77 换的,理由与回落
* 规则都写在 [flushPendingIncrements] 上。整表仍是**唯一权威**,只是不再需要为一条新消息付它的价钱。
* *
* ## WS 连接:全 App 共享一条,而**本类是它唯一的主人** * ## WS 连接:全 App 共享一条,而**本类是它唯一的主人**
* `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条 * `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条
@@ -136,6 +140,17 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
*/ */
private var trailingRefreshJob: Job? = null private var trailingRefreshJob: Job? = null
/**
* 窗口里攒下的「要问增量的会话 id」(W77)。
*
* 为什么是一个**集合**而不是一个 id:节流窗口把同一段时间里的落库广播合并成一次拉取,而那几个
* 会话**未必是同一个**(主会话说一句、另一个会话也说一句)。只记住最后一条的话,前面那个会话的
* 红点就永远不会出现——那正是 W49 修过的同一类病,只是换了个成因。
*
* 只在 [requestSilentRefresh] / [flushPendingIncrements] 两处写;登出时清空(它属于上一个身份)。
*/
private val pendingIncrements = mutableSetOf<String>()
/** /**
* 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。 * 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。
* *
@@ -201,6 +216,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
trailingRefreshJob?.cancel() trailingRefreshJob?.cancel()
trailingRefreshJob = null trailingRefreshJob = null
silentRefreshThrottle = SilentRefreshThrottle() silentRefreshThrottle = SilentRefreshThrottle()
// 攒下的会话 id 同理:它们是上一个身份的会话,重登后那些 id 要么不存在、
// 要么属于别人;留着只会让重登后的第一次窗口拿旧 id 去问增量。
pendingIncrements.clear()
pulledForIdentity = null pulledForIdentity = null
busyTurns = emptyMap() busyTurns = emptyMap()
_state.value = SessionsUiState() _state.value = SessionsUiState()
@@ -316,42 +334,54 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
} }
/** /**
* 节流地静默重拉一次列表 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现, * 节流地静默对齐一次 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
* 必须开一次抽屉才补上」(S6)。 * 必须开一次抽屉才补上」(S6)。
* *
* 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理 * 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理
* 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。 * 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。
* *
* ## 为什么是「重拉」而不是「本地把未读数加一」 * ## 为什么是「问服务端」而不是「本地把未读数加一」
* `message_persisted` 帧**不带 `unreadCount`**:本地没有逐条时间线,自己数出来的数会与编辑 / * `message_persisted` 帧**不带 `unreadCount`**:本端没有逐条时间线,靠帧自己数出来的数会与编辑 /
* 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**, * 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**,
* 只是把「服务端才是唯一真源」这件事提前做一次 —— 走的是**与抽屉打开时那次完全相同**的路径 * 只是把「服务端才是唯一真源」这件事提前做一次。
* ([refresh],含已读游标遮罩),只是不需要用户先开一次抽屉。 *
* ## W77:问的是**那一条会话的增量**,不再是整表
* 曾经的落点是 `refresh(silent = true)`(整表 `GET /sessions`),一次要为**所有**会话付一行预览 +
* 未读计数的价钱,而真正变了的只有一个会话。现在落到 [flushPendingIncrements]:
* `GET /messages?after=<已见最大 createdAt>`,只回那几条新消息。整表仍是唯一权威,但只在
* 「增量给不出可信答案」时才请它出场(回落规则写在 [flushPendingIncrements] 上)。
* *
* ## 三条纪律 * ## 三条纪律
* 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次 * 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次
* 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。 * 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。
* 2. **静默**:`silent = true`,不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。 * 2. **静默**:不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。失败时做什么**完全沿用既有
* 失败时做什么**完全沿用既有行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见; * 行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;以及「网络异常,显示 x 分钟前
* 以及「网络异常,显示 x 分钟前的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败, * 的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,不该比用户自己打开抽屉时的那次
* 不该比用户自己打开抽屉时的那次刷新更吵。 * 刷新更吵。
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。[refresh] 内部唯一会动 * 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。回落那条路([refresh])
* `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回主会话), * 内部唯一会动 `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回
* 那是既有的、正确的收敛,不是重置。 * 主会话),那是既有的、正确的收敛,不是重置。
* *
* @param cause 这次为什么要拉;决定它走不走窗口(见 [SilentRefreshCause])。 * @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) val step = silentRefreshStep(cause, System.currentTimeMillis(), silentRefreshThrottle)
silentRefreshThrottle = step.throttle silentRefreshThrottle = step.throttle
// 没有具体会话时不留 id:那一次整表会把所有会话都对上,攒着的 id 一个也不缺。
if (sessionId != null) pendingIncrements += sessionId
when (step.action) { when (step.action) {
// 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。 // 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。
// (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档 // (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档
// 走到这里时 trailingPending 必为 false,补拉早跑完了。) // 走到这里时 trailingPending 必为 false,补拉早跑完了。)
//
// 撤掉补拉**不等于**可以丢掉它攒下的 id:[pendingIncrements] 是独立的一份账本,
// 这一趟会把它们一并做掉。
SilentRefreshAction.PullNow -> { SilentRefreshAction.PullNow -> {
trailingRefreshJob?.cancel() trailingRefreshJob?.cancel()
trailingRefreshJob = null trailingRefreshJob = null
refresh(silent = true) flushPendingIncrements(fullTable = sessionId == null)
} }
// 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串 // 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串
@@ -365,15 +395,75 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
trailingRefreshJob = viewModelScope.launch { trailingRefreshJob = viewModelScope.launch {
delay(step.delayMillis) delay(step.delayMillis)
silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis()) silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis())
refresh(silent = true) flushPendingIncrements(fullTable = false)
} }
} }
// 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。 // 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。
// (但这一帧的会话 id 已经在上面记进 [pendingIncrements] 了 —— 补拉不会漏掉它。)
SilentRefreshAction.Ignore -> Unit 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.ActiveSession] ⇒ 就地清零 + 推进游标(这段一个字没改);
* - [PersistedRoute.InactiveSession] ⇒ **节流地静默重拉**([requestSilentRefresh]), * - [PersistedRoute.InactiveSession] ⇒ **节流地问一次这条会话的消息增量**([requestSilentRefresh]
* 用服务端下发的 `unreadCount` 说话。这正是 W49 修的那一幕:此前这一档直接 `return`,于是 * 带上 `sessionId` → [flushPendingIncrements] → `MessageRepository.loadIncrement`),未读由
* 「App 停在主会话、次级会话来了新消息」没有任何人会去发现,红点要等用户打开一次抽屉(那次 * 「增量里越过已读游标的条数」说话(W77;W49 那一档原本整表重拉 `GET /sessions`)。
* 静默重拉)才补上。 * 这正是 W49 修的那一幕:此前这一档直接 `return`,于是「App 停在主会话、次级会话来了新消息」
* 没有任何人会去发现,红点要等用户打开一次抽屉才补上。
* *
* 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做), * 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做),
* 而「重拉一次列表」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条 * 而「问一次增量」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
* 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的 * 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的
* 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。 * 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。
*/ */
private fun onMessagePersisted(event: WsEvent.MessagePersisted) { private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
when (persistedRoute(sessionId, _state.value.activeSessionId)) { when (persistedRoute(sessionId, _state.value.activeSessionId)) {
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表, // 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地问一次**这条会话**的消息
// 红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。 // 增量,红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
PersistedRoute.InactiveSession -> PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage) requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。 // 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。
// 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时, // 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时,
@@ -634,9 +725,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 判据也共用 [persistedRoute](只是会话 id 由这一帧自己钉:它不带 `sessionId`)。 * 判据也共用 [persistedRoute](只是会话 id 由这一帧自己钉:它不带 `sessionId`)。
* *
* 为什么必须在这里补一句:服务端的主动推送 Web 路由只发这一帧,**不补** `message_persisted` * 为什么必须在这里补一句:服务端的主动推送 Web 路由只发这一帧,**不补** `message_persisted`
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流静默重拉」根本不会被 * (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流刷新」根本不会被
* 触发——抽屉里主会话的红点就停在她开口之前,直到用户自己开一次抽屉。这里复用**同一条**路 * 触发——抽屉里主会话的红点就停在她开口之前,直到用户自己开一次抽屉。这里复用**同一条**路
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage]), * ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage],会话 id 取主会话),
* 不新写取数路径,也不动节流判据本身。 * 不新写取数路径,也不动节流判据本身。
* *
* `id` 缺席 = 服务端那一刻**没有落库行** ⇒ 历史里没有这一行,未读面上没有任何东西要更新 * `id` 缺席 = 服务端那一刻**没有落库行** ⇒ 历史里没有这一行,未读面上没有任何东西要更新
@@ -647,7 +738,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
val sessionId = SessionSummaryDto.MAIN_SESSION_ID val sessionId = SessionSummaryDto.MAIN_SESSION_ID
when (persistedRoute(sessionId, _state.value.activeSessionId)) { when (persistedRoute(sessionId, _state.value.activeSessionId)) {
PersistedRoute.InactiveSession -> PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage) requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是主会话:她这句话已经显示在聊天流里(ChatViewModel 那侧并入)⇒ 与 // 屏幕上就是主会话:她这句话已经显示在聊天流里(ChatViewModel 那侧并入)⇒ 与
// message_persisted 的同一档一样「看到即已读」,免得抽屉为她刚说的话亮一个红点。 // 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](与生产同一份配置),所以这里过不了的形状线上也过不了。 * 解码器用 [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`(不是 `{}`,也不是别的哨兵); * 2. **缺席与 `null` 是同一件事**:非语音消息整键缺席 ⇒ `null`(不是 `{}`,也不是别的哨兵);
* 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉); * 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉);
* 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」); * 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」);
* 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢); * 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢);
* `fallbackReason` 反过来**不是**闭集——本端原样留着、不做任何映射;
* 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。 * 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。
*/ */
class VoiceDtoTest { class VoiceDtoTest {
@@ -67,6 +69,8 @@ class VoiceDtoTest {
// url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。 // url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。
assertEquals("/api/v1/media/$name", voice.url) assertEquals("/api/v1/media/$name", voice.url)
assertFalse(voice.url!!.startsWith("http")) assertFalse(voice.url!!.startsWith("http"))
// 转写成功的行不该有降级原因(服务端 `omitempty` 不下发)⇒ 解成 null,显示层据此不加小标。
assertNull(voice.fallbackReason)
} }
@Test @Test
@@ -184,6 +188,8 @@ class VoiceDtoTest {
@Test @Test
fun `encoding a full voice keeps the seven key names`() { fun `encoding a full voice keeps the seven key names`() {
// 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。 // 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。
// 这里没传 `fallbackReason`(非 failed 行本来就不该有)⇒ `explicitNulls = false` 不写这个键,
// 键集仍是七个。
val encoded = json.encodeToJsonElement( val encoded = json.encodeToJsonElement(
VoiceDto.serializer(), VoiceDto.serializer(),
VoiceDto( 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 @Test
fun `encoding a voice with no copy omits the nullable keys`() { fun `encoding a voice with no copy omits the nullable keys`() {
// `explicitNulls = false`(与后端 `omitempty` 同一口径):无副本时那四个键**不写出来**, // `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 @Test
fun `a later page carrying voice upgrades the earlier copy of the same message`() { fun `a later copy carrying voice wins over the earlier copy of the same message`() {
// 契约 v0.79(api-client §4.4 覆盖率):`voice` **只在四个 REST 读取面**填充, // 同一条消息会从**多路**到本端:落库帧、握手 `history` 回放、REST 分页、编辑帧。
// `message_persisted` / 握手 `history` 回放**不带**它(入站语音的 voice 列是落库之后回填的)。 // 按 id 合并时「新到的那一份胜出」是这几路唯一的收口规则——不然后到的那份白到。
// ⇒ 设计上「实时到达的语音先按普通文本显示,等下一次拉取自动升级成语音气泡」, // 契约 v0.97 之后 `voice` **每一条路都带**(后端 M44 `fb55769`:落库顺序 INSERT →
// 而这条设计**完全依赖**下面这条既有规则:按 id 合并时**新到的那一份胜出**。 // 写 voice 列 → notify),所以这条规则**不再是**「语音升级的唯一通路」;
// 这条用例存在的意义是:谁哪天把「incoming 胜出」改成「保留旧的」,语音气泡就永远不会出现。 // 但它照旧必需:谁哪天把「incoming 胜出」改成「保留旧的」,编辑过的正文与任何后补字段
// 都会永远不生效。
val withoutVoice = msg("m1", 100, content = "明天几点出发") val withoutVoice = msg("m1", 100, content = "明天几点出发")
val withVoice = withoutVoice.copy( val withVoice = withoutVoice.copy(
voice = VoiceDto( 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))
}
}
@@ -2,6 +2,7 @@ package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test 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,五条口径逐条钉在这里: * 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,六条口径逐条钉在这里:
* 1. **🎤 判据 = `voice` 键存在**(`isVoiceMessage`)——**绝不**从正文里认媒体类型 * 1. **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**(`isVoiceMessage`)——**绝不**从正文里
* (正文里可能出现用户自己打出的 `[语音]` 字样); * 认媒体类型(正文里可能出现用户自己打出的 `[语音]` 字样),也绝不把 `failed` 行画成语音形态
* (那一行没有可播的声音,见 `isPlayableVoice` / `hasVoice`);
* 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全); * 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全);
* 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充; * 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充;
* 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示; * 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示;
* 5. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**, * 5. **`failed` 行多一行灰色小标**(`voiceFallbackNote`):固定前半句 + 服务端 `fallbackReason` 原样;
* 6. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**,
* 绝不退回「自己拼一条 `/api/v1/media/<name>`」。 * 绝不退回「自己拼一条 `/api/v1/media/<name>`」。
*/ */
class VoiceMessageTest { class VoiceMessageTest {
@@ -50,6 +53,7 @@ class VoiceMessageTest {
mime: String? = "audio/ogg", mime: String? = "audio/ogg",
size: Long? = 12_345, size: Long? = 12_345,
durationMs: Int? = 3_200, durationMs: Int? = 3_200,
fallbackReason: String? = null,
url: String? = "/api/v1/media/$copyName", url: String? = "/api/v1/media/$copyName",
) = VoiceDto( ) = VoiceDto(
source = source, source = source,
@@ -58,19 +62,22 @@ class VoiceMessageTest {
mime = mime, mime = mime,
size = size, size = size,
durationMs = durationMs, durationMs = durationMs,
fallbackReason = fallbackReason,
url = url, url = url,
) )
// —— 1. 判据 = 键存在 —— // —— 1. 「来自一条语音」的判据 = 键存在(`hasVoice`)——
@Test @Test
fun `the voice key is the only criterion`() { fun `the voice key is the only criterion for coming from a voice`() {
assertTrue(isVoiceMessage(message(voice = voice()))) assertTrue(hasVoice(message(voice = voice())))
assertFalse(isVoiceMessage(message(content = "在的"))) assertFalse(hasVoice(message(content = "在的")))
// 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发, // 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发,
// 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。 // 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。
assertFalse(isVoiceMessage(message(content = "[语音]"))) assertFalse(hasVoice(message(content = "[语音]")))
assertFalse(isVoiceMessage(message(content = "语音"))) assertFalse(hasVoice(message(content = "语音")))
assertTrue(hasVoice(message(content = "[语音]", voice = voice())))
// 「要不要画语音形态」是**另一个**问题(见下面第 4b 节),但键在是它的前提。
assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice()))) assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice())))
} }
@@ -163,6 +170,83 @@ class VoiceMessageTest {
assertNull(voiceNote(VoiceStatus.EMPTY, "其实有字")) 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. 相对路径接到当前服务器地址 —— // —— 5. 相对路径接到当前服务器地址 ——
@Test @Test
@@ -244,6 +328,14 @@ class VoiceMessageTest {
// ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。 // ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。
val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null)) val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null))
assertFalse(isContentlessUserMessage(empty)) 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 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。 // 对照:同样空的正文,没有 voice 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。
assertTrue(isContentlessUserMessage(message(content = ""))) assertTrue(isContentlessUserMessage(message(content = "")))
} }
@@ -0,0 +1,178 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.parse
import java.io.IOException
/**
* 「朗读」这条路上本端要说的那几句话与纯判据([ttsTextOf] / [ttsFailureOf] / [ttsClipIdOf])。
*
* 这个文件全是纯函数,不碰网络也不碰 Android:取字节在 `SpeechClientTest`(真 HTTP),
* 图标与播放接线在界面层。
*
* ## 它钉住的四件事
* 1. **念什么**:只念正文(`body`)——抬头(`head`)像字幕、旁白(`tail`)是舞台指示,都不念;
* 一条消息切成多个气泡时按 `\n` 拼,空正文不产生空行。
* 2. **能不能念**:「这条消息有没有可念的正文」是唯一判据,`ChatViewModel.speak`(发不发请求)与
* `MessageBubble`(画不画图标)用的必须是同一个答案;`mode` 不是 `chat` 时念的是**原文**,
* 因为那时气泡里放的就是原文、一个 markup 都不解析。
* 3. **失败算哪一档**:只有服务端明确回了「配置缺失」类 `code` 才进
* [TtsFailure.Unconfigured](重试永远不会成功,文案要说清缺什么、且不提重试),
* 其余一切(网络、解析、401、`voice_unavailable` / `tts_failed` / `tts_empty`)都是
* [TtsFailure.Soft]。
* 4. **播放键**:`tts:<messageId>`,与语音副本的键(就是 `messageId`)分得开。
*/
class VoiceSpeakTest {
// ---- 念什么:气泡 → 文本 ------------------------------------------------
@Test
fun `bubbles are joined by a newline so the pause lands between paragraphs`() {
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p>再见</p>")))
}
@Test
fun `an empty message has nothing to read out loud`() {
assertNull(ttsTextOf(parse("")))
assertNull(ttsTextOf(emptyList()))
}
@Test
fun `narration alone is stage direction and is not read out loud`() {
// 旁白整段进 head(首部依附),正文为空 ⇒ 没有可念的字。
assertNull(ttsTextOf(parse("<p><narration>她笑了笑</narration></p>")))
}
@Test
fun `the speaker head is skipped and only the body is read`() {
val bubbles = parse("<p><narration>她笑了笑</narration>你好</p>")
assertEquals("你好", ttsTextOf(bubbles))
}
@Test
fun `a blank bubble does not become a blank line`() {
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p> </p><p>再见</p>")))
}
// ---- 能不能念:一条消息 → 文本 ------------------------------------------
@Test
fun `a chat message is read after the markup is stripped`() {
assertEquals("你好\n再见", ttsTextOf(message(content = "<p>你好</p><p>再见</p>", mode = "chat")))
}
@Test
fun `a message without a mode counts as a chat message`() {
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = null)))
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = " ")))
}
@Test
fun `a non chat mode is read verbatim because the bubble shows the raw text`() {
// 非 chat 的 mode 下气泡里放的就是原文(MessageBubble 的 isChat 判据),
// 所以朗读念的也必须是那同一段字,一个标签都不许吞。
assertEquals("<p>你好</p>", ttsTextOf(message(content = "<p>你好</p>", mode = "roleplay")))
assertEquals("你好", ttsTextOf(message(content = " 你好 ", mode = "roleplay")))
}
@Test
fun `a message with nothing to say answers null in both modes`() {
assertNull(ttsTextOf(message(content = "", mode = "chat")))
assertNull(ttsTextOf(message(content = " ", mode = "roleplay")))
assertNull(ttsTextOf(message(content = "<p><narration>她笑了笑</narration></p>", mode = "chat")))
}
// ---- 失败算哪一档 ------------------------------------------------------
@Test
fun `a persona without a voice mapping says so and is not worth retrying`() {
val failure = ttsFailureOf(serverError(TTS_CODE_PERSONA_UNMAPPED))
assertEquals(TtsFailure.Unconfigured(TTS_CODE_PERSONA_UNMAPPED), failure)
assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, failure.message)
assertFalse("配置缺失类重试永远不会成功", failure.retryable)
}
@Test
fun `a session without any character says so instead of blaming the network`() {
val failure = ttsFailureOf(serverError(TTS_CODE_NO_CHARACTER))
assertEquals(TTS_NO_CHARACTER_MESSAGE, failure.message)
assertFalse(failure.retryable)
}
@Test
fun `an unrecognized code is not guessed into the configuration bucket`() {
// 本端认不出这个 code,就**不知道**它是配置缺失还是临时故障 ⇒ 不猜,落柔和档:
// 判成「配置没配好」等于对用户说了一句本端没有依据的话。
val failure = ttsFailureOf(serverError("tts_something_new"))
assertEquals(TtsFailure.Soft, failure)
assertTrue(failure.retryable)
}
@Test
fun `a configuration failure built from an unknown code still explains itself`() {
// 兜底分支:万一将来有调用点直接构造了一个本端还不认识的配置缺失码,
// 文案仍是「缺发音人」而不是「网络不好」,且照样不提重试。
val failure = TtsFailure.Unconfigured("tts_something_new")
assertEquals(TTS_UNCONFIGURED_MESSAGE, failure.message)
assertFalse(failure.retryable)
}
@Test
fun `a server that is merely unavailable right now stays in the soft bucket`() {
listOf("voice_unavailable", "tts_failed", "tts_empty").forEach { code ->
val failure = ttsFailureOf(serverError(code))
assertEquals("$code 该是柔和档", TtsFailure.Soft, failure)
assertEquals(TTS_SOFT_FAILURE_MESSAGE, failure.message)
assertTrue("$code 过一会儿再点是有意义的", failure.retryable)
}
}
@Test
fun `an expired token is soft because renewal can still save it`() {
val failure = ttsFailureOf(CyreneException(CyreneError.Unauthorized(code = "", message = "")))
assertEquals(TtsFailure.Soft, failure)
}
@Test
fun `a network or parse failure is soft and does not pretend to know why`() {
assertEquals(TtsFailure.Soft, ttsFailureOf(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))))
assertEquals(TtsFailure.Soft, ttsFailureOf(IOException("boom")))
// 非 CyreneException 一律柔和档:本端确实不知道断在哪一段。
assertEquals(TtsFailure.Soft, ttsFailureOf(IllegalStateException("boom")))
}
// ---- 播放键 ------------------------------------------------------------
@Test
fun `the clip key is namespaced so it never collides with a voice copy`() {
assertEquals("tts:m-1", ttsClipIdOf("m-1"))
assertFalse("朗读与语音副本的键必须分得开", ttsClipIdOf("m-1") == "m-1")
}
private fun serverError(code: String) = CyreneException(
CyreneError.Server(code = code, message = "服务端说明", httpStatus = 500),
)
private fun message(content: String, mode: String?) = HistoryMessageDto(
id = "m-1",
sessionId = "web",
role = "assistant",
content = content,
mode = mode,
createdAt = 0L,
)
}
@@ -0,0 +1,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}`。 - `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。 `raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。 - `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 的文件。 `Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的 挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。 `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] digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
return "<redacted sha256:{}>".format(digest) 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: class JsonlLog:
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。""" """一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
@@ -288,11 +319,17 @@ class JsonlLog:
def __init__(self, path: str): def __init__(self, path: str):
self.path = path self.path = path
self.lock = threading.Lock() 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: def append(self, record: dict) -> None:
line = json.dumps(record, ensure_ascii=False) line = json.dumps(record, ensure_ascii=False)
with self.lock: 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: with open(self.path, "a", encoding="utf-8", newline="\n") as handle:
handle.write(line + "\n") handle.write(line + "\n")
@@ -599,11 +636,15 @@ class Store:
with self.lock: with self.lock:
self.downstream_count += 1 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, `Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
也没有理由把它原文写进一个可能被 commit 的文件。 也没有理由把它原文写进一个可能被 commit 的文件。
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
`out/http.jsonl` 里读得出来,不用再去猜。
""" """
recorded_headers = {} recorded_headers = {}
for key, value in headers.items(): for key, value in headers.items():
@@ -623,6 +664,9 @@ class Store:
"query": query, "query": query,
"headers": recorded_headers, "headers": recorded_headers,
} }
summary = summarize_body(body)
if summary is not None:
record["body"] = summary
self.http_log.append(record) self.http_log.append(record)
with self.lock: with self.lock:
self.http_count += 1 self.http_count += 1
@@ -1046,17 +1090,38 @@ class Handler(BaseHTTPRequestHandler):
query = parse_qs(parts.query, keep_blank_values=True) query = parse_qs(parts.query, keep_blank_values=True)
return parts.path, query 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): 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") length = self.headers.get("Content-Length")
if not length: if not length:
return b"" return self._body_cache
try: try:
size = int(length) size = int(length)
except ValueError: except ValueError:
return b"" return self._body_cache
if size <= 0: if size <= 0:
return b"" return self._body_cache
return self.rfile.read(size) or b"" self._body_cache = self.rfile.read(size) or b""
return self._body_cache
def _parse_json_body(self): def _parse_json_body(self):
raw = self._read_body() raw = self._read_body()
@@ -1103,7 +1168,15 @@ class Handler(BaseHTTPRequestHandler):
def _handle(self, method: str): def _handle(self, method: str):
path, query = self._split() 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: try:
if path == "/ws/chat": if path == "/ws/chat":
self._handle_ws(method, query) self._handle_ws(method, query)