62 Commits
Author SHA1 Message Date
AskaEth 31ab1ff004 Merge branch 'feat/w80-voice-failed-status-judgment' into android 2026-10-27 21:33:01 +08:00
AskaEth 576dd58a6b fix(w80): 让「画不画语音形态」只剩「键在且 status 不是 failed」两条判据
后端 M57-M59 预告(devdocs/02 第 1255/1260 行我们的答复):`voice` 键在不再等于
「这是一段能播的声音」。合成降级 / 入站转写失败的行 `status = "failed"`,正文是一句话,
`fallbackReason` 里给出降级原因——按老判据会被画成一个点下去必然播不了的语音气泡。

判据拆成两问,各自落在一处(domain/chat/VoiceMessage.kt):
- `hasVoice` = `voice` 键存在 —— 「这条**来自**一段声音」;
- `isPlayableVoice` = `status != "failed"` —— 认不出的 status 一律当可播(fail-open:
  多画一枚语音形态最坏是点了播不出来,有既有失败文案兜着;反方向会把真有副本的语音
  静默吞成纯文字,用户再也点不到);
- `isVoiceMessage` = 两者相与 —— 「画不画语音形态」。

failed 行改画「正文 + 一行灰色小标」:`voiceFallbackNote` 给固定前半句 + 服务端
`fallbackReason` **原样**(不猜闭集、不做映射——取值集合后端尚未定稿;缺席/空白时退化成
固定那一句,不白屏)。新组件 `VoiceFallbackNote` 用 footnote2 +
onSurfaceVariantSummary,与语音形态旁边那几句脚注同一档。

`isContentlessUserMessage` 挂 `hasVoice` 而**不**是 `isVoiceMessage`:failed 行虽没有语音
形态,但有正文和那行小标,同样不是「什么都没有」——判错了屏幕上会多一句假的
「内容没有被传过来」。`messageTimeCarrier` 同理按 `hasVoice` 挑落脚点。

DTO 上补第八个键 `fallbackReason`(只在 failed 时有,`explicitNulls = false` 时不写出),
所以转写成功的行键集仍是原来七个。

验证:`assembleDebug` + `testDebugUnitTest` 全绿,142 个测试文件 / 1766 个用例 / 0 失败
(基线 1755,新增 11 条;VoiceMessageTest 20、VoiceDtoTest 13、WsEventPersistedVoiceTest 7)。
2026-10-27 21:22:28 +08:00
AskaEth a9c940d39f Merge branch 'feat/w79-share-declare-wiring' into android 2026-10-27 00:30:44 +08:00
AskaEth f6eb93027d fix(w79): 让「分享零声明 / 批量只第一条声明」只剩一个判据
接 W76 的 finding:M2 把口径提成纯函数 ShareRules.declaresSendPreference 并加了穷举测试,
但接线那一行落在 M2 scope 外,于是判据仍是两处、每处各半个——分享路径写死
applySendPreference = false,批量那条写 applySendPreference && index == 0,两处都单测不到,
而纯函数没有调用方(dead code)。

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

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

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

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

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

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

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

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

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

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

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

仍未验:真机出声;请求在飞时没有「取消在飞请求」;(sessionId, text) 这份缓存对服务端
换发音人仍无失效手段(rev-w75 已同意这半边可接受)。
2026-10-26 23:52:23 +08:00
AskaEth 436c0f06f6 Merge branch 'feat/w76-multiimage-share-batch-delivery' into android 2026-10-26 23:40:28 +08:00
AskaEth 4a9e22ea77 Merge branch 'feat/w77-unread-incremental-after-cursor' into android 2026-10-26 23:40:17 +08:00
AskaEth 6d158ca9bd fix(w77): 满页之后的「不满页」也说不清尾巴,complete 收紧为「从未拿过满页」
r2 复审指出 `loadIncrement` 的第三档(`!hasMore` ⇒ `complete = true`)是空页档同一个
洞的另一个出口:它只看「本页满不满」,不看整个追尾里有没有拿过满页。

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

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

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

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

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

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

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

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

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

验证:
1. 重启假后端后,在同一条 keep-alive 连接上连发 3 个**不同 body** 的
   `POST /api/v1/chat`,三发全 202,`out/http.jsonl` 记下三个不同 body(同一 client port)。
2. 完整设备重放(DocumentsUI 选 3 张图 → 分享 → Cyrene → 发送)三发三 202,
   3 条独立消息落库。
2026-10-26 23:27:04 +08:00
AskaEth 504c500e20 W75 她的消息朗读 —— /audio/speech 只走 owner Bearer,字节嗅探 WAV,缓存落 media/
后端回执三十八 §3 点名「独立真机 Bearer 样例待安卓首次接入时顺手确证」,本批次就是
这次接入:在她的消息气泡外侧加一枚「朗读」图标,点按 → 剥标签取纯文本 → POST
/api/v1/audio/speech → 播放 WAV。只做手动点按,不做自动朗读 / 设置页开关 / 实时流。

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

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

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

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

## 落盘

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

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

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

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

## 验证

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

未验:真机出声。已知取舍——请求在飞时图标 clickable(enabled = !speakPending) 点不动,
没有做「取消在飞请求」。
2026-10-26 23:25:42 +08:00
AskaEth db53de1a7e W77 增量追尾:满页之后的空页不再当作尾巴,未读基数的边界如实登记
review 判了两条 P2,都只动 W77 这一摊。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 新增的钉子

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

## 验证

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

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

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

验收:assembleDebug 通过;testDebugUnitTest 全量 133 suites / 1670 tests / 0 failures
(基线 129 / 1636,新增 4 个 suite / 34 个用例)。
2026-10-26 19:00:16 +08:00
AskaEth e0e657d5ee chore: devdocs/ 停止入库 —— 主人 2026-10-07 明确指令「整个文件夹禁止进入远程仓库」
- .gitignore 新增 ;本地文件一个没删,只是不再被 git 跟踪
- 此前它曾入库(2026-10-04 授权),历史里的旧副本不重写、也不再新增
- 本地 05 里那条「devdocs 本身是入库的」纪律已就地改正,避免后来人再 git add 它
2026-10-07 21:25:11 +08:00
AskaEth c1ebd208b1 merge: W72 —— 纯附件/图片/语音形态也显示时间(判据抽成 messageTimeCarrier;原本只挂在「有正文的气泡」上,纯附件消息没有正文气泡) 2026-10-07 19:18:28 +08:00
AskaEth a94e310cd7 W72 纯附件消息也有气泡外侧时间
时间原本挂在「这条消息**最后一个有正文的气泡**」外侧(W69),而纯附件消息(`documents`
或 `image` 非空、`content` 为空)解析出来的那个占位气泡 `body` 也是空,`Bubble` 见空
`body` 什么都不画 ⇒ 那条时间**整条没被画出来**(主人 2026-10-07 验收 W69 时发现的缺口)。

- 判据抽成纯函数 `messageTimeCarrier(message, bubbles)`:先找最后一个有正文的气泡(W69
  原本那一档,一个字都不改);一个都没有时按「谁最后画」往回退(语音形态 → 图片 →
  附件芯片排),落点用 `MessageTimeCarrier` 表示。真的空气泡(`isContentlessUserMessage`)
  仍由 `ContentlessNote` 那条路自己画时间,与 W69 一致,未改。
- 附件芯片排 / 图片 / 语音形态三块改为经 `BubbleWithTime` 渲染:**不是落点时 `time = null`,
  连 `Box` 都不建** ⇒ 与 W69 之前逐像素相同。贴哪一侧、宽度预扣(时间不参与 weight)、
  底对齐全部复用 W69 那一个组件,没有第二份布局逻辑。
- 新增 `MessageTimeCarrierTest` 11 条:纯附件 / 纯图片 / 无转写语音 / 角色无关 /
  多块时的优先序 / 有正文时 W69 口径不变 / 真空消息返回 null;`MessageTimeLabelTest`
  16 条一条未动。
- 假后端补夹具:`/__control/messages` 的 `text=`(**空串** = 这条没有正文)与 `documents=`
  (`extracted` / `listed` / `unreadable`)。此前「纯附件消息」在本机**根本造不出来**
  ——工具不实现 upload 摄取链路,`PATCH` 又要求 `content` 非空——设备验收因此无从做起。
  README 补 §8.3 与端点表。

验收:`assembleDebug` 通过;`testDebugUnitTest` **129 suites / 1636 tests / 0 failures /
0 errors**(基线 128 / 1625)。
2026-10-07 19:16:25 +08:00
AskaEth f1e894e71f merge: W71 —— 按契约 v0.89 最终版返工(u 加回白名单成六项;br 在 code/注记作用域内不再分隔,只有普通文本层分隔) 2026-10-07 19:11:37 +08:00
AskaEth 1055f03b46 merge: W70 —— 附件预览下方加元数据小字(文本:行数/服务端 chars/截断标记;图片:格式/分辨率/大小/EXIF 拍摄日期;拿不到的项一律省略) 2026-10-07 19:10:51 +08:00
AskaEth 9da25b0895 feat(W71): 按契约 v0.89 最终版返工 —— u 加回白名单(六项)+ <br> 只在普通文本层分隔
后端 `回执三十三`(v0.89 收口)更正了 W68/W69 的两处实现,本端照改。

一、`u` **加回**白名单(最终**六项**)
   `回执三十三 §一` 明说:`回执三十一` 那句「收窄到五项」**是他们的疏漏** —— 主人随后裁定
   下划线保留,后端已把 `u` 加回契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。
   最终白名单 = `b`/`strong`/`i`/`em`/`code`/**`u`**,`s` 仍不在内(逐字原样)。
   1. `InlineMarkup`:`PAIRED_TAG_NAMES` 加回 `"u"`、`InlineStyle` 加回 `Underline`、
      `inlineStyleOf` 落到它。**只加回 `Underline`**:`s` 依旧不做(枚举里没有 `Strikethrough`,
      不留走不到的渲染分支)。加回词表同时让流式扣留侧重新认 `<u` / `<U`(两边同一份判据)。
   2. `MessageBubble.inlineBody`:`textDecoration = TextDecoration.Underline`(重新引入
      `androidx.compose.ui.text.style.TextDecoration`;仍然只是 `AnnotatedString` + `SpanStyle`)。
   3. 渲染仍是「样式只给有的那几项」,非 `<u>` 的片段逐像素未变。

二、`<br>` **不再「始终分隔」**:只有**普通文本层**的 `<br>` 才分隔(`回执三十三 §三②`),
   `<code>` 与 `<action>` / `<narration>` **内部**的不分隔。后端给的两条理由我们同样成立:
   ① `<code>` 是**成对**元素,从中间切开必然两半不平衡 ⇒ 按硬口径「不平衡 ⇒ 不渲染」整段降级为
   字面量(用户看到裸标签);② `<action>她<br>点点头</action>` 切开就是「她」「点点头」两条**聊天
   消息** = 把注记正文当正文发了出去(信息泄露级)。
   1. 新增 `protectedSpans`(保护区扫描)+ `appendRegion` 里跳过落在区间内的 `<br>`:
      一趟线性扫描、**两个独立状态** —— `code` 一支用行内层的判据(`inlineTagEnd` +
      `openInlineTagName`/`closeInlineTagName`:大小写不敏感、可带属性),角色注记一支用 `ROLE_RE`
      的判据(只认小写、不带属性、找**同名**闭合)。两支各算各的,因为那本来就是两个消费侧各自的
      尺子(行内层在 `<code>` 里只认自己的闭合;`ROLE_RE` 完全不看 `<code>`)。区间按**起点**升序,
      切分处用游标 O(1) 判定 ⇒ 整体仍然线性(新增一条 20k 作用域的复杂度预算用例)。为此把
      行内层的 `inlineTagEnd` 与 `CODE` 提为 `internal`:**标签边界与 `code` 这个名字只该有一把尺子**。
   2. 作用域里的 `<br>` 算什么(**我们定的口径,写进 KDoc**):**普通字符、逐字原样** —— 不换行、
      也不消掉。`<code>` 是它既有口径(里面连白名单标签都是内容,`<code><b>甲</b></code>` 显示字面
      `<b>甲</b>`,由 `codeContentIsNotParsed` 钉着);注记那侧则是**强迫**的:旁白那一行画的正是
      `parts` 的逐字原文(编辑框/搜索/通知同源,`parts` 是红线),换 `\n` 或删掉就是静默改用户内容。
      两个作用域取同一条规矩,免得多出一种读法。「改成换行」因此被否:要么在这里改掉片段文本
      (`parts` 当场不再是原文),要么在行内层给 `<br>` 单开一条规则(W68 刚从那里整体删掉)。
   3. **未闭合的作用域不算作用域**(里面的 `<br>` 照旧分隔):判据只看「成对」,而消费侧本来就不认
      未闭合的那个(行内层整段降级、`ROLE_RE` 配不出旁白);且「未闭合 ⇒ 保护到结尾」会让一个漏掉
      `</code>` 的笔误把后面**所有** `<br>` 一并吞掉——一个畸形标签的爆炸半径不该放大到整段。
      两种读法都不渲染、都不丢字,差别只在气泡怎么切,我们选与消费侧一致的那种。
   4. 其余一律未动:`<br>` 大小写不敏感(两端口径一致,`回执三十三 §三①`)、`<br>` 出现在别的标签
      属性里照样分隔(`§三③` 登记不修)、`<p>` 语义、星号(旁白/动作)语义、`stripLooseTags` /
      `ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE`、W63 的线性特性、`parts` 逐字原文。

测试:`InlineMarkupTest` 41→43 条(`<u>` 六项之一渲染下划线且大小写/属性/叠加/未闭合各断言、
`s` 每种形态都仍逐字字面且枚举里没有 Strikethrough、`<code>` 里那个 `<br>` 是内容);
`ChatContentParserTest` 71→77 条:W69 那条「`<code>`/`<action>` 内也分隔」换成 7 条新口径用例
(code 作用域存活且无裸标签、注记不产生气泡且 `parts` 逐字、只有 code/注记算保护区、嵌套、
未闭合、两支各算各的、流式扣留残缺 `<br`),另加一条保护区扫描的复杂度预算用例;
流式一致性用例的样本补上 `<u>` 与保护区两形状。
其余既有断言(含 W68 那批 `<br>` 用例、复杂度预算)一条都没放松。
验收:`assembleDebug` 通过(app-debug.apk 47 330 538 B);`testDebugUnitTest` 全量
**127 suites / 1589 tests / 0 failures / 0 errors**(基线 1580,+9 条本次新增)。
2026-10-07 19:10:42 +08:00
AskaEth e962489520 feat(preview): 预览内容下方加一行格式元数据(行数 / 编码 / 分辨率 / EXIF 拍摄日期)
主人要的是「在预览界面下方拿小字放一点简要的格式元数据」。组装全收进一个纯函数
(`PreviewMetadata`,36 档穷举单测),两个预览弹层只负责取证据 + 画一行
(`footnote2` + `onSurfaceVariantSummary`,与项目里 footer / 角标同一套)。

- **文本 / Markdown**:行数(从正文重算;本端被截断时报「显示 N 行 / 共 M 行」,只说
  「显示 N 行」会让人以为文件就这么长)· 字符数(服务端 `chars` 在时报它并写明
  「服务端注入口径」,否则报本端码点数并写明「本端预览内容」——两个数不是一回事)·
  `UTF-8(本端解码口径)`(**只敢写本端口径**:客户端拿不到文件的真实编码,裸写一个
  UTF-8 等于替文件下结论)· 服务端 `truncated` 时「正文被截断(进上下文的部分)」。
- **图片**:格式(复用 `ImageOpenTypes.openType`,退化成「图片通配」时**整项不报**)·
  分辨率(`BitmapFactory.Options(inJustDecodeBounds = true)`,不整图解码)· 体积 ·
  「拍摄于 …」(`android.media.ExifInterface(InputStream)`,系统 API,**不引新依赖**;
  日期严格校验,`0000:00:00 00:00:00` 这类占位符判非法 ⇒ 整项缺席)。
- **非文本(PDF 这类)**:只有服务端字节嗅探所得的 mime。体积 / 服务端状态 / 「读不到」的
  原因原话各自已经在芯片行与正文区那句人话里,**不在这里重复**——宁可少一项,不要一句废话。
- **拿不到的项一律缺席**:不留占位符、不写「未知」、不写 `--`;一项证据都没有就整行不画。

`devtools/fake-backend` 补一个 `image_file` 灌图参数:服务端库里没有任何带 `image` 键的
历史消息可灌,而相册选择器会把选中项**转码**、把 EXIF 丢掉(实测:41 KB 带 EXIF 的 JPEG
经它拿到手变成 23 KB 且没有 APP1),没有它「拍摄日期」这一项在设备上根本验不到。

验收:`assembleDebug` 通过;`testDebugUnitTest` **127 suites / 1600 tests / 0 failures /
0 errors**(基线 126 / 1564,新增 36 条);真机(emulator-5554, API 37)实测:文本那条看到
「5 行 · 70 字(本端预览内容) · UTF-8(本端解码口径)」,图片那条看到
「image/jpeg · 1280 × 720 · 23.0 KB」(该源图不含 EXIF,所以「拍摄于」这一项按设计缺席)。
2026-10-07 19:07:00 +08:00
AskaEth cf00af7b42 merge: W69 —— 消息气泡外侧显示时间(她右侧/主人左侧;今天 HH:mm、昨天、今年内 M月D日、跨年 YYYY年M月D日) 2026-10-07 18:58:01 +08:00
AskaEth 7b39994715 feat(W69): 气泡外侧显示消息时间 —— 主人贴左 / 昔涟贴右的灰色小字(今天 HH:mm、昨天、今年 M月D日 HH:mm、跨年 YYYY年M月D日)
主人:「用户消息气泡左侧和他的消息气泡右侧还一点点留空的空间 能在这两份区域放一个灰色小字
显示消息发送时间吗?跨天和恒久远的消息也要标明简单日期。」——时间放在气泡**外侧那块空白**里,
不进气泡内部,也不进下方那行角标。

一、文案是纯函数 `messageTimeLabel(createdAt, nowMillis, zone)`
   四档按**本地日历日**分(不是「相差不到 24 小时」):今天 `14:30` / 昨天 `昨天 09:05` /
   今年内更早 `3月7日 09:05`(月日不补零、时分补零)/ 跨年 `2025年12月31日`(**不带时分**:
   一年前的消息精确到分没有意义)。用 `java.time` + `Locale.ROOT`,无新依赖、无新表驱动项。

二、两条口径(都写进 KDoc 里)
   1. **未来时间走「今天」那一档**(判据写成 `!day.isBefore(today)`):服务端时钟略快 / 本机略慢
      时出现的时间戳没有别的语义,只显示 `HH:mm`;不造「明天」这个词,也不摆一个用户从没见过的
      假日期。整整偏一天 / 一年也一起吃下。
   2. **`createdAt <= 0` ⇒ 一个字都不画**(返回 `null`):0 / 负数只可能来自「这个键根本没传过来」
      或解析事故,画成 `1970年1月1日` / `--:--` 都是替服务端编一个它没说过的答案。
      本地降级气泡(`proactive-local:`)**有**真时间(`proactiveIngestFor` 缺席时补 `nowMillis`)
      ⇒ 照常显示;流式气泡与待发气泡本端没有时间戳 ⇒ 走这一档,与加这一块之前逐像素相同。

三、布局:`BubbleWithTime`(新,`MessageBubble.kt`)
   时间与气泡**同一行**(`Box` + 底对齐),外侧用 `padding` 把时间那一条的宽度**先扣掉**,
   气泡吃剩下的全部宽度。取舍与理由:
   - 时间**不参与 weight 分配**,而是先用 `rememberTextMeasurer` 按它将要用的那个
     `MiuixTheme.textStyles.footnote2` 量出**精确宽度**(`Text` 内部只是在同一份 style 上 merge
     显式参数 ⇒ 量出来的就是排出来的那一串字),所以它既不会溢出屏幕、也不会被压成两行;
   - 气泡可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`:长消息照旧用满能用的宽度,
     **代价写进 KDoc**——有时间的消息气泡最多窄一条时间条(这是「与气泡同行」的必然结果);
     只有「极窄屏 + 超大字号」才会明显变窄,那时选择如实把日期显示完整(截断 `2025年12月31日`
     比窄一点的气泡更亏);
   - 不用「让时间先占住宽度」那种零测量写法:`Row` 给非 weight 子节点的宽度是**按子节点顺序**
     递减的,「气泡在前」的那一侧必然先把宽度吃光、把时间挤成一条竖线;
   - 时间在 `clip` / `background` / `combinedClickable` 之外 ⇒ 气泡的样子、选字范围、长按点击区
     一个字都没变;气泡自己那一侧离屏幕的边距与加这一块之前逐像素相同;
   - 时间挂在这条消息**最后一个有正文的气泡**上(解析出的末段偶尔只有旁白,那一项不画正文框);
     `ContentlessNote`(空气泡位置那句如实说明)同样挂,走同一条路。

四、未动的部分:下方那行角标(来源 / 六态 / 分享 / 「⋯」)语义一个字没改,只是**并列的另一块**;
   消息内容 / 排序 / 分页 / 去重 / 「跟随底部」的触发键一概没碰;`MessageGroup` 与
   `StreamingBubble` / `PendingBubble` 的既有调用一个像素都没变。

验收:`assembleDebug` 通过(APK 47,278,290 B);
`testDebugUnitTest` 全量 127 suites / 1580 tests / 0 failures / 0 errors / 0 skipped
(基线 126 / 1564 → 新增 `MessageTimeLabelTest` 16 条)。
单测覆盖每一档的两侧:同一天 / 正好午夜 / `23:59` 与次日 `00:01`(只差两分钟但跨天)/
昨天跨月 / 昨天跨年(「昨天」优先于跨年档)/ 今年内更早(月日不补零、一周前仍是这一档)/
跨年(前天、以及整整一年前同一个 `8月14日`)/ 闰年 `2/29`(当年是昨天、次年整日)/
未来(同天 + 几分钟、次日、次年)/ `0` / `-1` / `Long.MIN_VALUE` / 同一毫秒在上海与 UTC 分属两档。
`D` 认成「一年里的第几天」把 `8月14日` 排成 `8月226日`——这条正是被这批单测当场拦下的,已写进
`MessageDayFormat` 的 KDoc。
2026-10-07 18:57:14 +08:00
AskaEth 11602c0138 docs(05): 锚点补记四十三 —— 行内标签对齐契约 v0.89(W68 两轮:大小写不敏感 + 属性丢弃 + 不平衡整段降级 + 白名单收窄五项)+ br 改气泡分隔符(与 p 同级、三种写法等价、流式同源扣留)+ 真机八组样本逐条验收 + 三条待后端确认的口径 2026-10-07 17:39:38 +08:00
AskaEth 591f298dc0 merge: W68 —— 行内标签按契约 v0.89 对齐(大小写不敏感 + 属性一律丢弃 + 结构不平衡整段降级 + 白名单收窄到五项去掉 u/s)+ <br> 改为气泡分隔符(与 <p> 同级,三种写法等价) 2026-10-07 17:38:04 +08:00
AskaEth 9dee236778 feat(W69): 按契约 v0.89 的 <br> 更正与白名单收窄 —— <br> 改做气泡分隔符(与 <p> 同级)、撤掉 u/s
后端 `回执三十` / `回执三十一` 把 v0.89 两处更正了,本端照改。

一、`<br>` 不再是行内标签,而是与 `<p>` 同级的**气泡/消息分隔符**
   (`回执三十一` 原话:「对消息内的 `<br>` 始终处理为多条消息隔断」)
   1. 行内层(`InlineMarkup.kt`)整体移出:词表少一项、`InlineTag.Break` 与「`<br>` → `\n`」
      那套解析/裁剪逻辑全删;白名单只剩五项 `b`/`strong`/`i`/`em`/`code`。
   2. 改在气泡切分层:新增 `appendRegion`(`ChatContentParser.kt`),把每个文本区按 `BR_TAG_RE`
      切开、段与段之间补 `Tok.Break`(既有 `<p>` 同款机制)。`<p>甲<br>乙</p>` / `甲<br>乙`
      都是两条气泡;`<p>甲<br><br>乙</p>` 的中间空气泡照旧丢弃;`<br>` 两侧是空白不产多余气泡;
      `<br>` / `<br/>` / `</br>`(含 `<br />` / `</br/>` / `<br class=x>` / 大写)等价。
   3. 层序采用「**先按 `<p>`/`<br>` 切分,再对每一段各自做行内解析**」:`<b>甲<br>乙</b>` 两段各自
      不平衡 ⇒ 两段都回落原文。理由写进 `appendRegion` 的 KDoc(「同级」本身就规定了层序;
      一个气泡 = 一条消息,跨分隔符的标签在结构上不可能成立,后端剥标签同样是按单条消息判平衡;
      实现上行内层逐气泡跑一次,一个字节都不用改)。
   4. 流式侧跟上:`<br` / `<BR` / `</br` / `<br/` / `<br class=x` 这些残缺形态由
      `couldBeLineBreakNamePrefix` 扣留(与 `BR_TAG_RE` 同口径);完整的 `<br>` 不入栈、直接放行。
      不扣的话中间态会先把字面 `<br` 吐上屏、收口时再变成两条气泡——屏幕上的字会改口。

二、白名单**收窄到五项**(`回执三十`):`u` / `s` 从 `InlineStyle`、解析、渲染、测试里全撤
   (`Underline` / `Strikethrough` 与 `MessageBubble` 的 `TextDecoration` 一并删净,不留死代码)。
   现在 `<u>甲</u>` 走「白名单外 ⇒ 原样显示」,与 Web 端一致——多支持反而是制造跨端不一致。

其余口径一律不动:大小写不敏感、属性整段丢弃、不平衡整段降级为原文(粒度 = 该气泡正文)、
`<code>` 内不解析、白名单外字符一个不丢、`parts` 逐字原文、`runs` 为空时 UI 走原路。
`tokenize` 的 `<p>` 块切分 / `stripLooseTags` / `ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE` /
星号语义 / `MessageEditText`(`<br>` 不进容差,只含 `<br>` 的消息仍是编辑框恒等路径)行为未改。
渲染仍是 `AnnotatedString` + `SpanStyle`,无新依赖、无 HTML 注入。

验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 126 suites / 1564 tests / 0 failures / 0 errors。
`ChatContentParserTest` 新增 14 条(`<br>` 四情形逐个断言气泡数与文本、三种写法等价、大小写、
像标签但不是的 `<br2>`/`<boolean>`、跨分隔符标签两段各自回落、`parts` 不含结构、旁白跨分隔符依附、
流式完整/残缺两态、`<code>`/`<action>` 内也分隔、行内解析不看 `<br>`、复杂度预算);
`InlineMarkupTest` 删掉 6 条与 v0.89 冲突的(`<br>` 换行 4 条 + `u`/`s` 渲染 + `<b>甲<br>乙</b>` 单气泡
一条),新增 3 条(`u`/`s` 撤掉后走字面、`<br>` 在本层只是普通字符、`<br>` 在解析层真分成两条气泡)。
2026-10-07 17:36:38 +08:00
AskaEth 4b19278733 feat(W68): 行内标签按契约 v0.89 对齐 —— 大小写不敏感、属性一律丢弃、结构不平衡整段降级、补 u/s 与 </br>
后端 2026-10-07 傍晚把行内标签升成契约 v0.89(推库 `2dae469`),本端四处按它对齐。

1. 标签名大小写不敏感(`<B>` / `<STRONG>` / `<Br>` 都认)
   消费侧与流式**扣留**侧一起改,两边同源:`parseInlineTag` 与
   `couldBeInlineTagNamePrefix` / `isTagNameLetter` 收在 `InlineMarkup.kt` 一份,
   `isPotentialTagPrefix` 引用它。原来扣留侧只扫 `a..z`,若只改消费侧,`<B` / `<b class`
   这种残缺形态就会在中间态被当正文吐上屏、等 `>` 到了再吃掉——屏幕上显示过的字当场改口。
   段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍只认小写,既有契约逐字未改。

2. 补 `u` / `s`:`InlineStyle` 加 Underline / Strikethrough;`MessageBubble.inlineBody`
   落到 `TextDecoration.Underline` / `LineThrough`(两者叠加时给两条线,不二选一)。

3. 属性一律不解析:白名单标签只取标签名,`<b class="x" onclick="y">` 就是粗体,属性整段丢掉。
   形状判据:名字后必须是 `>`、空白或 `/`,所以 `<boolean>` / `<br2>` 仍是普通文本。
   `inlineTagEnd` 那扇定宽窗换成「扫到 `>` 或下一个 `<` 为止」(属性没有长度上界),仍是线性。

4. 结构不平衡 ⇒ **该气泡正文整段降级为原文**(未闭合 / 落单闭标签 / 交错;`<br>` 也一起放弃)。
   取代 W67 的局部降级。粒度 = 该气泡的 `body`(不是整条消息、也不是单个 `<p>` 块),
   理由写进 `parseInlineMarkup` 的 KDoc,由 `theFallbackCoversOnlyTheBubbleThatIsBroken` 钉住。
   白名单外的尖括号(`<script>` 等)不参与结构判定,按契约第 1 条原样显示、字符一个不丢。

另:`</br>` 与 `<br>` / `<br/>` 等价(HTML5);`<code>` 内的内容仍不解析、也不算结构,
与契约第 3 条方向一致但不是同一条规则(原因写在 KDoc 里)。
渲染仍是 `AnnotatedString` + `SpanStyle`,不引入任何 HTML 注入路径、不新增依赖。
`parts`(编辑/搜索读的逐字原文)、`runs` 为空时 UI 走原路、`tokenize` / `stripLooseTags` /
`ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE` / 星号语义 全部未动。

验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 126 suites / 1553 tests / 0 failures / 0 errors。
`InlineMarkupTest` 新增 11 条(大小写、属性、四种不平衡形状整段降级、气泡粒度、白名单外字符、
`u`/`s`、`</br>`、流式大小写+属性不吐标签),与 v0.89 冲突的老断言按新口径改写并在 KDoc 里
写明「口径在 W68 按契约 v0.89 变更、为什么」,不冲突的一条都没放松。
2026-10-07 17:25:26 +08:00
AskaEth 3fe55e8d0c docs(05): 锚点补记四十二 —— 行内 HTML 标签渲染(W67:b/strong、i/em、code、br;只认小写、未配对保持字面、code 内不解析、整段仅标记回落字面、流式扣留未闭合行内标签;不碰星号语义)+ 真机五组样本逐条验收 + 八条故意不做 + 给后端登记的一问 2026-10-07 17:17:26 +08:00
AskaEth 3d4a9f288a merge: W67 —— 行内 HTML 标签基本渲染(b/strong、i/em、code、br;只认小写、未配对保持字面、code 内不解析;不碰星号语义) 2026-10-07 17:15:48 +08:00
AskaEth d7d93b4cb0 feat(W67): 正文行内标签渲染 —— <b>/<strong>、<i>/<em>、<code>、<br> 四类(成对才认、只认小写、未配对/嵌套错位一律字面)
改了什么:
- 新增 `domain/parse/InlineMarkup.kt`:纯函数 `parseInlineMarkup(text) -> List<InlineRun>`
  (`InlineRun(text, styles: Set<InlineStyle>)`,样式只有 Bold / Italic / Code;`<br>` 不是样式、
  它是字符 `\n`)。相邻同样式的片段合并,两端空白去掉。整段只有标记没有字时(`<b></b>` / `<br>`)
  返回空列表 = 回落字面原文。
- `ParsedBubble` 加一个**有默认值**的字段 `runs: List<InlineRun> = emptyList()`(既有构造点无需改动);
  `toBubble` 在**段落定稿之后**对最终正文调一次 `parseInlineMarkup`:`body` 因此是正文的渲染文本
  (标签已消费、`<br>` 已换成换行),而 `parts` 仍是逐字原文——编辑框 / 搜索片段 / 通知文案读的是它。
- `MessageBubble`:`BubbleBody` 多收 `runs` + `trailingCursor`;`runs` 非空时用一个 `AnnotatedString`
  承载粗体 / 斜体 / 等宽底色 / 换行(`<code>` 底色:她侧 `surfaceContainerHigh`、主人侧 `onPrimary`
  压 18% 浅底),光标在片段**之外**补上(不继承她最后一段的样式)。`runs` 为空走原来的 `Text(text)`,
  逐像素不变(绝大多数消息都走这一档)。
- `ChatContentParser.stablePrefixEnd` 的扣留词表补上行内开标签(`TAG_NAMES` = 段落/旁白 + `INLINE_TAG_NAMES`,
  两边由同一份名单生成的正则判):未闭合的行内开标签同样入栈扣留;`<br>` 自闭合、**不入栈**
  (入了就永远弹不出来),只在残缺(`<br`)时按词表扣留。
- 新增 `InlineMarkupTest`:36 条用例(四类标签各自的片段形状、6 条边界逐条、星号语义反证、
  流式扣留与「只增不改口」、以及编辑/搜索/分享三个调用点的护栏、复杂度回归)。

为什么:
- 只认小写:`parse` 只认小写 `<p>`(`<P>` 字面显示是既有测试钉死的),且流式扣留侧的标签名扫描只认
  `a..z`——行内标签若认大小写,扣留与消费两边就分叉,中间态会把 `<B>` 当正文吐上屏、收口时再吃掉
  (屏幕上显示过的字会改口)。实测命中的 2 条也都小写。
- **成对 + 正确嵌套才认,其余一律字面**:与既有「未闭合/畸形标签回落普通文本」同一口径,也保证绝不丢字
  (`1 < 2`、`a<b`、`<b`、`<p2>`、`<B>` 全部原样)。正文里的 `a<b>c` 是既有测试钉着的字面文本,
  行内标签没有「段落结构已被完整块定死」那种语境,所以不套用 W55 的落单标签剥离容错。
- `<code>` 里不再解析:HTML 语义上它就是「原样文本」容器,也正是「想显示尖括号怎么办」的答案。
- 不碰 Markdown 的 `*` / `_`:星号已被 `ROLE_RE` 占成旁白/动作语义(既有测试钉着),再给同两个星号
  一个「斜体」的说法就是改既有语义。`#`/列表/表格/围栏/`[链接](url)`/`<a href>`/HTML 实体一律不做,
  逐条理由写在 `InlineMarkup.kt` 的文件头 KDoc(0 命中、或需单独拍板)。
- 行内解析刻意**不**放进 `tokenize`:分段、旁白归属、落单标签剥离与 W63 那两处线性优化一个字不动,
  `<br>` 因此只换行、不分气泡。
- 旁白/动作行不做行内解析:它读的是 `parts` 原文,与分享预览那行同源,而预览侧(`ShareConfirmPanel`)
  本次是红线——只改这边就等于让「预览 == 气泡」在旁白上重新裂开。

验收:`assembleDebug` 通过(`app/build/outputs/apk/debug/app-debug.apk`,47,308,566 B);
`testDebugUnitTest` 全量 **126 suites / 1545 tests / 0 failures / 0 errors**(基线 125/1509,
本次新增 1 个测试类 + 36 条用例)。
2026-10-07 17:14:44 +08:00
AskaEth 6d4a37e484 docs(05): 锚点补记四十一 —— 通知横幅(DEFAULT→HIGH,因渠道重要级不可变故换 id 到 proactive_v2)+ 免打扰开关(两条渠道切 id,默认都弹,真机双档验收)+ W63 去二次方(两处 O(N²),20000 个落单标签 26405ms→17ms)+ W64 分享回落链 + W65 预览与气泡一致 + 国产 ROM 上横幅还需什么 2026-10-07 11:57:46 +08:00
AskaEth 3b9936e212 merge: W64 —— 分享回落链(EXTRA_TEXT → SUBJECT → TITLE)+ 三者都空时明确告知而不静默(来源提示走柔和横幅、绝不进正文) 2026-10-07 11:54:44 +08:00
AskaEth dc5972213e merge: W65 —— 分享预览改走同一套解析(所见即所发:多段成块、旁白灰斜体;超窗判据改为内容自然高 vs 窗口高) 2026-10-07 11:54:34 +08:00
AskaEth c07a713e86 merge: W66 —— 免打扰开关(两条渠道:HIGH 弹横幅 / DEFAULT 只在通知栏;设置页新开关,默认两者都弹) 2026-10-07 11:53:58 +08:00
AskaEth bfa79946d4 merge: W63 —— 解析器去二次方(两处 O(N²):松标签重复扫描 + P_BLOCK_RE 回溯;20000 个落单标签 26405ms → 17ms,语义一字不变) 2026-10-07 11:53:57 +08:00
AskaEth 0e2df1193d feat(notify): 设置页加「免打扰」开关 —— 她主动说的话拆成两条渠道按开关发(一并重做 proactive_v2/HIGH 那份改动)
改了什么:
- 新增第二条渠道 `proactive_quiet`(`IMPORTANCE_DEFAULT`,渠道名「她主动说的话(免打扰:只在通知栏)」),
  与 `proactive_v2`(`IMPORTANCE_HIGH`,弹横幅)并存;两条的定义收在一个纯函数
  `proactiveChannelSpec(quiet)` 里(id / 渠道名 / 重要级 / 描述成对给全),`proactiveChannelId(quiet)` 是它的 id。
- 设置页新增「通知」段 + 一行「免打扰」(`SwitchPreference` 外包 `PreferenceCard`,照「后台保持连接」那一行;
  副文案走纯函数 `proactiveQuietSummary`,写明「这不是静音」)。偏好落进既有的 `AppSettings`
  (键 `proactive_quiet`,**默认 `false` = 都弹**),不新造存储。
- 发通知时按开关选渠道:`postProactiveNotification(..., quiet)`,7.x 及更早的 `setPriority` 跟着一起降回
  `PRIORITY_DEFAULT`;`AppContainer` 的 `post` lambda 每次**现读** `AppSettings.current`(`StateFlow` 热副本),
  所以开关一改、下一条通知就走新渠道,不必重启 App。
- 重做主仓那份未提交的渠道改动:`PROACTIVE_CHANNEL_ID` 由 `proactive` 改为 `proactive_v2`、
  `PROACTIVE_IMPORTANCE` 由 DEFAULT 提到 HIGH、新增 `LEGACY_PROACTIVE_CHANNEL_ID = "proactive"` 并在建渠道时
  `deleteNotificationChannel` 掉它、`setPriority` 改 `PRIORITY_HIGH`,对应测试断言同步更新。

为什么:
- 横幅由**渠道重要级**决定,而重要级**一旦创建只有用户改得动**(App 改不动)⇒「开关横幅」不可能靠运行时改
  设置实现,只能两条渠道切着用。
- 刻意**不用** `setSilent(true)`:那是完全静音(连提示音都没),而用户要的是「只在通知栏提示,别弹横幅」。
- 惰性创建只保证**这一次要用的那一条**(不存在的渠道会让 `notify` 被系统静默丢弃,所以必须在发之前建):
  一个从没收到过她消息的用户,系统设置里不会平白多出渠道;另一条不删(用户可能对它调过设置),
  只删那条再也发不到的 legacy id。
- 去重、通知 id 派生、「是否该弹」的判据一个字节都没动。

验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 **123 suites / 1479 tests / 0 failures / 0 errors**
(基线 121/1471,本次新增 2 个测试类 + 8 条用例)。
2026-10-07 11:53:04 +08:00
AskaEth 0e834fc854 perf(parse): 落单 <p> 的「后面还有没有正文」判据改记忆化,parse 从 O(N²) 回到 O(N)
W61 审查点出的问题:`stripLooseTags` 对**每一个**落单 `<p>` 都要调一次 `hasContentAfter`,
而那函数每次从该位置一路现扫到串尾 ⇒ 正文里有 N 个落单标签整体 O(N²)。`parse` 跑在主线程
(`ui/chat/MessageBubble.kt` 的 `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。

改了什么
1. `hasContentAfter(text, from)` → `ContentAfterQuery`(同文件私有类):判定用的还是原来那段
   扫描本身(`next` 逐分支对应原循环,判据仍是同一组 OPEN_P_TAG_RE / CLOSE_P_TAG_RE,没有另立
   一套「什么算 `<p>` 标签」的定义),只是把走链途经位置的答案落表,之后 O(1) 命中。每个位置
   至多被算一次 ⇒ 整体线性。惰性建表:没遇到落单 `<p>` 开标签的常规消息一个字节都不多花。
2. 顺着同一个输入查下去,发现同一形状下**还有另一处 O(N²)**:`tokenize` 的
   `P_BLOCK_RE.findAll(raw)`——正文里没有 `</p>` 时,每个 `<p` 候选点都会带着懒惰的
   `([\s\S]*?)</p>` 回溯到串尾。完整块必然以 `</p>` 收尾,所以把最后一个 `</p>` 之后切掉再扫:
   每个匹配都整体落在被切下的前缀里、正则的尝试顺序也没变 ⇒ 匹配结果逐字不变,而这一路回溯没了。

实测(本机 JVM 单测内 `System.nanoTime` 计时,修复前后跑的是同一份测速脚手架、同一台机器)

  `parse("<p>".repeat(N))`        修复前       修复后
  N = 2000                         854ms         5ms
  N = 8000                        4254ms        12ms
  N = 20000                      26405ms        17ms
  另一轮完整曲线 N=1000/2000/4000/8000/16000 = 148/238/881/3700/15973ms(每翻一倍涨约 4 倍)
  新增的复杂度回归测试在修复前报:`60000 字符(20000 个落单标签)用了 31226ms`

  两处各自的份额(N=20000,`"<p>".repeat(N)`):落单标签判据约 23s、完整块搜索约 8.2s。

  `parse(" \n<p> ".repeat(20000))`(标签间夹空白):修复前也是 20s 量级,修复后 6ms。

  已知残留(**非本次引入**,改前改后一致,在测试里如实登记):`"<p ".repeat(N) + "</p>"`
  这种「`<p` + 空白一路没有 `>`、最后只有一个 `</p>`」的病态输入仍走完整块搜索的最坏路径
  (N=2000/8000/20000 = 105/1067/7314ms → 108/1242/7201ms)。要治它得把完整块搜索换成手写的
  线性匹配(牵动气泡切分的核心语义),本切片没做。

语义
`parse` 的对外行为一字未改:气泡切分、旁白依附、空块过滤、畸形回落全部照旧。既有 121 个测试
套件 1471 条全部通过、一条期望值都没动,另外新增 2 条测试:1 条复杂度回归
(`hugeRunOfLooseTagsIsDroppedWithinALinearBudget`,20000 个落单标签 + 宽松阈值,注释里写明
它测的是复杂度不是精确耗时)+ 1 条钉住「后面有正文才留字面」的语义
(`aLooseTagIsKeptWheneverThereIsStillContentAfterIt`)。

验收:`gradlew.bat testDebugUnitTest assembleDebug --rerun-tasks` → BUILD SUCCESSFUL
(121 suites / 1473 tests / 0 failures / 0 errors;APK 已产出)。无新依赖。
2026-10-07 11:50:53 +08:00
AskaEth 4fe5509c24 feat(share): 正文回落链 EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(修「只有标题的分享整条静默」)
W61 分享路径审查登记的那个缺口:有些发送方不填 `EXTRA_TEXT`,内容只在
`EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里,而本端只读 `EXTRA_TEXT` ⇒ 那次分享
**整条静默**:用户点了分享,界面毫无反应、后端零请求,连 `contentUnreadable`
那条「读不进来」的告知也够不着(它的判据里没有这两个 extra)。

改动(按主人拍板的方向):

1. **回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`,取第一个非空白的
   当正文(归一化口径与原来取 text 时完全一致)。`EXTRA_TEXT` 非空时它一定赢 ⇒
   既有情形逐字不变(`ShareTextFallbackTest` 有专门的用例钉住这一点)。
2. **来源如实登记**:`SharedContent.textSource`(`ShareTextSource.Text/Subject/Title`)
   记下正文是从哪个 extra 来的;渲染层据此说一句「这段文字来自对方的分享标题
   (对方没有提供正文)」——回落来的正文与真正文长得一模一样,用户只能靠这一句分辨。
   ⚠️ 那句话只出现在界面上,**绝不进正文**(面板预览与发出去的消息逐字相同,有用例钉住)。
   面板那边的接线需改 `ShareConfirmPanel.kt`(本批的**红线**:另一支正在改它),
   所以这一句先挂在**认领那一刻**的柔和告知通道上(`ChatViewModel.claimSharedContent`
   → `showShareNotice`):它必须在用户点「发送」之前说。
3. **三个 extra 都空 / 都读不出来 ⇒ 走「读不进来」告知,绝不静默**:判据加了
   `textPresent`(「三个里至少一个躺着原始值」由读了原始值的那一层报进来,与既有的
   `streamPresent` 同一个形态)。「连值都没有」(误触分享)照旧静默 —— 边界没放宽。
4. **读取防御式**:四个 extra 统一读**原始值**再按形态分派(`Intent.rawExtra`,带
   `runCatching`)。核对过 AOSP `android/os/BaseBundle.java`:`getCharSequence` 自己
   catch `ClassCastException` 回 null(**类型不符不抛**),会抛的是 `unparcel()` 的
   `BadParcelableException`(畸形 bundle)——那一路由 `runCatching` 挡住,不让异常从
   `accept` 冒出去。不引入新依赖,UI 侧零新增控件。

测试:新增 `ShareTextFallbackTest`(11)、`ShareTextExtrasTest`(8)、`ShareNoticeTest` +4;
全量 testDebugUnitTest 123 suites / 1494 tests / 0 failures / 0 errors
(基线 121/1471/0/0);`assembleDebug` 通过(--no-build-cache 真跑)。
2026-10-07 11:41:55 +08:00
AskaEth cdd58fa9c4 W65: 分享确认面板的预览改走与气泡同一套解析(所见即所发)
W61 审查发现的那处不一致:预览把正文 trim 之后**原样显示**、不过解析器,而发送后的气泡走
`domain/parse` 的 `parse`——分享 `"<p><p>正文</p>"` 进来,预览里是一串标签、气泡里只剩
「正文」。用户按发送之前看到的不是他将要发出的东西。

- 预览正文区改为渲染 `sharePreviewBlocks(text)`:就是 `MessageBubble` 用的那一个 `parse`
  的结果(多段 ⇒ 多个块;`<action>` / `<narration>` 与星号那几种写法 ⇒ 灰斜体小字的旁白行)。
  面板不再自己写一套「剥标签」;`parse` 一行未改。解析结果为空时按「整段原样显示」兜底,
  与 `MessageBubble` 的 `parsed.ifEmpty { plainBubble(content) }` 同一条——绝不显示空白。
- 「超窗」判据从「数一个 Text 的行数」换成**比高度**:内容自然高度(`onSizeChanged` 挂在
  `verticalScroll` **里面**,量到的是不受上限影响的自然高)vs 窗口高度(量到的行高 ×
  SHARE_PREVIEW_TEXT_LINES)。比较在组合期算完、由 `SideEffect` 报上去(排版回调里读到的两个量
  都是上一帧的,在那里比会永远差一帧)。窗口高也不再取「第三行的底边」:多块之后没有任何一个块的
  第三行等于这一窗的第三行。
- 两处 `verticalScroll` 的有限约束一如既往:外层预览区仍是 `heightIn(sharePreviewMaxHeight)`;
  正文窗仍是 `heightIn(shareConfirmWindowCap(cap))`(唯一出口,返回值恒有限),展开态把上限与滚动
  一起去掉。W58 那两次崩溃的形状一个都没碰。
- 测试:`ShareTextPipelineTest` 里那条钉着「预览原样、气泡解析」分工的用例按新口径**推翻重写**
  (预览与气泡的正文文本一致、标签不进可见文本),另加三条(多段块数 / 旁白段 / 空解析兜底);
  `ShareConfirmTest` 的超窗判据用例换成新纯函数的穷举,并新增 `sharePreviewWindowHeight` 的用例。

验证:`assembleDebug` 通过;`testDebugUnitTest` 121 suites / 1476 tests / 0 failures / 0 errors。
2026-10-07 11:38:07 +08:00
AskaEth 541a70f126 docs(05): 锚点补记四十 —— 后端回执二十八四条答案 + 畸形回归通过(部署后无新畸形)+ W60 后端重启自愈(根因两半、拉不到新消息的真相)+ W61 分享审查(当前 HEAD 无残留崩溃点,主人手机是旧包;三处如实登记的缺口)+ W62 语音接收侧真机验收通过(五档夹具 + 播放出声)+ 一条流程教训(合并≠设备上是新包) 2026-10-07 11:19:21 +08:00
AskaEth 3d8bc61912 merge: W61 —— 分享路径审查(补 12 条跨层串测 ShareTextPipelineTest;shareConfirmWindowCap 加有限性守卫,把「上限恒有限」从调用方好习惯变成唯一出口保证) 2026-10-07 11:15:28 +08:00
AskaEth 9ced7444bd merge: W62 —— 语音消息接收侧(voice DTO + 语音气泡 + 播放;无副本不画播放键、时长缺席不估算、实时帧不等 voice) 2026-10-07 11:14:54 +08:00
AskaEth 2fbf24a675 feat(voice): 接收侧语音消息 —— 显示 + 播放(W62 / 契约 v0.79 §4.4 与 §1.2 U19)
别的客户端(QQ / 微信)发来的语音,现在能正确显示并播放。只做接收侧,不做本端录音/发送。

契约口径(逐条落地,不自己发明键名):
- **判据 = `voice` 键存在**:绝不从 `content` 正文里认媒体类型(`[语音]` 占位标记自 v0.67 起不再
  下发,正文里也可能有用户自己打的同样字样);
- **按 `status` 分支**(`transcribed` / `empty` / `failed` 三态),正文一律照常显示、一个字不改;
- **`name` / `mime` / `size` / `url` 四者同生共死** ⇒ 凑不齐就**不画播放控件**(不给必然失败的按钮);
- **`durationMs` 缺席就不显示时长**(不反算、不显示 `0:00` 冒充);
- **不在 WS 实时帧上等 `voice`**(覆盖率:只有四个 REST 读取面填充)—— 实时到达的先按普通文本显示,
  由既有的「按 id 合并、新到的胜出」在下一次拉取时自动升级成语音气泡(新增单测钉住这条依赖)。

改了什么:
- `VoiceDto`(7 个键逐字照抄)+ `HistoryMessageDto.voice: VoiceDto? = null`(缺席与 null 同一件事);
- `domain/chat/VoiceMessage.kt`:判据与展示换算的**唯一落点**(`isVoiceMessage` / `voiceAudio` /
  `voiceDurationLabel` / `voiceNote` / `voiceMediaUrl` / `voicePlaybackFailureMessage`);
- `domain/chat/VoicePlayback.kt`:播放通道的纯状态机(Idle / Loading / Playing / Failed),
  每次转换都按 `messageId` 比对 ⇒ 迟到回调不许改状态;
- `ui/chat/VoicePlayer.kt`:`android.media.MediaPlayer`(平台 API,**不引任何新依赖**)驱动那条通道,
  三处收干净:换目标先停旧、换会话换实例、组合销毁 `onDispose` 释放;
- `ui/chat/VoiceBubble.kt`:`[播放/暂停] 语音 · 时长` + `empty` 那句补充说明 + 失败提示(一行,error 色);
  图标用 AAR 里真实存在的 `MiuixIcons.Play` / `Pause`(extended 包,basic 那 7 枚里没有);
- `data/api/MediaApi.kt` + `data/repository/VoiceStore.kt`:`GET {当前地址}{voice.url}`(自带的
  Bearer + 401 续期走 `ApiClientFactory`),字节落 `cacheDir/media/<存储名>`(复用 `DocumentCache`
  的名字判据 / 命中判定 / 原子落盘;**不落 `documents/`**,那是 FileProvider 唯一暴露的根);
- `MessageBubble` 渲染语音形态;`isContentlessUserMessage` 加一条「语音消息不算什么都没有」
  (否则空转写的语音会被扣上「内容没有被传过来」那顶**假**帽子);
- `devtools/fake-backend/`:新增语音夹具(`__control/messages?voice=&duration=` + 真 2 秒 WAV 的
  `/api/v1/media/{name}`)—— 生产库里一条带 `voice` 的真消息都没有,设备验收只能靠造。

为什么这么改:`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路,而语音是唯一「会出声」的
消息形态,生命周期必须有一处说清楚(写在 `VoicePlayer` 的 KDoc);播放失败必须可见(按钮回未播 +
一行短提示),一条点了没反应的语音在用户眼里就是坏的。

验收:`assembleDebug` 通过;`testDebugUnitTest` 120 suites / 1458 tests / 0 failures / 0 errors
(基线 115 / 1405,新增 5 个套件 53 条)。
2026-10-07 11:14:28 +08:00
AskaEth 22d655b73b fix(chat): 分享面板的高度上限出口补上「非有限值」守卫(W61 分享全链路复核)
主人报「网易云分享文本到 App 会闪退」。已修的那个 P0(df7b504 / 6d4f2cd:面板一打开就崩 /
点展开又崩)是 Dp.Infinity 当哨兵值与展开态没去掉 verticalScroll,那是**最可能**的成因。
这一批把这**整条链**(Manifest 的 intent-filter → ShareIntake → 落地面板 → sendConfirm →
气泡渲染)从「假设主人的包就是当前 HEAD」重审了一遍,找残留崩溃点。

结论:**当前 HEAD 这条路径我没有找到残留崩溃点**。逐项复核见下面「查了什么」。审查中发现
一处**不变量只写在 KDoc 里、实现并不保证**的地方,本批把它补实(不改变任何现状行为)。

改了什么(最小的一处 + 一处 KDoc 更正):

1. `shareConfirmWindowCap` 从纯透传改成**守卫式**:`measured` 非有限(`Dp.Infinity` / `Dp.Unspecified`
   即 NaN)时与「还没量到」同等对待,回落 150dp。它上面那段 KDoc 一直写着「返回值永远不是
   Dp.Infinity」,而实现是 `measured ?: 150.dp` —— 今天唯一的调用方传的是 null 或
   `getLineBottom(...).toDp()`(恒有限),所以**现状没有可触发的崩溃**;但那条不变量此前只由
   「调用方的好习惯」保证,而它正是 W58 崩过两次的那一条。补守卫之后可以给出一句可证明的话:
   这一层给出的上限**恒有限**,于是面板里那两个 `verticalScroll`(外层预览区 / 收起态正文窗)
   在任何输入与状态下都不可能落在无界高度上。
2. `ShareConfirmBody` 那段 KDoc 里一句不准确的话改准:旧文说该函数的出口「要么返回 null(不设
   上限)」——它恒返回 Dp,「不设上限」发生在展开态那一支(连 verticalScroll 一起去掉),与它是两回事。

新增测试:

- `ShareTextPipelineTest`(12 例,新文件):把「分享一段文本进来」这条链从 Intent 字段一路串到
  气泡正文(`ShareRules.extract` → `shareConfirmPreview` / `shareConfirmBody` / `shareConfirmCanSend`
  → `parse`),此前**跨层串测完全空缺**(每一站各自有单测,没有一处把它们接起来)。钉三件事:
  ①**内容一字不丢**(「分享单曲 + 链接 + emoji + 多行」逐字节到气泡正文;用户补的描述同样);
  ②**正文里的标记按既有语义走且绝不抛异常**(`<p><p>` / `<action>` / 全角半角星号 / `1 < 2` /
  半个代理对 / `\u0000` / 200 个落单 `<p>` / 10 万字符都过一遍);
  ③**面板预览不过解析器、气泡过解析器**(同一段带标记的字,预览里是原样、气泡里被剥离——
  这是如实记下的一处不一致,不是崩溃)。
- `ShareConfirmTest`:把 `Dp.Infinity` 与 `Dp.Unspecified` 放进「任何输入都给不出非有限上限」的
  输入表——原先那张表**恰好漏掉了唯一能推翻该断言的两个值**。反证过:把实现改回纯透传时,
  这一条用例确实 FAILED(`no input can produce a non finite cap`),改回守卫后通过。

查了什么(逐项,都是读代码 + 纯函数求证,不是猜):

① Intent 形态:`getCharSequenceExtra` 对非 CharSequence(`String[]` / `ArrayList`)是**catch 住
   ClassCastException 回 null**(SDK 源码 `BaseOs/BaseBundle.getCharSequence` 已核),不抛;
   EXTRA_STREAM 走原始值按形态分派;字符串形态只认 `content://`;ClipData 纯文本条目只当
   「带着内容迹象」用来出告知。② 空/边界输入:空串 / 纯空白 → `worthDelivering=false` 静默
   (正确);`<p><p>` / 星号 / `<` 一律不抛(新用例钉住)。③ 面板渲染:Miuix 的 `WindowDialog` /
   `TextField` 里**没有**任何 scroll 节点(反编译 `miuix-ui-0.9.3` 的 class 查过),面板里的两个
   `verticalScroll` 各自都先过一层有限的 `heightIn`。④ 出队闸门:闸门在 `take()` 之前、认领落在
   VM 状态里,重建不会再取;`claimSharedContent` 只有 Compose 主线程一个调用点。⑤ 发送后渲染:
   `parse` 的落单标签口径(W55)与既有契约一致,新用例把分享正文含标记时的落点一并钉住。
   ⑥ 通知/保活:分享落地与 `ProactiveFrameIntake` / `KeepAliveService` 之间没有任何联动路径。

没覆盖 / 只能上真机的:渲染层本身(本模块没有 Compose UI 测试依赖,`checkScrollableContainerConstraints`
那一类崩溃单测抓不到——W58 的教训),以及任何需要设备的观感;本批**没有**装包上机(emulator-5554
上有别的会话在跑,主仓在我复核期间刚构建过 APK,我刻意没有动它)。

真跑:`./gradlew.bat assembleDebug` 通过;`./gradlew.bat testDebugUnitTest` 全量
**115 suites / 1400 tests / 0 skipped / 0 failures / 0 errors**(基线 114 / 1388,差的正是本批
新增的 1 个 suite 12 例,其余一条没碰坏)。
2026-10-07 11:00:45 +08:00
AskaEth 55612a3922 merge: W60 —— 后端重启后自愈(连接重建即收口未结束的回合/流式卡/工具卡,修「永久卡在正在思考」) 2026-10-07 10:54:37 +08:00
AskaEth 83a2a88b3c fix(chat): 连接重建(后端重启)时把「进行中」全部收口,不再永久卡在思考/工具调用
改了什么
- 新增纯判据 `isReconnect(previous, current, everConnected)`(ui/chat/ReconnectReset.kt):
  只有「**曾经**到过 Connected 之后又回到 Connected」才算重建,第一次接上一个字节都不动。
  主判据是 VM 自己那位只置不翻的 `everConnected`(状态流会合流,`Connected → Reconnecting →
  Connected` 中间那一跳可能被吞掉,只置不翻的位不受影响);第二证据是上一跳为 `Reconnecting`
  (只在「本 VM 是离线期间建起来的」时成立,且那一刻它手里什么都没有,收口是恒等变换)。
- 新增 `ChatUiState.settledAfterReconnect()`(纯函数):把服务端那一轮在本端留下的五个面一次收口 ——
  `turns.allClosed()`(未收口绑定全标 closed,气泡 ④ 灰态 → ⑤)、`streams.allFinished()`
  (每一路停止生长:思维链不再「思考中…」、工具卡不再「执行 X…」)、`busyMode = null`、
  `activeTurns = 0`、`queueDepth = 0`。无变化时返回**同一个实例**。
- `TurnStreams.kt` 新增 `allFinished()`:正文与思维链一个字都不删;**唯一**的删除是「本来就什么都
  没有」的空路(`active` 而一个像素都不画的那种,留着就是一个占着列表末项的幽灵)。
- 工具卡收口 `List<ToolCallState>.settledAfterReconnect()`:`Running` ⇒ `phase = null` + `done = true`
  (渲染成「工具调用 · X」——不再有省略号、也不替服务端下「失败」的结论);`Done`/`Error` 一个字节
  都不动。`ToolPhase` 枚举**没加第四档**(它逐字对齐后端流程图 status,是契约面)。
- 接线在 `ChatViewModel.onConnectionChanged`:收口刻意放在既有的「同值早退」**之前**(见上),
  补拉那一半(`reconnectPull` / `gapCursor` / `firstPageState`)**一个字都没改**。

为什么
后端卡在回合里被重启 ⇒ 那一轮在服务端已经不存在 ⇒ 本端**永远**等不到 `turn.end` / `stream_end` /
助手落库行,而本端「进行中」的每一条闸都只认帧(灰态只认 `TurnBinding.closed`、卡片只认
`thinkingActive`/`active`/`phase`、底栏只认 `busyMode`)⇒ 三条路都不会自己收口,界面永久停在那一格。
今天唯一的兜底是 `session_idle` / `session_interrupted` 的那两个 `allClosed()` 调用点,而说那两句话的
进程已经没了。连带的第二半:那条永远 `active` 的流式路永远是 `items` 的最后一项(`reverseLayout` 下就是
屏幕最下方),而 `ChatScreen` 的「跟随底部」判的正是 `items.lastOrNull()?.key` ⇒ 末项不再变化 ⇒
此后新消息不再把列表滚到底(用户视角就是「拉不到新消息」)。

触发时机只做「连接重建」(a),**不做静默超时** (b):理由、代价与风险((b) 要多一个只能拍的时间常量、
会把正常但慢的回合误杀;但它能兜住「连接没断而编排器死锁」这一档,今天没有现场证据)都写在
`ReconnectReset.kt` 顶上,留主人拍板。

如实登记的两处代价
1. 收口**不删**正文/思维链/工具卡内容,所以那张卡本身要等一个后续事件才消失(它自己那一路的落库行,
   或下一轮 `message.start` 的 `beginLeg` 把它当「已结束的路」丢掉)。若主人更想要「末项立刻让开」,
   代价是那半段正文从屏幕上消失——我没选它(硬要求:不许动消息内容)。
2. `busyMode = null` 连带改变下一次发送的声明(`emitAndDeliver` 用 `busyMode != null` 算
   `interruptScopeForSend` / `parallelForSend`):重建后的**第一次**发送不再先发 `interrupt`。如实说:
   重建之后本端确实不知道那一轮还在不在;服务端下一次报 `session_busy` 就恢复。
「闪动」口径:`closed` 单调(迟到的帧不会把它翻回灰态),流式卡片会被后续真帧重新拉回「在跑」
(那是如实的、且不丢字),`busyMode` 由服务端下一帧恢复。

验证
- `./gradlew.bat assembleDebug` BUILD SUCCESSFUL(app-debug.apk 已产出)
- `./gradlew.bat testDebugUnitTest` 全量:**115 suites / 1405 tests / 0 failures / 0 errors**
  (基线 114 / 1388 ⇒ +1 suite、+17 tests,全部来自新增的 `ReconnectResetTest`)
- 新增 `ReconnectResetTest`(17 例):连接重建 ⇒ 未收口绑定全 closed(且 `phaseOf` 真的变成 Answered);
  第一次连接(`Idle`/`Connecting → Connected`)不收口;判据 5×5×2 = 50 格全表穷举;`settledAfterReconnect`
  幂等且无变化时 `assertSame`;已 closed 的不会被 `withTurnBegin` 翻回;流式卡片全停且正文保留、空路被丢;
  工具卡 `Running → null+done`、`Done`/`Error` 原样、后续真帧能放回 `Running`;
  消息/待发/草稿/队列/error/绑定 messageIds **一律原样**。
2026-10-07 10:50:23 +08:00
AskaEth 53c3c5c8ee docs(05): 锚点补记三十九 —— 后端回执二十五/二十六要点(.end 加 resultSummary 已批准、proactive 在 data 里、帧 id 同源、channel_persisted 可能带 proactive、markup 畸形确认是 bug 且已修但未部署)+ 我们去重口径复核没踩坑 + W59 工具卡结果区落地 + 真机验收边界(这批只有单测证据) 2026-10-07 10:15:13 +08:00
AskaEth e0bdaa3537 merge: W59 —— 工具卡结果区(tool_execution.end 的可选 resultSummary;缺席/空串一律不画,与老服务端逐字节一致) 2026-10-07 10:14:30 +08:00
AskaEth 316cd27049 feat(chat): 工具卡接上 resultSummary 结果区(缺席时与今天逐字节一致)
后端新契约切片给 `tool_execution.end` 的 `data` 补了一个**可选** `resultSummary`
(与回看面 `toolCalls[].resultSummary` 同一份预算:已脱敏、已截断 ≤500 字符)。
它的语义是「拿到就带、没拿到就缺席」——失败 / 取消 / 超时 / 未落图时**整个键缺席**,
且契约明说**别把缺席读成「没有输出」**。在那之前工具卡只能显示工具名与状态。

改了什么:
- wire:`WsEvent.ToolExecutionEnd` 新增 `resultSummary: String? = null`;共用的
  `ToolExecutionData` 跟着加这个字段(只由 `.end` 读,`.start` / `.update` 的领域事件
  仍然没有它)。缺键 ⇒ `null`,空串 ⇒ `""`,都在解码层照原样保留、不编结论。
- 领域:`ToolCallState` 新增 `resultSummary`;`applyToolExecution` 多一个同参数
  (非空白时顶替、缺席 / 空白时保留原值——三帧里只有 `.end` 带它,一次落不到不该抹掉旧的);
  VM 的 `onToolExecution` 与 `ToolExecutionEnd` 分支透传。
- 判据:新增纯函数 `toolResultTextOf(tool)`:摘要非空白 ⇒ 用它;否则回落 `output`
  (今天唯一的来源,口径不变);两者都没有 ⇒ `null`。
- UI:`ToolCard` 折叠区里「结果」那一块(参数之后)改由 `toolResultTextOf` 决定要不要画。
  `null` 时**连标签与它上方那道 Spacer 都不画** ⇒ 摘要缺席时这一屏与后端补这个键之前
  逐字节相同。空串同理(服务端承诺不发空串,但真发了也绝不渲染出一个空的结果区块)。
  摘要只展示:不再截断第二次、不解析、不当 markdown;正文沿用与参数同一个 `FoldCardBody`
  (`SelectionContainer` + 不设 `maxLines`),长文本整段可见,没有新增任何滚动 / 限高 modifier。

为什么这么做:一句话的判据、纯函数、单测可钉;渲染层只做「画 / 不画」这一个决定,
于是「老服务端(键恒缺席)」与「新服务端(键在场)」共用同一套 UI,不用等部署。

测试:新增 14 条(`WsEventToolExecutionTest` +5、`ToolExecutionCorrelationTest` +9),
覆盖 ①键在场 ⇒ 解出该值(含 500 字符原样透传)②键缺席 ⇒ `null` 且不报错
③空串 ⇒ 解出 `""` 且判据按「不画」处理 ④`toolResultTextOf` 在缺席 / 空串 / 有值三档下的输出
(含摘要优先于 `output`、结果落卡、缺席不抹旧值、落不到卡时不影响任何卡)。
`assembleDebug` 通过;全量单测 114 suites / 1388 tests / 0 failures / 0 errors(基线 1374)。
2026-10-07 10:14:10 +08:00
AskaEth c40a752bcf docs(05): 锚点补记三十八 —— 分享面板 P0 崩溃(两次:Dp.Infinity 当哨兵值 / 展开态只去上限没去 verticalScroll)已修并真机验收(收起 153px↔展开 255px,同一 pid 无崩溃);三条教训(单测抓不到渲染层崩溃、子代理论证再详细也不能替代真机点按、verticalScroll 的约束来源要看谁在外面);顺带推翻 W56『首帧回流看不见』的结论 2026-10-07 09:51:59 +08:00
AskaEth df7b504fa2 fix(chat): 分享面板展开态也会崩 —— 只去掉上限是不够的,必须连 verticalScroll 一起去掉
补上一个修复的漏:上一笔只把展开态的 heightIn 上限去掉,没去掉 verticalScroll。而外层
ShareConfirmPreviews 自己就是一个 verticalScroll,它给内容的是**无界高度** ⇒ 这一层的 scroll
在无界高度下被测量 ⇒ 抛同一个 IllegalStateException。真机上点「展开」的那一下崩了第二次
(pid 2077,01:48:53,reason=4 APP CRASH)。

改:展开态把这一层**整个**去掉(上限 + 滚动一起),只留外层预览区那一层滚 —— 这也正是 W56
原本的设计意图('展开态只留外层那一层')。shareConfirmWindowCap 去掉了 expanded 维度(展开态
不再走它),测试同步改写并补了一条「两种状态在链上都有有限约束」。

真机验收(emulator-5554 + 真后端):收起 153px(3 行)+『展开』→ 点开展开 255px +『收起』→
点回收起 153px,**三次操作同一个 pid 4063,没有任何新崩溃记录**。
2026-10-07 09:51:44 +08:00
AskaEth 6d4f2cdbae fix(chat): 分享确认面板一打开就崩 —— 正文那一层绝不能再拿到 Dp.Infinity
真机崩溃栈:heightIn(max = Dp.Infinity) 等于不施加约束,verticalScroll 在无界高度下被测量时
checkScrollableContainerConstraints 直接抛 IllegalStateException(ScrollNode.measure ←
SizeNode.measure ← DialogLayout.internalOnMeasure),分享进来、面板刚打开就闪退。

改法:这一层的高度上限只经纯函数 shareConfirmWindowCap() 一个出口 —— 首帧用
SHARE_PREVIEW_MAX_HEIGHT(150dp) 兜底(有限值),展开态这一层不设上限(外层 240dp 仍是有限约束),
并且不再用 Dp.Infinity 当「还没量到」的哨兵值(改用 null)。新增 4 条单测钉住
「任何输入组合都不会产出非有限上限」。
2026-10-07 09:45:34 +08:00
AskaEth af9c63d267 docs(05): 锚点三十七补第 5 节 —— 收尾:假后端任务到点、tcp:80 切回真后端、App 已重连并拉到真实会话;并如实记录「换后端目标但地址字符串不变 ⇒ 本地消息残留」这个观察(已核实真后端没有那条测试消息,且正常换地址必经登出会清) 2026-10-07 03:57:03 +08:00
AskaEth c8f19f9c84 docs(05): 锚点补记三十七 —— 长跑第3次定案(30min 有效通过;2h 失败是模拟器 system_server 崩溃连带杀进程,五条证据)+ Android 14 划掉会终止 dataSync 前台服务(验收造场景改用返回键 finish Activity)+ W57 端到端验收通过(含未做改前对照的如实说明) 2026-10-07 00:18:51 +08:00
AskaEth f9555fba85 docs(05): 锚点补记三十六 —— W57 通知消费点提到进程级(根因链:连接是进程级/订阅挂 Activity 级 VM/replay=0 丢帧)+ 长跑第3次基线(开关确认是开的,推翻上节那条猜想)+ W57 设备验收步骤与副作用 2026-10-06 22:23:09 +08:00
AskaEth 900d191b8d merge: W57 —— 把「她主动说的话」的通知消费点提到进程级(新增 ProactiveFrameIntake;修 Activity 被划掉后收不到帧/不弹通知) 2026-10-06 22:22:26 +08:00
AskaEth 0e03eba6a4 feat(notify): W57 —— 「她主动说的话 ⇒ 弹通知」的帧消费点提到进程级
改了什么
- 新增 app/ProactiveFrameIntake:订 ChatSocket.events,把两类帧(proactive_message、
  message_persisted{proactive:true})折成四个入参交给 ProactiveNotifier。纯函数
  proactiveFrameOf 管「哪些帧算她主动说的话、每个入参取自帧上的哪个键」。构造即订阅,
  刻意不提供 start/stop。
- AppContainer:新增 proactiveFrameIntake(与 chatSocket 同一层,进程级)。
- SessionsViewModel:删掉 onEvent 里那两处转交 proactiveNotifier 的调用,本类不再判断
  通知的任何一件事(未读、「谁在跑」原样不动)。
- AppPresence / ProactiveNotifier 的 KDoc 跟着改:谁在读「在不在前台」、谁在喂帧。

为什么
改前这份订阅挂在 SessionsViewModel 的 viewModelScope 上。连接本身是进程级的
(ChatSocket 自带 scope,只有 close() 取消它,而 SessionsViewModel.onCleared 刻意不关),
所以从最近任务把 Activity 划掉之后连接照旧收帧——但 events 是 replay=0 的 SharedFlow,
没有订阅者的 tryEmit 当场丢掉:她的话进了连接、然后没了,通知永远不弹。这是 W53N 登记
的那一格缺口。

边界
- 判据与去重一个字节没动(仍在 ProactiveNotifier 的纯逻辑里)。
- 连接所有权没动:仍然只在登录态那一处 connect/close。
- 不是「新起一条连接」,只是把帧的消费点从 Activity 级提到进程级;VM 里那两处转交是
  删而不是留(留两处等于靠去重去遮一次重复消费,且「谁在喂通知」会有两个答案)。
- deliver 里接住落地异常:这条订阅不会再被重建,抛出去就是进程崩溃 + 通知整个哑掉。

验收
assembleDebug 通过;testDebugUnitTest 全量 113 suites / 1370 tests / 0 failures /
0 errors(基线 112/1361,新增的 9 条全在 ProactiveFrameIntakeTest)。
2026-10-06 22:21:26 +08:00
99 changed files with 14562 additions and 5969 deletions

No files matched your search

+4
View File
@@ -12,3 +12,7 @@ captures/
.cxx/
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
/IMG_*.png
# 开发文档 —— **主人 2026-10-07 明确指令:整个文件夹禁止进入远程仓库**。
# 它只在本机磁盘上(前后端协同留言板、进度交接、设计拆解都在里面),靠磁盘本身交接。
# 凭据同样绝不写进去(那类东西在 dev-secrets.local.md)。
devdocs/
+10
View File
@@ -15,6 +15,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
-->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--
联网权限**显式**声明,不靠任何传递依赖。
@@ -15,6 +15,7 @@ import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.DocumentsApi
import top.yeij.cyrene.data.api.MediaApi
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.PersonaApi
import top.yeij.cyrene.data.api.SessionsApi
@@ -23,6 +24,7 @@ import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository
import top.yeij.cyrene.data.repository.DocumentRepository
import top.yeij.cyrene.data.repository.MediaRepository
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.PersonaRepository
@@ -65,12 +67,22 @@ class AppContainer(context: Context) {
/**
* 「她主动说的话」的通知通路(W53N):判据、去重与落地都在里面,Android 那一步从这里注入。
*
* 它**不订阅任何东西**(帧由 `SessionsViewModel` 那条既有订阅喂进来),因此与连接的所有权
* 完全无关——本端那条共享连接的生死仍然只在登录态那一处表达。
* 它**不订阅任何东西**(帧由 [proactiveFrameIntake] 喂进来),因此与连接的所有权完全无关
* ——本端那条共享连接的生死仍然只在登录态那一处表达。
*/
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
presence = appPresence,
post = { text, notificationId -> postProactiveNotification(appContext, text, notificationId) },
post = { text, notificationId ->
// 「免打扰」**每次现读**(`AppSettings.current` 是那份热副本):开关一改,下一条通知就
// 换一条渠道,不必重启 App。为什么不能在这里挂起读一次、以及那个毫秒级窗口是怎么回事,
// 都写在 `AppSettings.current` 的 KDoc 里。
postProactiveNotification(
context = appContext,
text = text,
notificationId = notificationId,
quiet = appSettings.current.value.proactiveQuiet,
)
},
)
/**
@@ -186,6 +198,34 @@ class AppContainer(context: Context) {
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
private var cachedMediaApi: MediaApi? = null
private var cachedMediaBaseUrl: String? = null
/**
* 语音副本端点(`GET /api/v1/media/{name}`,契约 §1.2 **U19**);与 [documentsApi] **同一套门**:
* `owner|admin` **必须 Bearer**(与图片读侧「名字即凭据」刻意相反 —— 语音是私密内容)。
*
* 同样与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理):`voice.url` 是**服务端相对路径**,
* 只有落在**同一台后端**上才取得到那份副本。
*/
val mediaApi: MediaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedMediaApi
if (cached != null && cachedMediaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = MediaApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedMediaBaseUrl = baseUrl
cachedMediaApi = created
return created
}
val mediaRepository: MediaRepository get() = MediaRepository(mediaApi)
private var cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = null
@@ -287,4 +327,22 @@ class AppContainer(context: Context) {
onAuthFailed = authManager::onSessionExpired,
)
}
/**
* 「她主动说的话 ⇒ 弹通知」那条通路在**帧这一层的消费点**(W57,**进程级**):把 [chatSocket]
* 上那两种帧交给 [proactiveNotifier]。构造即订阅,此后与进程同寿(没有 start / stop 的理由
* 见 [ProactiveFrameIntake] 的类注释)。
*
* 改前这份订阅挂在 `SessionsViewModel` 的 `viewModelScope` 上(它随 Activity 一起结束),于是
* 「从最近任务划掉 App、进程还被 `KeepAliveService` 撑着」那一档:帧到了 [chatSocket]、
* 一个订阅者都没有,而 `events` 是 `replay = 0` 的 `SharedFlow` ⇒ 当场丢掉,通知永远不弹。
*
* 声明在这里(类的末尾)有一个顺序上的理由:它要同时用到 [proactiveNotifier] 与 [chatSocket],
* 而 [chatSocket] 是本类最后声明的那个(Kotlin 按声明顺序初始化)。读 [ChatSocket.events] 会把
* 那条连接对象提前建出来——本容器与它同时诞生、同时活着,这一步没有任何代价。
*/
val proactiveFrameIntake: ProactiveFrameIntake = ProactiveFrameIntake(
events = chatSocket.events,
notifier = proactiveNotifier,
)
}
@@ -0,0 +1,136 @@
package top.yeij.cyrene.app
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.core.notify.ProactiveNotifier
import top.yeij.cyrene.data.ws.WsEvent
private const val LOG_TAG = "ProactiveFrameIntake"
/** 一帧「她主动说的话」剥出来的东西:[ProactiveNotifier.onProactiveFrame] 的四个入参 + 会话归属。 */
internal data class ProactiveFrame(
val sessionId: String?,
val id: String?,
val messageId: String?,
val timestamp: String?,
val content: String,
)
/**
* 这一帧算不算「她主动说的话」,以及那四个入参各取帧上的哪个键——**纯函数**(离线可穷举)。
*
* 只有两种帧是她主动说的话,它们对应服务端两条路(见 [ProactiveNotifier]):
* 1. [WsEvent.ProactiveMessage](实时帧 `proactive_message`)——`id` 取落库行键 `data.id`,`messageId`
* 取 IM 侧消息号,两者**不是同一个 id**(见那个事件的 KDoc);
* 2. [WsEvent.MessagePersisted] **且** `proactive == true`(IM 桥那条路补发的落库帧)——这一档只有
* 落库行的键(`message.id`),另外两个键帧上就没有,所以恒给 `null`,一个字都不许编。
*
* 其余帧一律 `null`:普通落库广播、流式增量、别的事件都不是「她主动找我说话」。判据错在这一层的
* 两个方向都很难在真机上发现——要么她开口了却什么都不弹(要等下一次主动消息才看得见),要么普通
* 消息也弹(每一次她正常回话都响)。
*/
internal fun proactiveFrameOf(event: WsEvent): ProactiveFrame? = when (event) {
is WsEvent.ProactiveMessage -> ProactiveFrame(
sessionId = event.sessionId,
id = event.id,
messageId = event.messageId,
timestamp = event.timestamp,
content = event.content,
)
is WsEvent.MessagePersisted -> {
if (!event.proactive) {
// 普通落库广播(她回话、别的端发的消息都走这一帧):主人只批了 proactive 这一档。
null
} else {
ProactiveFrame(
sessionId = event.sessionId,
id = event.message?.id,
messageId = null,
timestamp = null,
content = event.message?.content.orEmpty(),
)
}
}
else -> null
}
/**
* 「她主动说的话 ⇒ 弹系统通知」那条通路的**帧消费点**(W57,**进程级**)。
*
* ## 为什么必须提到这一层(改前那一档是坏的)
* 这条通路的判据与去重一直是对的,错的是**订阅挂在哪**。改前它是 `SessionsViewModel` 里那句
* `viewModelScope.launch { socket.events.collect(::onEvent) }`。连接倒是进程级的——划掉 Activity
* 之后它照样连着、照样在收帧——但 `viewModelScope` 随 Activity 一起结束,而 `ChatSocket.events`
* 是 `replay = 0` 的 `SharedFlow`:**没有订阅者的 `tryEmit` 就是当场丢掉**(它不替将来的订阅者
* 留任何东西)。于是「从最近任务把 App 划掉、进程还被前台服务撑着」那一档,她的话进了连接、
* 然后没了。
*
* 本类因此只做一件事:**在进程级订那份帧流**,把 [proactiveFrameOf] 认出的帧交给 [ProactiveNotifier]。
* 它不建连接、不关连接、不认识登录态——连接的所有权一个字节都没动,仍然只在登录态那一处表达
* (`SessionsViewModel` 的 `LoggedIn → connect()` / `LoggedOut → close()`)。
*
* ## 为什么没有 start() / stop()
* 它的生命周期**就是进程**:`AppContainer` 在 `Application.onCreate` 里构造它,此后一直活着。
* 刻意不提供 `stop()` 正是这次要修的那个 bug——**唯一会想到去调 stop 的调用方(某个 ViewModel)
* 会随 Activity 一起死**,那等于把这条通路又绑回 Activity 上;留一个可以被忘掉调用的 `start()`
* 也只是多一种坏法(忘调 = 通知整个哑掉,且没有任何报错)。
*
* 它不需要停:帧只有连接活着才可能到,而连接在登出(含令牌失效那条经 `onAuthFailed` 的路径)时
* 由登录态关掉。「登出期间收不到帧」这件事由**连接**保证,不靠这里取消订阅。去重账本也**刻意不**
* 在这里清:它是 `ProactiveNotifier` 里的进程级账本,而换了账号之后同一条消息再推一次,仍是
* 同一个人的同一句话,不该弹第二遍。
*
* ## 它现在是这条通路上唯一的帧消费点
* 改前 `SessionsViewModel` 里那两处转交已经删掉(留两处就等于靠去重去替一次重复消费擦屁股,
* 而且「谁在喂通知」会有两个答案)。所以这条订阅一旦死掉,通知就**整个**哑掉——兜底见 [deliver]。
*
* @param events 进程内唯一那条连接的帧流(`ChatSocket.events`)。
* @param notifier 判据 + 去重 + 落地那一处。本类只喂帧,不替它判断任何事(那两个入参是它自己的
* 事实与动作,见它的 KDoc)。
* @param dispatcher 收集用的调度器;单测传 `Dispatchers.Unconfined`,让帧在同一次 `emit` 里走完。
*/
class ProactiveFrameIntake(
events: SharedFlow<WsEvent>,
private val notifier: ProactiveNotifier,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
/**
* 订阅本身那个 scope:私有,没有第二个人拿得到它,也就没有人能取消这条订阅——本类的全部意义
* 就在这里。`SupervisorJob` 只为 [deliver] 那条兜底留余地。
*/
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
/** 构造即订阅(理由见类注释:启动时刻只有一个,留个能忘调的 `start()` 只会多一种坏法)。 */
init {
scope.launch { events.collect { event -> proactiveFrameOf(event)?.let(::deliver) } }
}
private fun deliver(frame: ProactiveFrame) {
try {
notifier.onProactiveFrame(
sessionId = frame.sessionId,
id = frame.id,
messageId = frame.messageId,
timestamp = frame.timestamp,
content = frame.content,
)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// 落地那一步(建渠道 / notify)万一抛,**这条订阅必须活下来**:它是进程级那唯一一处、
// 不会再被重建,而不接住的话异常会打到线程的未捕获处理器(Android 上就是进程当场挂)。
// 代价封顶在「这一条通知没弹」——去重账本已经把它记下了,不会为了补它再弹一次。
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "这条主动消息的通知没能落地", e)
}
}
}
@@ -48,6 +48,17 @@ import java.util.UUID
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
*
* ## 四个 extra 为什么都读「原始值」
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` / `EXTRA_STREAM` 一律用 [Intent.rawExtra] 取原始值,
* 再由本文件按**形态**分派([shareStreamExtrasOf] / [shareTextExtrasOf])。两个理由:逐类型的
* `getXxxExtra` 遇到别的形态只会往 logcat 打一句 `ClassCastException` 再把值扔掉(那正是「分享点下去
* 什么都没发生」的来处),而本端还要知道「这个 extra 里**到底有没有躺着值**」——那件事纯函数看不到,
* 只能由这里如实报进去(两个 `present` 参数)。
*
* ⚠️ **正文的回落链起点也在这里**:`EXTRA_TEXT` 读不出东西时,`EXTRA_SUBJECT` → `EXTRA_TITLE` 依次
* 顶上([ShareRules.extract]),来源记进 `SharedContent.textSource`。三个都空 / 都读不出来时,这一次
* 分享照样要往下走(走「读不进来」那条如实告知),**绝不静默**。
*
* ## 清理
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
@@ -90,14 +101,24 @@ object ShareIntake {
*/
fun accept(context: Context, intent: Intent) {
val appContext = context.applicationContext
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
val rawStream = intent.streamExtraValue()
// 四个 extra 一律读**原始值**、按**形态**分派(见 [shareStreamExtrasOf] / [shareTextExtrasOf]):
// 挨个 getXxxExtra 去问的话,遇到别的形态只会拿到一行 `ClassCastException` 加一个 `null`
// ——那正是「分享进来什么都没发生」的来处。读不出形态时本端既不猜、也不闭嘴(见下面两个
// `present`):那照样是一次「带着内容迹象却什么都没读进来」。
val rawStream = intent.rawExtra(Intent.EXTRA_STREAM)
val streamExtras = shareStreamExtrasOf(rawStream)
val textExtras = shareTextExtrasOf(
text = intent.rawExtra(Intent.EXTRA_TEXT),
subject = intent.rawExtra(Intent.EXTRA_SUBJECT),
title = intent.rawExtra(Intent.EXTRA_TITLE),
)
val extracted = ShareRules.extract(
action = intent.action,
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
// 正文走回落链:EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(有些发送方不填 EXTRA_TEXT,
// 内容只在后两个里;只读第一个会让那次分享整条静默)。来源记在 `textSource` 里。
text = textExtras.text,
subject = textExtras.subject,
title = textExtras.title,
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
stream = streamExtras.uriForms.firstOrNull(),
streams = streamExtras.uriForms,
@@ -105,6 +126,7 @@ object ShareIntake {
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
// 否则「分享点下去什么都没发生」会从那条缝里回来。
streamPresent = rawStream != null,
textPresent = textExtras.present,
clipUris = intent.clipUris(),
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
mimeOf = { uri ->
@@ -280,14 +302,27 @@ object ShareIntake {
}
/**
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
* 一个 extra 的**原始值**(四个 extra 共用),取不到回 `null`。
*
* ## 为什么读原始值而不是 `getXxxExtra`
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
* `ClassCastException` 再把值扔掉(`String[]` 形态的 `EXTRA_STREAM` 就是这么静默丢掉的),而那
* 正是「分享点下去什么都没发生」的来处。形态分派见 [shareStreamExtrasOf] / [shareTextExtrasOf]。
*
* ## 为什么要 `runCatching`
* `Bundle.get` 第一件事是 `unparcel()`,它会解析别的进程给的字节:里面有个解析不出来的
* `Parcelable` 时它抛 `BadParcelableException`(核对过 AOSP 的 `android/os/BaseBundle.java`:
* defuse 关着时**直接抛出**,开着时也还要看 `Flags.deprecateBundleDefuse()` 那个 flag)。那是
* `RuntimeException`,从 `accept` 冒出去就是 `onCreate` 里的一次崩溃。
*
* ⚠️ 与它相对的是「类型不符」那种:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`
* 并回 `null`(同一个 AOSP 文件),所以那一档**不会**抛异常——但也**读不到值**,于是它只能靠
* 本函数给出的原始值被记成「里面躺着值」(见 [ShareTextExtras.present]),不会被静默当成
* 「什么都没分享」。两件事一起挡住:畸形 bundle 不当崩,读不出来的值不当没发生。
*/
@Suppress("DEPRECATION")
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
private fun Intent.rawExtra(key: String): Any? = runCatching { extras?.get(key) }.getOrNull()
/**
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
@@ -348,3 +383,54 @@ internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
}
/**
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` 三个**原始值** ⇒ 能当正文用的那几段。
*
* ## 为什么也读原始值(而不是 `getCharSequenceExtra` 挨个问)
* 两个理由,第一个是「读得回来」,第二个是「读不回来也要知道有东西」:
*
* 1. `getCharSequenceExtra` 只回「读到 / 没读到」,而本端要按**形态**分派:值可能是 `String`、
* `Spanned`(富文本分享的常态)、`SpannedString`……它们都是 `CharSequence`,但按 `String` 读就
* 直接是 `null`(那正是「分享进来什么都没发生」的来处之一)。这里用 `as?` 一次判完:是
* `CharSequence` 就收下,不是就当没有——**不猜、也不抛**。
* 2. 「三个里**有没有躺着值**」这件事纯函数看不到(它只收到几个 `CharSequence?`),而
* 「有值、只是本端一段都读不出来」与「用户什么都没分享」是**两件事**:前者必须能说出口,
* 否则那三种 extra 一起落空就是一次静默。所以 [present] 由这里如实报进去
* (见 [ShareRules.extract] 的 `textPresent`)。
*
* ## 它抛不抛异常(核对过 AOSP 源码,`sources/android-36.1/android/os/BaseBundle.java`)
* - **类型不符不抛**:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`(`unparcel()` 之后
* 直接 `(CharSequence) o`,失败就 `typeWarning` 记一句 log 再回 `null`)⇒ 所以
* `intent.getCharSequenceExtra(...)` 不会因为「值不是 CharSequence」把 App 打崩,它只是**读不到**。
* 那一档在这里落成 [present] 为真、[text] 为 `null`:既不崩、也不再静默。
* - **畸形 bundle 会抛**:它内部的 `unparcel()` 在别的进程给的 bundle 解析不出来时抛
* `BadParcelableException`(`RuntimeException`)。那一路由 [Intent.rawExtra] 的 `runCatching` 挡住
* ——读不到就当没有,**绝不让异常从 `accept` 冒出去**(那会是 `onCreate` 里的一次崩溃)。
*/
internal data class ShareTextExtras(
/** `EXTRA_TEXT` 读出来的那一段(读不回来是 `null`;空白**原样留着**,由纯函数判它算不算正文)。 */
val text: String?,
/** `EXTRA_SUBJECT` 读出来的那一段。 */
val subject: String?,
/** `EXTRA_TITLE` 读出来的那一段。 */
val title: String?,
/**
* 三个 extra 里**至少一个躺着原始值**——含**读不出类型**的那一种,也含**只是一段空白**的那一种。
*
* 空白也算,是刻意的:它说的是「对方**给了**这个字段」,与「这个字段里有没有可用的正文」是两件
* 事。而 `EXTRA_STREAM` 那边早就是这个口径(原始值只要非 null 就算 `streamPresent`),两个 extra
* 用同一条判据,才不会出现「流的空值会说话、文本的空值却闭嘴」这种要靠运气才发现的差别。
*/
val present: Boolean,
)
/** 见 [ShareTextExtras]:把三个文本 extra 的原始值读成能当正文用的那几段(纯逻辑,可单测)。 */
internal fun shareTextExtrasOf(text: Any?, subject: Any?, title: Any?): ShareTextExtras = ShareTextExtras(
text = (text as? CharSequence)?.toString(),
subject = (subject as? CharSequence)?.toString(),
title = (title as? CharSequence)?.toString(),
// 读不出类型(`42`、别种 `Parcelable`……)也算「里面躺着值」:本端不猜它是什么意思,但不该因此
// 把整次分享当成「什么都没发生」。`null`(连值都没有)才是真的没有。
present = text != null || subject != null || title != null,
)
@@ -21,7 +21,8 @@ import kotlinx.coroutines.flow.asStateFlow
* 一个布尔量在那里必然被旧实例的 `onStop` 记成「后台」,于是用户正看着聊天页,她一句话却弹出
* 通知来。按实例登记就没有这个方向性问题——新实例先加进来、旧实例后移出去,集合始终非空。
* 集合按 `ConcurrentHashMap` 语义(`Activity` 不重写 `equals` ⇒ 就是**同一实例**),
* 且读写可能不在同一线程上(帧在主线程的收集里读),所以用并发容器而不是普通 `MutableSet`。
* 且读写可能不在同一线程上(她的话那条通路是在**进程级订阅自己的调度器**上读它的,见
* `ProactiveFrameIntake`),所以用并发容器而不是普通 `MutableSet`。
*
* ## 它**不是**「App 有没有活着」
* 后台保活(前台服务)让进程活着、连接继续收帧,而本类在这里恒报「不在前台」——那正是通知要
@@ -11,25 +11,70 @@ import top.yeij.cyrene.app.MainActivity
import top.yeij.cyrene.core.keepalive.notificationsAllowed
/**
* 「她主动说的话」那条通知的渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做过的静音
* 设置会失效(与 `keep_alive` 同一条纪律)。
* 「她主动说的话」那条通知的渠道 id —— **弹横幅**的那一条([PROACTIVE_IMPORTANCE])。
*
* ⚠️ **这里是 `proactive_v2`,不是最初的 `proactive`**:渠道的**重要级一旦创建就改不了**
* (Android 的设计 —— 只有用户能改,App 改不动)。2026-10-07 主人明确要求「要横幅」,而把重要级
* 从 DEFAULT 提到 HIGH **对已经建过旧渠道的设备不生效** ⇒ 只能换一个新 id,让系统按新重要级
* 重新建一条。旧渠道由 [ensureProactiveChannel] 顺手删掉(它的重要级改不动,留着只会让系统设置里
* 多一条永远不再出现的条目)。
*
* **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是
* [PROACTIVE_IMPORTANCE](**要提醒人**)。两者混在一条渠道里,就只能在「保活开始响铃」与
* 「她的话不响铃」之间二选一——那正是「一个渠道只能是其中一件事」的由来。
* [PROACTIVE_IMPORTANCE]。两者混在一条渠道里,就只能在「保活开始响铃」与「她的话不响铃」之间
* 二选一——那正是「一个渠道只能是其中一件事」的由来。
*
* 与 [QUIET_PROACTIVE_CHANNEL_ID] **也是两条**,理由与上面那条一模一样:横幅由渠道重要级决定,
* 而重要级建完就改不了 ⇒ 设置页那个「免打扰」开关**不可能**靠运行时改设置实现,只能按开关选一条
* 渠道发(见 [proactiveChannelId])。
*/
internal const val PROACTIVE_CHANNEL_ID = "proactive"
internal const val PROACTIVE_CHANNEL_ID = "proactive_v2"
/**
* 渠道重要级:`DEFAULT`(有提示音、不弹全屏横幅)。
* 「她主动说的话」在**免打扰**下走的那条渠道 id:重要级是 [QUIET_PROACTIVE_IMPORTANCE]
* (有提示音、通知栏有条目、**不弹横幅**)。
*
* 这一条**是**要来提醒人的(她主动找我说话),所以不用 LOW;也不升到 HIGH——HIGH 会在屏幕顶部
* 弹横幅盖住用户正在做的事,而这句话并没有急到那个程度。
* 为「免打扰」单独建**第二条**渠道,是因为**重要级建完就改不了**(理由见 [PROACTIVE_CHANNEL_ID])
* ——想「只关横幅」就只有换渠道这一条路。
*
* 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改),所以这里必须是
* 最终值。`ProactiveNotificationTest` 把这两个值钉住。
* ⚠️ 刻意**不用** `setSilent(true)`:那是**完全静音**(连提示音都没),而用户要的语义是
* 「只在通知栏提示,别在屏幕顶部弹横幅」——两者之间差着一个提示音,不是同一件事。
*/
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
internal const val QUIET_PROACTIVE_CHANNEL_ID = "proactive_quiet"
/**
* 上一个渠道 id([PROACTIVE_CHANNEL_ID] 的前身)。**只在建渠道时用于删除**:它的重要级是
* DEFAULT 且改不动,留着毫无用处。
*/
internal const val LEGACY_PROACTIVE_CHANNEL_ID = "proactive"
/**
* 弹横幅那条渠道的重要级:**`HIGH`**(屏幕顶部弹横幅 + 提示音)。
*
* 这一条**是**来提醒人的 —— 她主动找我说话的那一刻,我显然不在跟她聊天(App 不在前台),
* 正是最需要在屏幕顶部弹一下的场景。DEFAULT 只会响一声、安静躺在通知栏里,很容易错过
* (主人 2026-10-07 的原话:「我在其他 APP 时没看到横幅」)。
*
* 想安静的用户可以在系统设置里单独把这条渠道调低、或关掉它的横幅 —— 那是用户的权利,
* 不是我们对他的默认;应用内的那条捷径是设置页的「免打扰」,它把通知改发到
* [QUIET_PROACTIVE_CHANNEL_ID]。
*
* ⚠️ 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改)⇒ **改这个常量
* 对已建过渠道的设备不生效**,必须同时换 [PROACTIVE_CHANNEL_ID]。`ProactiveNotificationShapeTest`
* 把这两个值钉住。
*/
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_HIGH
/**
* 免打扰那条渠道的重要级:**`DEFAULT`**(提示音 + 通知栏条目,**没有横幅**)。
*
* `HIGH`(4) 才是屏幕顶部弹横幅的门槛,所以 DEFAULT 正是「她的话照旧说、只是不盖住我正在做的事」
* 那一档。
*
* ⚠️ 与上面那条同理:**改这个常量必须同时换 [QUIET_PROACTIVE_CHANNEL_ID]**,否则对已经建过它的
* 设备一个字节都不生效。这个方向(往安静改)出错时用户的感受只是「开关按了没反应」——不会有任何
* 报错,所以这条纪律只能靠常量与测试钉住。
*/
internal const val QUIET_PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */
internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
@@ -40,6 +85,67 @@ internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
*/
internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话"
/**
* 免打扰那条渠道的渠道名。**必须一眼看出与 [PROACTIVE_CHANNEL_NAME] 的区别**:系统设置的渠道列表
* 里这两行会并排出现,用户就是靠这一行判断「哪条会弹横幅」的——同名的话两条渠道就白分了
* (他只能点进去、一条条试着看)。
*/
internal const val QUIET_PROACTIVE_CHANNEL_NAME = "她主动说的话(免打扰:只在通知栏)"
/** 弹横幅那条渠道的描述(点进渠道详情才看得到)。 */
internal const val PROACTIVE_CHANNEL_DESCRIPTION = "她主动找你说话时的通知"
/**
* 免打扰那条渠道的描述。把「不弹横幅」与「还有提示音」都说清:用户点进来想知道的是**它到底静到
* 什么程度**,而「免打扰」四个字很容易被读成「完全没有声音」——那是另一件事(也正是用户会去系统
* 设置里翻、翻完还是不确定的那一件事)。
*/
internal const val QUIET_PROACTIVE_CHANNEL_DESCRIPTION =
"她主动找你说话时的通知(只在通知栏提示、不弹横幅;仍有提示音)"
/**
* 一条 proactive 渠道的完整定义:**id / 渠道名 / 重要级 / 描述一次给全**。
*
* 为什么要打包成一个值:这四样必须**成对地**跟着「免打扰」这个布尔走(换了 id 却没换重要级 =
* 开关没生效;换了重要级却没换 id = 对老设备没生效)。散成四个各自 `if (quiet)` 就是四份口径,
* 而它们分叉时的症状全都是「用户按了开关,什么都没变」——最难被发现的那种坏法。
*/
internal data class ProactiveChannelSpec(
val id: String,
val name: String,
val importance: Int,
val description: String,
)
/**
* 「免打扰」→ 这一次该用哪条渠道 —— **纯函数**(不含任何 Android 调用,两档可穷举)。
*
* @param quiet 设置页那个「免打扰」开关**当下的值**(`false` = 都弹,也是默认)。
*/
internal fun proactiveChannelSpec(quiet: Boolean): ProactiveChannelSpec = if (quiet) {
ProactiveChannelSpec(
id = QUIET_PROACTIVE_CHANNEL_ID,
name = QUIET_PROACTIVE_CHANNEL_NAME,
importance = QUIET_PROACTIVE_IMPORTANCE,
description = QUIET_PROACTIVE_CHANNEL_DESCRIPTION,
)
} else {
ProactiveChannelSpec(
id = PROACTIVE_CHANNEL_ID,
name = PROACTIVE_CHANNEL_NAME,
importance = PROACTIVE_IMPORTANCE,
description = PROACTIVE_CHANNEL_DESCRIPTION,
)
}
/**
* 发通知那一刻用哪条渠道 id —— **纯函数**,就是「同一个开关、两条渠道」这件事的全部落地形状。
*
* 单独再露一层是因为调用方要的正好只是这个 id(`NotificationCompat.Builder(context, ???)`),
* 而测试要问的也只是这一个问题:「开关两档各发去哪条」。
*/
internal fun proactiveChannelId(quiet: Boolean): String = proactiveChannelSpec(quiet).id
/**
* 点通知进来时那个「切到主会话」的 Intent extra。
*
@@ -52,15 +158,20 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR
/**
* 弹一条「她主动说的话」的通知。
*
* 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、去重与 id 的派生
* 都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)——本函数被调用就
* 意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
* 只做四件事:按 [quiet] 选渠道、建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、
* 去重与 id 的派生都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)
* ——本函数被调用就意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
*
* ## 为什么 id 是入参而不是这里恒定一个
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
*
* ## 免打扰只换渠道,别的一个字节都不动
* [quiet] 为真时整条通知除了「发去哪条渠道 / 7.x 的优先级」以外**逐字节不变**:同一个标题、同一
* 段正文、同一个通知号、同一次点击行为。用户要的是「别在我正做事的时候盖住屏幕」,不是「别让我
* 知道」——所以什么都不该少,只是她的话安静地躺在通知栏里。
*
* ## 没授通知权限时什么都不做
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
* 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有
@@ -74,11 +185,24 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR
*
* @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动:
* 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。
* @param quiet 「免打扰」开关**当下的值**:`true` ⇒ 发到 [QUIET_PROACTIVE_CHANNEL_ID](有提示音、
* 不弹横幅);`false` ⇒ 发到 [PROACTIVE_CHANNEL_ID](弹横幅)。**刻意不给默认值**:调用方忘写
* 这一位时症状是「开关按了没反应」,那种坏法没有任何报错——让编译器拦住它。
* 值由调用方每次**现读**(见 `AppContainer` 里 `post` 那个 lambda),所以开关一改不必重启 App,
* 下一条通知就走新渠道。
*/
internal fun postProactiveNotification(context: Context, text: String, notificationId: Int) {
internal fun postProactiveNotification(
context: Context,
text: String,
notificationId: Int,
quiet: Boolean,
) {
if (!context.notificationsAllowed()) return
ensureProactiveChannel(context)
// 选渠道与建渠道用的是**同一个** spec:两处各自 `if (quiet)` 就会有一条渠道永远建不出来,
// 而它的症状是「开了免打扰之后她再也不通知我了」(Android 8+ 对不存在的渠道是静默丢弃)。
val channel = proactiveChannelSpec(quiet)
ensureProactiveChannel(context, channel)
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
@@ -91,7 +215,7 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(context, PROACTIVE_CHANNEL_ID)
val notification = NotificationCompat.Builder(context, channel.id)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle(PROACTIVE_NOTIFICATION_TITLE)
.setContentText(text)
@@ -102,7 +226,10 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
// 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
// 横幅:Android 8+ 由**渠道重要级**决定(上面那条 spec 选出来的那条渠道),这一句管的是
// 7.x 及更早(那边还没有渠道,优先级就是全部)。两处必须同向——免打扰那一档在这里也得
// 降回 DEFAULT,否则老设备上「开着免打扰却照样弹横幅」。
.setPriority(if (quiet) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH)
.build()
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
@@ -111,20 +238,40 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
}
/**
* 建(或更新)渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的渠道列表里不会平白多出
* 一条「她的话」。
* 建(或更新)**这一次要用的那一条**渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的
* 渠道列表里不会平白多出一条「她的话」。
*
* ## 为什么只建「这一次要用的那一条」,而不是两条都建
* 「免打扰」决定的是**下一条**通知发去哪条渠道,而渠道只需要在 `notify` 之前存在。两条都建的话,
* 一个从没开过免打扰、将来也不打算开的用户,系统设置里会平白多出一条他从没见过的「她主动说的话
* (免打扰:只在通知栏)」——那是我们替他做的选择,也算脏数据。所以这里只 ensure 被选中的那一条。
*
* ⚠️ 但**必须在 `notify` 之前** ensure:Android 8+ 上目标渠道不存在时,那条通知是被系统**静默
* 丢弃**的(不报错、不打日志、只是什么都没有)。这正是「只建一条」这个省法的代价 —— 它靠
* [postProactiveNotification] 里「选渠道」与「建渠道」用的是**同一个** [ProactiveChannelSpec]
* 兜住(两处各自 `if (quiet)` 就会有一条渠道永远建不出来,而症状是「开了免打扰之后她再也不
* 通知我了」——用户只会以为是她不说话了)。
*
* ## 另一条渠道**不删**
* 它是「这一次没选」,不是「以后不再用」:用户来回切开关时,他在系统设置里对另一条调过的横幅 /
* 静音设置得留着(删掉 = 把他的选择一起删掉,还得重新建一条空白的)。唯一会删的是
* [LEGACY_PROACTIVE_CHANNEL_ID] —— 那条 id 本端**再也不会发**,而它的重要级又改不动,留着只会
* 在系统设置里多一条永远不再出现的条目(用户可能在那儿调半天、却发现怎么调都没变化)。
*
* 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效**
* ——它已经在 [PROACTIVE_IMPORTANCE] 上定死。
* ——它已经在 [PROACTIVE_IMPORTANCE] / [QUIET_PROACTIVE_IMPORTANCE] 上定死(渠道名与描述此后仍
* 可以改,重要级改不了)。
*
* @param spec 这一次要用的那条渠道(由 [proactiveChannelSpec] 按「免打扰」开关选出来)。
*/
private fun ensureProactiveChannel(context: Context) {
val channel = NotificationChannel(
PROACTIVE_CHANNEL_ID,
PROACTIVE_CHANNEL_NAME,
PROACTIVE_IMPORTANCE,
).apply {
description = "她主动找你说话时的通知"
private fun ensureProactiveChannel(context: Context, spec: ProactiveChannelSpec) {
val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply {
description = spec.description
setShowBadge(true)
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
// 旧的 DEFAULT 渠道删掉:重要级改不动,留着只会在系统设置里多一条永远不再出现的条目
// (用户可能在那儿调半天、却发现怎么调都没变化)。删一条不存在的渠道是幂等的。
manager.deleteNotificationChannel(LEGACY_PROACTIVE_CHANNEL_ID)
}
@@ -32,10 +32,12 @@ enum class ProactiveNotifyOutcome {
/**
* 「她主动说的话」的通路(W53N):**判据 + 去重 + 落地**收在一处,Android 那一步由 [post] 注入。
*
* ## 它凭什么能看见两条路
* ## 谁把帧喂进来(W57 起)
* 后端两条路(实时帧 `proactive_message`、落库帧 `message_persisted{proactive:true}`)在客户端
* 汇于一处:`SessionsViewModel` 的帧订阅(它持有整个会话列表,两条帧它本来就都在消费)。本类
* 因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android。
* 汇于一处:**进程级**的帧消费点 `app.ProactiveFrameIntake` 订着那条共享连接,把这两种帧折成下面
* [onProactiveFrame] 的四个入参喂过来(同一条消息无论走哪条路、到几次,都只经过这一处)。
* 本类因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android——测试里传个
* 假的 [post] 就能把它整条驱动起来。
*
* ## 它绝不碰连接
* 连接的所有权在登录态(`SessionsViewModel` 那处 `LoggedIn → connect()`)。本类只在帧**已经到达
@@ -8,8 +8,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
@@ -72,6 +78,23 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
else -> InterruptMode.AppendQueue
}
/**
* 「免打扰」([AppPrefs.proactiveQuiet])没有落盘值时的取值 —— **`false` = 都弹**(横幅 + 提示音
* + 通知栏条目)。老安装、或从没碰过这个开关的用户走的都是它。
*
* 两条路必须落在**同一个**值上:[AppPrefs] 那个数据类的默认参数(进程刚起来、还没读到盘时用),
* 与 [toProactiveQuiet](盘上没有这个键时用)。分叉的症状是「开关显示关着、行为却是安静的」
* ——一个默认就安静的 App 会让人以为通知坏了。
*/
internal const val PROACTIVE_QUIET_DEFAULT = false
/**
* 落盘布尔 → 免打扰开关(`AppSettings` 的 `proactive_quiet` 键读取口;抽成顶层函数是为了能单测
* ——`AppSettings` 本体要 Android + DataStore,单测里起不来)。缺省 / 盘上没有这个键
* ⇒ [PROACTIVE_QUIET_DEFAULT]。
*/
internal fun Boolean?.toProactiveQuiet(): Boolean = this ?: PROACTIVE_QUIET_DEFAULT
/**
* App 级偏好(非机密)。
*
@@ -81,6 +104,10 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
* @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
* `core.keepalive.shouldKeepAliveRun`)
* @param proactiveQuiet 免打扰:她主动找你说话**只在通知栏提示,不在屏幕顶部弹横幅**(**默认
* `false` = 都弹**)。⚠️ 它的语义**不是静音**:提示音与通知栏条目照旧,只是不盖住用户正在做的
* 事。落地形状是**换一条渠道**(见 `core.notify.proactiveChannelId`)——横幅由渠道重要级决定,
* 而重要级建完就改不了,所以只能两条渠道切着用
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
@@ -88,6 +115,7 @@ data class AppPrefs(
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
val keepAliveEnabled: Boolean = false,
val proactiveQuiet: Boolean = false,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
@@ -97,6 +125,13 @@ class AppSettings(context: Context) {
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
/**
* 与 `AppSettings` 同寿的收集 scope —— 也就是**与进程同寿**(`AppContainer` 在
* `Application.onCreate` 里建它)。刻意**不取消**:[current] 的下一个读者可以是任何时刻的
* 任何一次通知落地,没有比进程结束更晚的时刻需要它。
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val data: Flow<AppPrefs> = store.data.map { prefs ->
AppPrefs(
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
@@ -104,9 +139,31 @@ class AppSettings(context: Context) {
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
keepAliveEnabled = prefs[KEY_KEEP_ALIVE] ?: false,
proactiveQuiet = prefs[KEY_PROACTIVE_QUIET].toProactiveQuiet(),
)
}
/**
* [data] 的**热副本**:进程里任何地方都读得到「当下是什么偏好」。
*
* ## 为什么需要它
* 发通知那一刻**没有挂起上下文**:帧的落地是一次同步回调(`ProactiveNotifier.post` 不是
* `suspend`,也不该是——它是连接那条已建立的流水线上的最后一跳),而 DataStore 的读取是挂起
* 的。所以只能由这里常驻订阅、把最近一次读数摆在一个同步读得到的地方(`StateFlow.value`)。
*
* ## 为什么是 `Eagerly`
* 它的读者(发通知那条路)随时可能来,而「等有人订阅了再开始读」意味着第一次落地时读到的是
* 初始值。`Eagerly` 让这一次读盘在设置对象构造时就发生 —— 与 UI 那边(`CyreneRoot` /
* `SettingsScreen` 各自 `collectAsState`)读的是同一份上游数据,没有多出一次磁盘 IO 的量级。
*
* ⚠️ 它有一个**毫秒级**的不准确窗口:进程刚起来、第一次读盘还没回来时,[StateFlow.value] 是
* [AppPrefs] 的默认值(免打扰 = false ⇒ 弹横幅)。要撞上它,得在「本对象构造完 → DataStore
* 第一次读完」之间就有一条她的主动消息走完建连、认证、收帧(`ProactiveFrameIntake`)——
* 实际上到不了那么快。**代价封顶是「开了免打扰的用户在冷启动瞬间多看到一次横幅」**,
* 而不是丢通知。
*/
val current: StateFlow<AppPrefs> = data.stateIn(scope, SharingStarted.Eagerly, AppPrefs())
suspend fun setThemeMode(mode: ThemeMode) {
store.edit { it[KEY_THEME_MODE] = mode.name }
}
@@ -127,6 +184,15 @@ class AppSettings(context: Context) {
store.edit { it[KEY_KEEP_ALIVE] = enabled }
}
/**
* 写「免打扰」。**写这一处就够了**:发通知那条路每次现读 [current](见它的 KDoc),所以这里
* 一落盘、DataStore 一发出新值,**下一条**通知就走新渠道——不必重启 App,也不必在这里再去
* 通知谁。
*/
suspend fun setProactiveQuiet(enabled: Boolean) {
store.edit { it[KEY_PROACTIVE_QUIET] = enabled }
}
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
@@ -139,5 +205,6 @@ class AppSettings(context: Context) {
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled")
val KEY_PROACTIVE_QUIET = booleanPreferencesKey("proactive_quiet")
}
}
@@ -0,0 +1,41 @@
package top.yeij.cyrene.data.api
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url
/**
* 语音副本的字节读取(契约 **v0.79 已实现**,api-client.md §1.2 **U19**
* `GET /api/v1/media/{name}`)。
*
* ## 鉴权:**必须** Bearer `owner|admin`
* 与 `GET /api/v1/documents/{name}` 同一套门,与图片读侧「128-bit 名即凭据」**刻意相反**
* ——语音是私密内容,那个 URL 不能直接贴进 `<audio src>`。Bearer 不在本接口上做任何事:
* [ApiClientFactory.create] 按 **OkHttpClient** 挂 `AuthInterceptor`,只要本接口的实例是从
* 那里建的(`AppContainer.mediaApi` 的写法),请求就自带令牌、并享受 401 单飞续期。
*
* ## 为什么参数是一整条 URL 而不是 `@Path name`
* 契约把相对路径 `/api/v1/media/<name>` 定成**服务端唯一的派生字段**(`voice.url`),并明写
* 客户端**绝不自己拼路径**。所以调用方把**服务端给的那条路径接到当前服务器地址上**
* (纯函数 `domain.chat.voiceMediaUrl`)再传进来——本接口一个字都不拼,只发出去。
*
* @see MediaRepository
*/
interface MediaApi {
/**
* 取一条语音副本的字节。
*
* 响应**不是信封**(就是音频本身),所以返回 [ResponseBody] 由仓库层读字节并负责关闭。
* [Streaming] 让 Retrofit 不把响应体一次性读进内存。
*
* ⚠️ 服务端的 `416`(`Range` 不可满足)是**标准库直写**的 `text/plain` + `Content-Range`,
* **不套** `{"error":{…}}`(契约 §0.3 / §4.5)——本端不发 `Range`、也不拿 `error.code`
* 去解析它,`ApiErrorMapper` 对这种非信封响应的处理是「说不出就不编」。
*/
@Streaming
@GET
suspend fun download(@Url url: String): Response<ResponseBody>
}
@@ -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>
}
@@ -8,6 +8,10 @@ import kotlinx.serialization.Serializable
*
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
*
* [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
* `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
* (前提与理由写在 [voice] 的 KDoc 里)。
*/
@Serializable
data class HistoryMessageDto(
@@ -59,6 +63,38 @@ data class HistoryMessageDto(
* 回放 / `channel_persisted`(本端不消费最后一条)。
*/
val turnId: String? = null,
/**
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]);
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事。
*
* ## 判据是**两个**问题,别再合成一个
* - 「这条**来自**一段声音吗」= 这个键在不在(`domain.chat.hasVoice`);
* - 「要不要画语音形态」= 键在 **且** `status != "failed"`(`domain.chat.isVoiceMessage`)。
* 后端 M57-M59 的出站合成降级行(`{source:"tts", status:"failed", fallbackReason}`)与入站的
* 转写失败行都是**键在、但没有可播的语音**:正文照常按文字渲染,另加一行灰色小标
* (`domain.chat.voiceFallbackNote`),**不画**语音形态。按旧的单条判据(键在即语音)它们会
* 被画成一个点下去必然播不了的气泡。
*
* ## 覆盖率(契约 v0.97):**全部读面都带**,第一条帧就带
* `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
* `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与
* `message_edited` 广播帧,**外加** WS 握手 `history` 回放与 `message_persisted` /
* `channel_persisted` 落库帧——后三处由后端 M44(`fb55769`)修好:落库顺序改为
* **INSERT → 写 `voice` 列 → notify**,于是语音消息的**第一条**落库帧就带 `voice`
* (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。
*
* 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底:
* `voice` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。
*
* ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify;
* ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
* 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。
*
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
* 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一
* 诚实的表达(与 [documents] 回一条 `unreadable` 不同)。
*/
val voice: VoiceDto? = null,
)
/**
@@ -0,0 +1,86 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**,
* `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。
*
* ## 🎤 判据 = 本键存在 **且** `status != "failed"`
* `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。
* 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]`
* 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。
*
* 键在只说明「这条来自一段声音」;要不要画语音形态还看 `status`(见下)。
* 判据落在一处:`domain.chat.isVoiceMessage`(`domain.chat.hasVoice` 答的是前半个问题)。
*
* ## 三态 `status`(闭集)
* `transcribed`(正文即转写文本;出站方向 = 合成成功)/ `empty`(转写成功但没识别出文字)/
* `failed`(**没有可播的语音**——入站没拿到转写文本,出站是合成降级回文字)。客户端**按 `status`
* 分支,绝不按正文文案分支**;设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 /
* 限流 / 超时 / 格式不支持 / 无字节一律只进服务端日志),所以本端**不猜**细分。
*
* ## `fallbackReason` 只在 `failed` 时有
* 出站方向(`source = "tts"`)合成降级时,服务端在这个键里给出降级原因;本端**原样**小字展示
* (`domain.chat.voiceFallbackNote`),**不猜闭集**——取值集合后端尚未定稿,见那个函数的 KDoc。
*
* ## `name` / `mime` / `size` / `url` 四者同生共死
* 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。
* 反过来说:**四个键没凑齐就不要画播放控件**——不能给一个点下去必然失败的按钮。
* 判据落在一处:`domain.chat.voiceAudio`。一个真实的无副本例子是微信(`weixin-ilink`):
* 平台自带转写就**不白下载那份音频**,于是 `source` 是 `platform` 而四个键全缺席
* (**只有 🎤 标记**,不可回放)。
*
* ## `durationMs` 只有渠道如实给出时才有
* 服务端「读不出来就省略键,**绝不估算**」。本端同理:**缺席就不显示时长**
* (不拿 size / 码率反算,也不显示 `0:00` 冒充)。所以它是可空的,且 `<= 0` 一并当缺席
* (见 `domain.chat.voiceDurationLabel`)。
*
* ## `url` 是**服务端相对路径**,客户端绝不自己拼
* 取值形如 `/api/v1/media/<name>`,由服务端一处拼出(**唯一派生字段**)。
* 客户端要取字节时把它接到**当前服务器地址**后面(`domain.chat.voiceMediaUrl`)——
* 与图片那条「保留 path、替换 host」同一取向,但这里服务端给的就是相对路径,不需要换 host。
*/
@Serializable
data class VoiceDto(
/**
* `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的
* `voice_item.text`)/ `"tts"`(我们自己合成的出站语音,后端 M57-M59)。
* 见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
*
* 接收侧**不按 `source` 分支**:合成成功的那条与入站语音同形(`status = transcribed` +
* 四键副本),走同一条渲染路径。
*/
val source: String = "",
/**
* `"transcribed"` / `"empty"` / `"failed"`(闭集三态,见 `domain.chat.VoiceStatus`)。
*
* 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值
* (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。
*
* ⚠️ 只有 `"failed"` 是**没有可播的语音**的那一态(`domain.chat.isPlayableVoice`);认不出的
* 取值一律当可播(fail-open,理由写在那个函数的 KDoc 里)。
*/
val status: String = "",
/** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */
val name: String? = null,
/** 副本的 MIME(按存储名扩展名映射)。与 [name] / [size] / [url] 同生共死。 */
val mime: String? = null,
/** 副本的字节数。与 [name] / [mime] / [url] 同生共死;**不用它反算时长**。 */
val size: Long? = null,
/** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */
val durationMs: Int? = null,
/**
* 合成降级的原因(后端 M57-M59 预告,`devdocs/02` 已登记;契约 **v0.98 的正式回执尚未到**)。
*
* 只在 `status == "failed"` 时有意义,其余状态下服务端不会下发(`omitempty`);入站转写失败
* 也不带它——入站的失败原因由正文那行诚实提示承载。
*
* 本端**原样**小字展示(`domain.chat.voiceFallbackNote`),**不猜闭集、不做映射**:取值集合
* 后端还没定(`tts_timeout` 之类是我们看到的预告示例,不是契约)。缺席 / 空白时那一行小标
* 退化成固定的一句(`VOICE_FALLBACK_NOTE`),不白屏。
*/
val fallbackReason: String? = null,
/** 副本的服务端相对路径(`/api/v1/media/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
val url: String? = null,
)
@@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext
* 三条读取出口共用同一个端点,只是给的查询参数不同:
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
* - [search]:`q` 会话内搜索(也是倒序);
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
*
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
*/
@@ -87,7 +88,9 @@ class MessageRepository(
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
*
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
* 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
*/
suspend fun loadAfter(
sessionId: String,
@@ -100,6 +103,87 @@ class MessageRepository(
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* **增量追尾**(W77):从 [after] 起把某会话的新消息一直翻到尾巴,最多 [maxPages] 页。
*
* 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。
*
* ## 什么时候只能说 `complete = false`
* 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于:
* `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被
* 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次也问不到
* (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。
*
* 要紧的是**切点**:只有当服务端的 `LIMIT` 在某一毫秒中间切过一刀,才会留下追不回来的尾巴。
* 客户端看不见那一刀落在哪儿,只知道自己**拿过满页**就等于「发生过一次切点」。所以这里只有一条
* 不变式,下面几档都是它的推论:
*
* > `complete = true` **当且仅当整个追尾从未拿过满页**。
*
* - **第一页就是空页 ⇒ `true`**:一条新消息都没有,没有可少给的;
* - **第一页就不满 ⇒ `true`**:`LIMIT` 没切到任何东西,`> cursor` 的行全在这一页里;
* - **拿过整页之后,下一页是空页 ⇒ `false`**:那次空页分不出「干净的尾巴」与「空页底下还压着
* 同一毫秒的几条」;
* - **拿过整页之后,下一页不满 ⇒ 也是 `false`**:不满只说明「`> cursor` 的行就这么多了」,对
* 切点那一毫秒还有没有行**一无所知**。例如积压 `m1@500 m2@500 m3@500 m4@600`、页宽 2:
* 第一页给 `m1 m2`(切点正好劈开 500 那一毫秒),第二页 `after=500` 只给 `m4@600`、不满页,
* 而 `m3@500` 既不 `> 500`、也就永远拿不到;
* - **翻满 [maxPages] ⇒ `false`**。
*
* 代价:积压一到页宽(默认 50 条)就回落整表。这与「翻满 [maxPages] ⇒ 回落」同类——那时增量
* 已经取回 ≥50 行,多问一次整表是边际成本,也正合 [MAX_INCREMENT_PAGES] 那句「积压多到增量不再
* 便宜」。主路径(几条新消息、第一页就不满)不受影响。
*
* 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。
*
* 不传 `platform`:未读面上的口径是服务端 `count(created_at > read_at)`(`unreadCountQuery`,
* 不带平台过滤),按平台过滤会让本地数出来的条数**与服务端的 `unreadCount` 不同源**。
*
* @param after 本端**已见**的最大 `createdAt`(毫秒);服务端返回严格新于它的消息,升序。
* @param maxPages 最多翻几页(默认 [MAX_INCREMENT_PAGES])。用尽即 `complete = false`:
* 一次静默刷新不该为了补一个角标翻十几页。
*/
suspend fun loadIncrement(
sessionId: String,
after: Long,
pageSize: Int = DEFAULT_PAGE_SIZE,
maxPages: Int = MAX_INCREMENT_PAGES,
platform: String? = null,
): Result<MessageIncrement> = withContext(dispatcher) {
val size = pageSize.coerceIn(1, MAX_PAGE_SIZE)
val byId = LinkedHashMap<String, HistoryMessageDto>()
var cursor = after
// 只要翻到过一整页,就说明服务端的 LIMIT 切过一刀,那一刀可能劈开了一毫秒(见 KDoc)。
// 此后无论下一页是空页还是不满页,都说不出「追到了尾巴」。
var chasedFullPage = false
fun done(at: Long, complete: Boolean) = Result.success(
MessageIncrement(messages = byId.values.sortedWith(MessageOrder), cursor = at, complete = complete),
)
var page = 0
while (page < maxPages.coerceAtLeast(1)) {
page++
val raw = apiCall { api.listMessages(sessionId, size, before = null, platform = platform, after = cursor) }
.getOrElse { return@withContext Result.failure(it) }
val paged = toPagedMessages(raw, size)
// 空页有两种含义,客户端分不出来:第一页就空 = 真的没有新的;拿过一整页之后空 = 可能还有
// 同一毫秒的几条卡在游标上(服务端只给严格大于)。后者只能说「没追完」。
if (paged.messages.isEmpty()) {
return@withContext done(cursor, complete = !chasedFullPage)
}
// 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。
paged.messages.forEach { byId[it.id] = it }
val newest = paged.newestCreatedAt ?: return@withContext done(cursor, complete = false)
// 不满页只说明「> cursor 的行就这么多了」,对切点那一毫秒一无所知 —— 与空页档同一个洞,
// 同样是拿过整页之后就不作数。
if (!paged.hasMore) return@withContext done(newest, complete = !chasedFullPage)
cursor = newest
chasedFullPage = true
}
done(cursor, complete = false)
}
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
* 完整消息 DTO**。
@@ -152,6 +236,15 @@ class MessageRepository(
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
const val DEFAULT_SEARCH_PAGE_SIZE = 30
/**
* [loadIncrement] 一次最多翻几页(500 条)。
*
* 这个上限只为一件事存在:一次**后台**的顺手对齐不该翻十几页。越过它就说明积压已经多到
* 「增量」不再便宜,那时整表(`GET /sessions` 一次拿回权威的 `unreadCount` 与预览)
* 反而更划算——调用方据此回落,见 `SessionsViewModel.flushPendingIncrements`。
*/
const val MAX_INCREMENT_PAGES = 10
private val EMPTY_PAGE = PagedMessages(
messages = emptyList(),
hasMore = false,
@@ -190,3 +283,18 @@ private fun toPagedMessages(
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)
}
/**
* [MessageRepository.loadIncrement] 的结论:升序增量消息 + 推进后的游标 + 有没有真的追到尾巴。
*
* [complete] 为 `false` 时 [messages] 仍是**已拿到的全部**(不丢),但调用方不能把它当成
* 「这之后就没有了」——未读面上那种情况要回落到整表,见 `SessionsViewModel.flushPendingIncrements`。
*
* 放在这里而不是 `PagedMessages.kt`:`PagedMessages` 是纯分页形状(一页的元数据),
* 这个类型是 [MessageRepository] 翻页循环的产物,跟着它的生产者走。
*/
data class MessageIncrement(
val messages: List<HistoryMessageDto>,
val cursor: Long,
val complete: Boolean,
)
@@ -0,0 +1,164 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.MediaApi
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.VoiceAudio
import top.yeij.cyrene.domain.chat.readBounded
import top.yeij.cyrene.domain.chat.voiceMediaUrl
import java.io.File
/** 本端一次读得下、也放得动的语音副本上限,超过就如实说「放不了」(见 [VoiceTooLargeException])。 */
class VoiceTooLargeException : Exception("voice copy too large")
/** 服务端只给了相对路径,而本端目前拿不到一个能接上去的服务器地址(未登录 / 地址为空)。 */
class VoiceUrlUnavailableException : Exception("voice media url unavailable")
/**
* 语音副本的取字节层(`GET /api/v1/media/{name}` 的第一个调用方)。
*
* 与 [DocumentRepository] 同一个形状:响应不是信封(就是音频本身),所以不用 `apiCall`——
* 成功即读字节,失败才走 [ApiErrorMapper](`404 media_not_found` / `401`)。
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
*
* [url] 由调用方拼好(服务端给的**相对路径** + 当前服务器地址,纯函数
* [voiceMediaUrl]):本层**不自己拼路径**——契约把那条路径定成服务端唯一的派生字段。
* 因此这里也不做形态校验:拼不出可信地址的调用方根本不会走到这里([VoiceStore] 先判)。
*/
class MediaRepository(
private val api: MediaApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 取回一条语音副本的字节。
*
* 读到超过 [MAX_VOICE_BYTES] 就判 [VoiceTooLargeException]:语音副本本就不该大,
* 而「一次读进内存」这条路上必须有一把尺子(与文档 / 图片同一个做法)。
* **0 字节不算成功**——那会让下一次播放「读缓存命中」一份什么都没有的文件。
*/
suspend fun download(url: String): Result<ByteArray> = withContext(dispatcher) {
try {
val response = api.download(url)
val body = response.body()
when {
response.isSuccessful && body != null -> body.use { stream ->
val bytes = readBounded(stream.byteStream(), MAX_VOICE_BYTES)
when {
bytes == null -> Result.failure(VoiceTooLargeException())
bytes.isEmpty() -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
else -> Result.success(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 companion object {
/**
* 上限取附件那一把尺子(20 MB):语音副本远小于它,而这里要防的只是「一次读进内存」
* 这条路上没有上界。**不新造一个数字**——多一个自定义上限就多一处会漂移的口径。
*/
val MAX_VOICE_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
}
}
/**
* 语音副本的「取字节 + 落盘缓存」层(播放前那一步的**唯一**取数入口)。
*
* ## 缓存:`<cacheDir>/media/`,复用 [DocumentCache] 的三条规则
* - **名字规则 / 命中判定 / 原子落盘**全部复用 [DocumentCache](同一条 `isSafeName`、
* `isHit`(非空才算命中)、`write`(先写 `.part` 再改名))——只在缓存根下换一个目录名。
* - **特意不落进 `documents/`**:那个目录是 `FileProvider`(`res/xml/file_paths.xml`)**唯一**
* 暴露出去的根,「用其它应用打开 / 保存到下载目录」两条出口都从它取文件。语音本片只做
* 收 / 放,不提供「交给别的 App」这条出口,所以**不把暴露面扩大一个字节**。
* - 目录名是 `media`,与 `voice.url` 里的 `/api/v1/media/` 同源,便于排查时对得上号。
*
* ## 为什么缓存(而不是每次现下)
* 服务端副本是**内容寻址**的(名字由内容决定)+ `Cache-Control: private, max-age=86400,
* immutable` ⇒ 同一个名字的字节永不改变,「文件在且非空」就是永远有效的命中。重复播放因此
* **一个请求都不发**,离线也能重听刚听过的那条。代价是缓存目录里多占几十 KB~几百 KB 一份,
* 而它在 `cacheDir` 下:系统需要空间时可以回收,本端被回收之后下次播放重新下载(**不是**错误)。
*
* ## 它不做什么
* 不拼路径([voiceMediaUrl] 在调用前判,拿不到可信地址就直接失败)、不做形态校验、不猜 MIME、
* **不拿 `size` 反算时长**(时长只认服务端给的 `durationMs`)。
*/
class VoiceStore(
/**
* 取一条副本的字节(未命中缓存时调一次)。
*
* 收成一个 lambda 而不是直接收 [MediaRepository]:本类因此可以在单测里用临时目录 + 假下载器
* 跑完,不必造一个 HTTP 替身(与 [ImageCacheStore] 的 `download` 参数同一手法)。
* 生产路径上它就是 `container.mediaRepository::download`。
*/
private val download: suspend (String) -> Result<ByteArray>,
private val cacheDir: File,
private val baseUrlProvider: () -> String,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 取这条语音的字节并确保它已落进缓存;返回**可直接交给 `MediaPlayer` 的那份文件**。
*
* 顺序是刻意的:**先拼地址、再查缓存**。地址拼不出来(未登录 / 地址为空)就如实失败,
* 绝不因为「本地碰巧缓存过」而放行——那会让一条本端根本没有服务器归属的语音照旧能放,
* 而调用方拿不到任何异常信号。
*/
suspend fun load(audio: VoiceAudio): Result<File> = withContext(dispatcher) {
val url = voiceMediaUrl(audio.url, baseUrlProvider())
?: return@withContext Result.failure(VoiceUrlUnavailableException())
// ⚠️ 刻意**不用** `DocumentCache.fileFor`:那个函数的缓存根写死成 `documents/`
// (FileProvider 那一层),传别的目录进去会拼成 `<cacheDir>/media/documents/<name>`。
// 这里只用它的**名字判据**,路径自己拼。
if (!DocumentCache.isSafeName(audio.name)) {
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回别的名字。
return@withContext Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
}
val file = File(directory(cacheDir), audio.name)
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
val bytes = download(url).getOrElse { return@withContext Result.failure(it) }
try {
DocumentCache.write(file, bytes)
Result.success(file)
} catch (_: Exception) {
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——**绝不**退回「把字节直接喂给
// 播放器」那条路:那需要另一条落盘位置,而缓存目录写不进去时多半写得进的地方也没有。
file.delete()
Result.failure(DocumentCacheUnavailableException())
}
}
companion object {
/** 缓存根下的目录名(与 `voice.url` 里的 `/api/v1/media/` 同源,便于对号)。 */
const val DIRECTORY_NAME: String = "media"
/** 语音副本的缓存目录:`<cacheDir>/media`。 */
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
}
}
@@ -0,0 +1,219 @@
package top.yeij.cyrene.data.voice
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SpeechApi
import top.yeij.cyrene.data.api.SpeechRequestDto
import top.yeij.cyrene.data.repository.DocumentCache
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.readBounded
import java.io.File
import java.security.MessageDigest
/** 服务端回的字节**不是**一段 WAV(200 却回了 JSON 信封 / HTML 错误页 / 别的东西)。 */
class SpeechNotAudioException : Exception("speech response is not wav")
/**
* 一次 TTS 请求的**字节嗅探**:真的是 RIFF/WAVE 吗?
*
* 契约(api-client.md §4.1 行 561 的旁注、§1.2 U6)只说「产物恒为 WAV」,**没有**承诺
* `Content-Type` 是什么,并明写「不要照抄 `Content-Type`」。所以判据只能落在字节上:
* 头 4 字节 `RIFF` + 偏移 8 起的 4 字节 `WAVE`(RIFF 容器里夹一段 `fmt ` 那种标准布局)。
*
* 为什么要判:`MediaPlayer` 拿到非音频字节时抛的是它自己那套 `setDataSource` 异常,
* 与本端「取字节失败」的语义完全不同;而 200 回 JSON 信封恰恰是**最可能**的一种
* 「网关/反代把错误包了一层」的形态 —— 提前拦在这里,失败就被如实归到「解析失败」那一档,
* 而不是在播放器里炸成一个没人认识的东西。
*/
fun isWavBytes(bytes: ByteArray): Boolean =
bytes.size >= WAV_HEADER_BYTES &&
bytes[0] == 'R'.code.toByte() &&
bytes[1] == 'I'.code.toByte() &&
bytes[2] == 'F'.code.toByte() &&
bytes[3] == 'F'.code.toByte() &&
bytes[8] == 'W'.code.toByte() &&
bytes[9] == 'A'.code.toByte() &&
bytes[10] == 'V'.code.toByte() &&
bytes[11] == 'E'.code.toByte()
/** RIFF 头里本端要看的那几个字节都在前 12 个之内。 */
private const val WAV_HEADER_BYTES = 12
/** 本端一次读得下、也放得动的合成音频上限(与语音副本同一把尺子:附件上限 20 MB)。 */
private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
/**
* 「把一段文字念出来」的取字节层(`POST /api/v1/audio/speech` 的唯一调用方,契约 api-client.md
* §1.2 **U6**,v0.96 增量 + v0.97 补写)。
*
* ## 凭据:只走 Bearer,不设 API Key
* 服务端是 owner-only 的**双凭据门**(Bearer owner JWT **或** `X-API-Key`,两个分支落到同一个
* `authorize(RoleOwner)`)。本端只走前者:令牌由 [ApiClientFactory.create] 挂在 OkHttpClient 上的
* `AuthInterceptor` 注入,并顺带享受「带令牌仍 401 ⇒ 单飞续期并重试一次」。
* 主人 2026-10-26 拍板**不加 API Key 输入框** —— 那会多一份要保管、要轮换、要泄漏的凭据,
* 而本端本来就已经有 owner 会话。
*
* ## 发音人从哪来:`sessionId`,不是 `voice`
* 契约里 `voice` **接受但不生效**、`format` **不生效**。真正决定发音人的是 `sessionId` 那条会话的
* **生效人格**(会话绑定 → 全局默认 → 服务端配置的 default)。所以 [speak] 一定要拿到当前会话 id
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。
*
* ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次
* 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
* `documents/` 根下,不扩大暴露面一个字节)。
*
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
*
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
* 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit]
* (非空才算)——它是 `cacheDir` 下的东西,系统随时可回收,被回收之后下次朗读重新请求,
* **不是**错误。
*
* ## 错误怎么分类
* 非 2xx 一律走 [ApiErrorMapper](`401` ⇒ [CyreneError.Unauthorized],其余 ⇒
* [CyreneError.Server] 且**保留服务端那句 `code`**——`tts_persona_unmapped` /
* `tts_no_character` 这两档的文案完全不同,折成一句话就说不清了)。
* 分类成「柔和提示」还是「明确降级」是纯函数的事,在
* [top.yeij.cyrene.domain.chat.ttsFailureOf] 里,本类不做产品判断。
*
* @param baseUrlProvider 每次现读服务器地址(换服务器 ⇒ 客户端重建,与 `AppContainer` 同一纪律)。
* @param apiFactory 按 baseUrl 造 [SpeechApi];收成 lambda 是为了单测能塞一个直连本地 HTTP 替身的
* 实例,而不必造一整套依赖注入。
*/
class SpeechClient(
private val baseUrlProvider: () -> String,
private val tokenProvider: () -> String? = { null },
private val unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
private val cacheDir: File,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val apiFactory: (String) -> SpeechApi = { baseUrl ->
ApiClientFactory.create(
baseUrl,
SpeechApi::class.java,
tokenProvider,
unauthorizedPolicy,
)
},
) {
/** 按 baseUrl 缓存 Retrofit 实例(换地址才重建),与 `AppContainer.mediaApi` 同一手法。 */
private var cachedApi: SpeechApi? = null
private var cachedBaseUrl: String? = null
private fun api(): SpeechApi {
val baseUrl = baseUrlProvider()
val cached = cachedApi
if (cached != null && cachedBaseUrl == baseUrl) return cached
return apiFactory(baseUrl).also {
cachedApi = it
cachedBaseUrl = baseUrl
}
}
/**
* 把 [text] 合成成一段 WAV 并落盘,返回**可直接交给 `MediaPlayer`** 的那份文件。
*
* [sessionId] 是当前会话 id:它决定发音人(见类 KDoc)。传 `null` 表示「本端不知道是哪个会话」,
* 服务端会退到全局默认人格 —— 调用方**不要**为了让字段好看而编一个 id 出来。
*/
suspend fun speak(text: String, sessionId: String?): Result<File> = withContext(dispatcher) {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
// 服务端对空 text 回 `400`;本端连请求都不发(省一次往返,也省一次无意义的失败)。
return@withContext Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
}
val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId, baseUrlProvider()))
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
try {
val response = api().speech(SpeechRequestDto(text = trimmed, sessionId = sessionId))
val body = response.body()
when {
response.isSuccessful && body != null -> body.use { stream ->
val bytes = readBounded(stream.byteStream(), MAX_SPEECH_BYTES)
when {
// 超过上限:本端一次读不下(合成音频远小于它,走到这里说明对面不对劲)。
bytes == null -> Result.failure(SpeechNotAudioException())
// 0 字节:与 MediaRepository 同一口径,判解析失败而不是「拿到一段静音」。
bytes.isEmpty() -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
// 200 却不是 WAV:**绝不**把 JSON / HTML 喂给 MediaPlayer(见 isWavBytes)。
!isWavBytes(bytes) -> Result.failure(SpeechNotAudioException())
else -> cache(file, bytes)
}
}
// 2xx 却没有响应体:与 apiCall 同一口径。
response.isSuccessful -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
private fun cache(file: File, bytes: ByteArray): Result<File> = try {
DocumentCache.write(file, bytes)
Result.success(file)
} catch (_: Exception) {
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——绝不退回「把字节留在内存里等播放器
// 读」那条路:MediaPlayer 只吃路径,而缓存目录写不进去时多半写得进的地方也没有。
file.delete()
Result.failure(DocumentCacheUnavailableException())
}
private companion object {
/** 合成缓存的名字前缀:与语音副本(服务端内容寻址名)在同一目录里区分开。 */
const val NAME_PREFIX = "tts_"
/** sha256 前 32 位十六进制,与服务端存储名 / [DocumentCache.localName] 同一长度。 */
const val NAME_HEX_LENGTH = 32
/**
* 缓存名 = `tts_<sha256(baseUrl \n sessionId \n text)[:32]>.wav`。
*
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
* 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
*
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
*/
fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(
"${baseUrl.trimEnd('/')}\n${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8),
)
val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH)
return NAME_PREFIX + hex + ".wav"
}
}
}
@@ -0,0 +1,219 @@
package top.yeij.cyrene.data.voice
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.chat.VoiceClip
/** 录音机的两个可见态(界面只认这两个)。 */
sealed interface VoiceRecorderState {
/** 没在录。 */
data object Idle : VoiceRecorderState
/**
* 正在录。
*
* [elapsedMs] 由**已收到的字节数**反算([VoiceClip.durationMillisOf]),不是另起一个计时器——
* 两个时钟一定会漂,而屏幕上那个数字必须与最终发出去的音频长度一致。
*
* [reachedLimit] = 已经录满 [VoiceClip.MAX_RECORDING_MILLIS],此后的字节被**丢弃**(不是
* 悄悄截断已经收到的部分)。UI 看到它应当立刻收尾发送,见本类注释。
*/
data class Recording(val elapsedMs: Int, val reachedLimit: Boolean) : VoiceRecorderState
}
/**
* 麦克风录音机:`AudioRecord` 收 PCM → 交给 [VoiceClip] 封 WAV(本类不碰封装,见那里的 KDoc)。
*
* ## 为什么自己持有一个作用域,而不复用调用方的 `rememberCoroutineScope`
* 读循环必须活到 [stop] 真的收尾:调用方的组合一旦被丢弃(转屏 / 退到后台那一刻重建),
* 挂在它上面的协程会被取消,而 USB/内置麦克风已经交给系统的那一块缓冲就再也取不回来——
* 用户按下停止却拿到一个缺尾的片段。所以读循环跑在这个类自己的 `SupervisorJob + IO` 上,
* 由 [release] 明确收掉。
*
* ## 满了不自动发送,只置位
* 到 [VoiceClip.MAX_RECORDING_MILLIS] 时把 [VoiceRecorderState.Recording.reachedLimit] 置真、
* 继续把多余的字节丢掉。**发送时机由 UI 决定**(它要先把「正在录」那一行换回输入栏,再交给
* VM):本类不知道 VM 也不发帧,这条边界让它在单测里不必造一个 VM。
*
* ## 权限
* 调用方必须先拿到 `RECORD_AUDIO`(运行时权限)。本类**不申请**也不检查:`AudioRecord`
* 在没权限时会抛 `SecurityException`,[start] 把它连同「设备不支持 / 初始化失败」一起收敛成
* `false`,由 UI 按「这台设备现在开不了麦克风」说一句,**绝不静默**。
*/
class VoiceRecorder(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
/** 当前录音状态(界面据此显示「录音中 0:07」)。 */
val state: StateFlow<VoiceRecorderState> = _state.asStateFlow()
private var record: AudioRecord? = null
/** 收集到的 PCM;只有 [start] 建、[stop]/[cancel] 取走或丢掉。 */
private var collected: ByteArrayOutputStream? = null
private var reader: Job? = null
/**
* 录音代次:每次 [start] 自增。
*
* 只服务一件事:读循环的 `finally` 里**只在还属于这一代**时才把状态收回 [VoiceRecorderState.Idle]。
* 少了它,「取消 → 立刻重录」的窄窗口里,上一轮的收尾会把新那一轮的「录音中」抹成「没在录」,
* 而麦克风其实还在收字节——界面上什么都不显示,用户以为自己没在录。
*/
private var generation = 0
@Volatile
private var collecting = false
/**
* 开始录音。
*
* @return `false` = 这台设备现在起不了麦克风(没有权限 / 设备被占用 / 不支持的采样参数)。
* 调用方必须**如实告知**(不得假装在录)。
*/
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (collecting) return false
val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_MASK, ENCODING)
if (minBuffer <= 0) return false
val recorder = runCatching {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(ENCODING)
.setSampleRate(SAMPLE_RATE_HZ)
.setChannelMask(CHANNEL_MASK)
.build(),
)
// 缓冲给到最小值的两倍:读循环偶尔晚一拍时不至于立刻丢帧。
.setBufferSizeInBytes(minBuffer * 2)
.build()
}.getOrNull() ?: return false
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
runCatching { recorder.release() }
return false
}
val started = runCatching { recorder.startRecording() }.isSuccess
if (!started) {
runCatching { recorder.release() }
return false
}
record = recorder
val sink = ByteArrayOutputStream(INITIAL_PCM_BYTES)
collected = sink
collecting = true
val current = ++generation
_state.value = VoiceRecorderState.Recording(elapsedMs = 0, reachedLimit = false)
reader = scope.launch { readLoop(recorder, sink, current) }
return true
}
/**
* 停止并交出这一段 PCM;`null` = 没有可发的字节(没在录 / 读失败 / 全被丢弃)。
*
* `suspend` 是刻意的:必须等读循环真的收尾([reader] 的 `join`)才去读那份缓冲,
* 否则最后一块还没写进去就被取走——用户会听到句尾少一截。
*/
suspend fun stop(): ByteArray? {
if (!collecting) return null
collecting = false
reader?.join()
reader = null
releaseRecorder()
_state.value = VoiceRecorderState.Idle
val sink = collected ?: return null
collected = null
return sink.toByteArray().takeIf { it.isNotEmpty() }
}
/** 取消:丢掉已经收到的字节,一个都不发(用户点了「取消」)。 */
fun cancel() {
collecting = false
reader?.cancel()
reader = null
releaseRecorder()
collected = null
_state.value = VoiceRecorderState.Idle
}
/** 组合被丢弃时调(等价于 [cancel]:一段没发出去的录音不该在后台继续占着麦克风)。 */
fun release() = cancel()
/**
* 读循环:一块一块收进 [sink],顺手更新已录时长;满了之后**丢字节但不打断录音**
* (见类注释「满了不自动发送」)。
*
* 读出错(`read < 0`)就结束这一次录音:硬件被抢走、设备被拔出都会走到这里,此时
* 已经收到的部分仍然有效([stop] 照常交出它们)。
*
* @param generation 启动时那一代的编号:收尾只在自己还是「当前这一代」时才改状态。
*/
private suspend fun readLoop(recorder: AudioRecord, sink: ByteArrayOutputStream, generation: Int) {
val buffer = ByteArray(READ_BYTES)
try {
while (collecting) {
val read = recorder.read(buffer, 0, buffer.size)
if (read < 0) break
if (read == 0) continue
val used = sink.size()
if (used < VoiceClip.MAX_PCM_BYTES) {
sink.write(buffer, 0, minOf(read, VoiceClip.MAX_PCM_BYTES - used))
}
val elapsed = VoiceClip.durationMillisOf(minOf(sink.size(), VoiceClip.MAX_PCM_BYTES))
_state.value = VoiceRecorderState.Recording(
elapsedMs = elapsed,
reachedLimit = elapsed >= VoiceClip.MAX_RECORDING_MILLIS,
)
}
} finally {
// 读循环自己结束(出错 / 被取消)时也要把状态收回 Idle,否则界面会永远停在「录音中」。
// 只在自己还是当前这一代时才收:否则「取消 → 立刻重录」的窄窗口里,上一轮的收尾
// 会把新一轮的「录音中」擦掉。
if (generation == this.generation && _state.value is VoiceRecorderState.Recording) {
_state.value = VoiceRecorderState.Idle
}
}
}
/** 把 `AudioRecord` 拆干净(`stop` 在没起录时抛 `IllegalStateException`,所以都包在 `runCatching` 里)。 */
private fun releaseRecorder() {
val current = record ?: return
record = null
runCatching { current.stop() }
runCatching { current.release() }
}
private companion object {
val SAMPLE_RATE_HZ: Int = VoiceClip.SAMPLE_RATE_HZ
const val ENCODING: Int = AudioFormat.ENCODING_PCM_16BIT
const val CHANNEL_MASK: Int = AudioFormat.CHANNEL_IN_MONO
/**
* 一次读多少字节:`2048` = 16 kHz 下单声道 16-bit 的 64 ms(1024 帧)。
* 比这更小则更新频率过高(每秒几十次状态写),更大则「停止」那一刻的延迟可感。
*/
const val READ_BYTES: Int = 2048
/** 初始缓冲按 10 秒预分配(够大多数语音,长的那条会自己扩容)。 */
val INITIAL_PCM_BYTES: Int = VoiceClip.pcmBytesFor(10_000)
}
}
@@ -223,6 +223,37 @@ class ChatSocket(
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
/**
* 发送 `voice_input`(契约 v0.96 §3.15):一段 base64 音频 + 格式,走与 `chat` 同一条出站通道。
*
* ## 为什么没有「离线队列」
* 文本那条有补发队列(内容只是一小段字符串,留着不贵);语音的 audio 是**几 MB 的 base64**
* 内联在帧里,队列化它等于把这段内存钉在待发状态里,而重连之后服务端到底要不要它、要哪一段
* 又都是用户该重新决定的事。所以本层与 [sendChat] 一样:**发不出去就回 `false`**,由
* `ChatViewModel` 把这段留在内存里、让用户按「重发」(复用同一个 `clientMsgId`)。
*
* @param audio base64(单行,`Base64.NO_WRAP`)。
* @param format 契约白名单 `wav|mp3|ogg` 之一;本端恒 `"wav"`。
* @param deviceId 与 `chat` 帧同一份来源标识。
* @param clientMsgId 幂等键:同一段语音重发**必须复用**(否则服务端会把同一句话说两遍)。
* @return 未连接 / 写失败返回 `false`。
*/
fun sendVoiceInput(
sessionId: String?,
audio: String,
format: String,
deviceId: String?,
clientMsgId: String?,
): Boolean = send(
WsOutbound.VoiceInput(
audio = audio,
format = format,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
),
)
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
val socket = webSocket ?: return false
@@ -190,10 +190,19 @@ sealed class WsEvent {
override val type: String get() = "tool_execution.update"
}
/** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */
/**
* 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。
*
* [resultSummary] 是**执行结果摘要**(新契约切片,与回看面 `toolCalls[].resultSummary` 同一份预算:
* 已经脱敏、已经截断 ≤500 字符的成品,本端只展示)。它的语义是**缺席即「不知道」**:
* 只有这一轮的执行结果**真的拿到了**才在场,失败 / 取消 / 超时 / 未落图时后端**整个键省略**
* (且承诺不用空串顶位)。所以 `null` 与 `""` 在本端都读作**不知道**——绝不解成「没有输出」,
* 因为那是替服务端下一个它没下的结论。当前服务端**尚未部署**该键 ⇒ 真机上它恒缺席。
*/
data class ToolExecutionEnd(
val tool: String,
val phase: String,
val resultSummary: String? = null,
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
@@ -591,12 +600,30 @@ sealed class WsEvent {
override val type: String get() = "observability_log"
}
/** 语音合成回复(定向回请求连接)。 */
/**
* 语音合成回复(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* ## 会话归属**只在 `data` 里**
* 契约明写这帧的帧壳**没有** `sessionId`(与其它结构化流帧不同),会话 id 在载荷上。
* 所以解析层用既有口径 [`resolveSessionId`] 把它提到 [sessionId] 上:帧壳有就用帧壳,
* 否则用 `data` 里那一份。少了这一步,`isForSession` 会把帧**放行给每一个会话的 VM**
* (空值 = 无法判定),于是切到别的会话时也会响一段本属于别的会话的语音。
*
* ## 只定向回发起 `voice_input` 的那条连接、断线不补投
* 这是契约的明确边界,不是缺口:本端**不**为它做任何「重连后拉取」的补救(历史靠 REST,
* 音频只在这一次连接里存在)。所以这一帧只有「当场播放 / 当场显示」这一种用途。
*
* [text] 是**转写**(那段音频被识别成什么),[reply] 是**她说的话**——两者的取舍见
* `domain.chat.voiceReplyText`。[provider] 是降级信号(`silent` = 这一轮没有真音频),
* 判据见 `domain.chat.voiceOutputHasAudio`。
*/
data class VoiceOutput(
val audio: String?,
val format: String?,
val text: String?,
val reply: String?,
val deviceId: String? = null,
val provider: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
@@ -760,7 +787,7 @@ sealed class WsEvent {
"tool_execution.update" -> decode<ToolExecutionData>(data)
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
"tool_execution.end" -> decode<ToolExecutionData>(data)
.let { ToolExecutionEnd(it.tool, it.phase, it.id, turnId, seq, sessionId) }
.let { ToolExecutionEnd(it.tool, it.phase, it.resultSummary, it.id, turnId, seq, sessionId) }
"stream_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
"stream_end" -> decode<StreamEndData>(data)
.let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) }
@@ -881,8 +908,20 @@ sealed class WsEvent {
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
"message_deleted" -> decode<DeletedData>(data)
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data).let {
VoiceOutput(
audio = it.audio,
format = it.format,
text = it.text,
reply = it.reply,
deviceId = it.deviceId,
provider = it.provider,
turnId = turnId,
seq = seq,
// 帧壳**没有** sessionId(契约 v0.96),归属在 data 里 ⇒ 走既有口径提到帧上。
sessionId = resolveSessionId(sessionId, it.sessionId),
)
}
// 她主动说的话(v0.72)。帧壳**没有** `sessionId`(服务端不设),与会话归属有关的一切
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
"proactive_message" -> decode<ProactiveData>(data).let {
@@ -983,6 +1022,10 @@ private data class ToolCallData(val id: String = "", val index: Int = 0, val del
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
* 也没有 `streamId`(它们不属于某一次模型流)。
*
* [resultSummary] 只由 `.end` 携带(后端在新契约切片里只补了 `.end`)。它放在这份共用载荷上
* 是因为三帧形状同源、共用一个 DTO;`.start` / `.update` 即便被塞了这个键也**不读**
* (它们的领域事件上根本没有这个字段),所以契约没变宽——变宽的只是「多一个键不再算坏帧」。
*/
@Serializable
private data class ToolExecutionData(
@@ -990,6 +1033,7 @@ private data class ToolExecutionData(
val phase: String = "",
val id: String? = null,
val delta: String = "",
val resultSummary: String? = null,
)
@Serializable
@@ -1115,12 +1159,22 @@ private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList(
@Serializable
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
/**
* `voice_output` 载荷(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* `sessionId` 在这里**不是可选的**(契约「帧壳没有 sessionId(在 data 里)」),但解成缺省空串
* 并交给 [WsEvent.resolveSessionId]:坏帧(真没给)时宁可退化成「无法判定」(放行、由消费方
* 兜底),也不把整帧按坏帧丢掉——丢了就是「她回了语音但屏幕上什么都没有」。
*/
@Serializable
private data class VoiceOutputData(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val reply: String? = null,
val sessionId: String = "",
val deviceId: String? = null,
val provider: String? = null,
)
/**
@@ -105,12 +105,26 @@ sealed interface WsOutbound {
}
}
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
/**
* `voice_input`:`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`(契约 v0.96 §3.15)。
*
* 五个键里 [audio] / [format] 是我们**总要给**的(一段 WAV 的 base64 + `"wav"`,见
* `domain.chat.VoiceClip`);[text] 只有本端**自己做过转写**时才给(给了服务端就跳过 STT),
* 本端不做本地转写 ⇒ 恒省略。
*
* [clientMsgId] 与 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条,重复到达回
* `chat_duplicate`、不再落库不再起回合。所以「这一段没发出去、用户点重发」必须**复用**
* 同一个键(见 `domain.chat.voiceClientMsgIdFor`),换新键就是让服务端把同一句话再答一遍。
*
* [deviceId] 的取值与 `chat` 帧同一份(`ChatViewModel.DEVICE_ID`):服务端据此标记来源。
*/
data class VoiceInput(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val sessionId: String? = null,
val deviceId: String? = null,
val clientMsgId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "voice_input"
@@ -120,6 +134,8 @@ sealed interface WsOutbound {
format?.let { put("format", it) }
text?.let { put("text", it) }
sessionId?.let { put("sessionId", it) }
deviceId?.let { put("deviceId", it) }
clientMsgId?.let { put("clientMsgId", it) }
}
}
@@ -40,12 +40,21 @@ data class EditableMessage(
)
/**
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签):
* `<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器会**改掉**的那几个标签——行内标签不在其中,
* 见下):`<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
*
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
*
* **行内标签(`<b>` / `<code>` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
* 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 <b>18:35</b>` 在
* 编辑框里连 `<b>` 一起显示、也一起保存,这里一个字符都不动它。
*
* **`<br>` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `<br>` 的消息在这份
* 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`<br>` 原样显示、原样保存);只有原文**另有**已知标记时
* 才走剥标记那条路,那时 `<br>` 与 `<p>` / `</p>` 一样被换成它本来就在表示的**空行分隔**
* (保存后渲染结果不变,且弹层已经明说标记会被替换)。
*/
private val KNOWN_TAG_RE = Regex(
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
@@ -0,0 +1,207 @@
package top.yeij.cyrene.domain.chat
import java.util.Locale
/**
* 预览内容**下方**那一行灰色小字的组装(纯函数,逐档穷举单测在 `PreviewMetadataTest`)。
*
* ## 这一行是什么,不是什么
* 它只放**关于这份文件本身**的格式事实:有多少行、按什么口径解码、多大、多少像素、什么时候拍的。
* 它**不是**状态栏、也不是错误提示——「读不到 / 打开失败 / 保存失败」各有自己的去处(芯片行、
* 正文区那句人话、弹层下方那行告知),混进来会让这一行读起来像出了什么事。
*
* ## 纪律:拿不到的项就不出现
* 每一项都在**有证据时才产出**,没有证据就整项缺席——**不留占位符、不写「未知」、不写 `--`**。
* 一屏小字里堆三个「未知」换不来任何信息,却会让人以为本端查过了。三档证据强度各自成条:
* - **字节**(本端真的持有那一份):行数、像素、EXIF 拍摄日期、文件体积;
* - **服务端结论**(DTO 里的字段):注入字符数、注入是否被截断、嗅探所得的 mime;
* - **本端口径**(本端自己做了什么):`UTF-8` 只是**本端解码口径**,不是文件的真实编码(见下)。
*
* ## 「编码」为什么敢写 UTF-8(且只敢这么写)
* 客户端**拿不到文件的真实编码**:磁盘上那份字节是按什么字符集写的,没有任何字段说过,本端也没有
* 可靠的方式判定(猜 GBK / Shift-JIS 就是在编)。能确定的只有一件事:正文区那段文字是
* [DocumentPreview.slice] 用 `String(bytes, Charsets.UTF_8)` 解出来的——所以这一项写的是
* **「本端解码口径」**,即「你正在读的这段文字是怎么来的」。**表述里必须带这个限定语**:
* 裸写一个「UTF-8」会被读成「这份文件是 UTF-8 编码的」,那是本端不知道的事。
*
* ## 与芯片行重合的两处:一处保留、一处省略
* 预览弹层顶部那枚芯片已经写着「类型 · 体积 · 字数 · 状态 · 注入已截断」([DocumentChipModel.detail]),
* 因此这一行刻意**不重复**:
* - **省略**「体积」与「服务端状态」:[DocumentChips.sizeLabel] / [DocumentChips.statusLabel] 那两个
* 数字/词与芯片行**逐字相同**、且没有口径可补,再写一遍只是噪音(图片那一路没有芯片行,所以
* 体积在 [imageItems] 里是要显示的);
* - **保留**「字符数」并补上口径:芯片只写「8,000 字」,读者无法知道那是**服务端注入上下文**的
* 字数还是这份文件的字数——这正是一句会把人带偏的话,补上「(服务端注入口径)」才算说清。
*
* ## 本端的截断为什么不在这里再说一遍
* [PreviewSlice.truncated](本端只预览了前 256 KB / 5,000 行)已经让 [PreviewSlice.notice] 在**正文
* 上方**明说了,本行不再重复同一件事;而**服务端**的「注入被预算截断」是另一件事([DocumentRefDto.truncated],
* 正文照样进了上下文),它没有别的去处,所以在 [textItems] 里单列一条。
*/
object PreviewMetadata {
/** 这一行各项之间的分隔符。 */
const val ITEM_SEPARATOR: String = " · "
/** 把各项拼成能直接交给 `Text` 的那一行;空列表拼成空串(调用方据此不画这一行)。 */
fun join(items: List<String>): String = items.joinToString(ITEM_SEPARATOR)
/**
* 文本类预览的那几项:行数 · 字符数(带口径)· 解码口径 · 服务端的截断标记。
*
* @param slice 正文区的切段结果([DocumentPreview.slice] 的产物)。
* @param serverCharsLabel 服务端下发的注入字符数**已格式化**的那一份
* (`documents[].chars` → [DocumentChips.charsLabel],如 `"8,000 字"`);`null` = 服务端没报
* (`chars` 整键缺席,契约 v0.59 §1.2),此时改报本端预览内容的字数。
* @param contextTruncated 服务端是否报告了「注入被预算截断」(`documents[].truncated`)。
* ⚠️ 与 [PreviewSlice.truncated](**本端**预览截断)是两件事,见本对象 KDoc。
*/
fun textItems(
slice: PreviewSlice,
serverCharsLabel: String?,
contextTruncated: Boolean,
): List<String> = buildList {
add(linesItem(slice))
add(charsItem(slice.text, serverCharsLabel))
// 本端解码口径:正文就是 String(bytes, UTF_8) 的产物,说得出、也只在说这一件事。
add(DECODED_AS_UTF8)
if (contextTruncated) add(TRUNCATED_FOR_CONTEXT)
}
/**
* 非文本类(PDF 这类二进制)的那几项:只有服务端**字节嗅探**所得的 mime。
*
* 体积与状态刻意不在这里(芯片行已经写着,见本对象 KDoc);服务端说「读不到」时下发的
* **原因原话**也不在这里——它已经被 [DocumentPreviewGate.plan] 送进正文区那句人话里**原样显示**,
* 这里是同一屏的第二个位置,重复一遍只会让那句话看着像两条不同的消息。
*
* @param serverMime DTO 的 `mime`(服务端嗅探所得基类型);本地待发附件**没有**这个结论
* (还没上传过),传 `null` —— 这一项因此缺席,**不由本端按扩展名补一个**。
*/
fun fileItems(serverMime: String?): List<String> =
listOfNotNull(serverMime?.trim()?.takeIf { it.isNotEmpty() })
/**
* 图片的那几项:格式(mime)· 分辨率 · 体积 · 拍摄日期。
*
* 图片这一路没有附件芯片行,所以体积在这里是要显示的。每一项都可能在拿不到时缺席:
* 格式(嗅探与平台声明都判不出来时)、分辨率(本端解不出边界时)、拍摄日期(EXIF 里没有
* `DateTimeOriginal`,或那个值不是能认的日期时)——见各自的参数说明。
*
* @param mime 交给别的应用的类型([ImageOpenTypes.openType] 的产物)。⚠️ 退化到「图片通配」
* 时**必须传 `null`**:`image/` 通配([ImageOpenTypes.ANY_IMAGE])不是格式,写出来等于给了一个什么都没说的答案。
* @param width / @param height 本端按 `inJustDecodeBounds` 读到的像素;任一侧 ≤ 0 / 缺席即整项不出现。
* @param sizeBytes 本端实际持有的字节数;[UNKNOWN_BYTES] 即整项不出现。
* @param capturedAt EXIF `DateTimeOriginal` 的**原始字符串**(`yyyy:MM:dd HH:mm:ss`,
* 没有时区、按本地墙钟读,见 [formatExifDateTime])。
*/
fun imageItems(
mime: String?,
width: Int? = null,
height: Int? = null,
sizeBytes: Long = UNKNOWN_BYTES,
capturedAt: String? = null,
): List<String> = buildList {
mime?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
if (width != null && height != null && width > 0 && height > 0) add("$width × $height")
DocumentChips.sizeLabel(sizeBytes).takeIf { it.isNotEmpty() }?.let { add(it) }
formatExifDateTime(capturedAt)?.let { add(CAPTURED_AT_PREFIX + it) }
}
/**
* EXIF 的拍摄时间 → 人能读的样子(`"2019:07:04 20:31:05"` → `"拍摄于 2019年7月4日 20:31"`);
* 认不出就回 `null`(宁可不显示,也不显示一个错的日期)。
*
* ## 两个必须说清的取舍
* 1. **没有时区**。EXIF 的 `DateTimeOriginal` 是纯墙钟字符串(`yyyy:MM:dd HH:mm:ss`),规范里
* 根本没有偏移量字段。所以这里**不做任何时区换算**,按字面数字当日历时间读——把它当 UTC
* 再转本地,只会让一个原本正确的日期凭空错几个小时。
* 2. **严格到不近人情**。只有逐位对上 `yyyy:MM:dd HH:mm:ss`(19 个字符、分隔符固定)且各段落在
* 合法范围内才认。相机的「值未写入」占位符 `"0000:00:00 00:00:00"` 因此落在月份 0 上被判非法
* ——这是对的,它本来就表示「不知道」。宽松解析(补零、猜分隔符)换来的是**看起来正常的错日期**,
* 比不显示糟得多。
*
* 秒刻意丢掉:这一行是给人看的摘要,分钟已经足够定位(同秒的两张图对用户没有区别)。
*/
fun formatExifDateTime(raw: String?): String? {
val value = raw?.trim().orEmpty()
val groups = EXIF_DATE_TIME.matchEntire(value) ?: return null
// 正则已保证每段都是纯数字,`toInt()` 在这里不会抛。
val (year, month, day, hour, minute, second) = groups.destructured
val y = year.toInt()
val mo = month.toInt()
val d = day.toInt()
val h = hour.toInt()
val mi = minute.toInt()
val s = second.toInt()
if (y < MIN_YEAR) return null
if (mo !in 1..12) return null
if (d !in 1..31) return null
if (h !in 0..23) return null
if (mi !in 0..59) return null
// 60 是闰秒,相机会写出来,不该判成坏日期。
if (s !in 0..60) return null
return String.format(Locale.ROOT, "%d年%d月%d日 %02d:%02d", y, mo, d, h, mi)
}
/**
* 行数那一项。
*
* 显示量按 [DocumentPreview.lineCount] **从正文本身重算**(不是取 [PreviewSlice.shownLines]):
* 这一行的标题就是「你看到的这段有多少行」,重算让它与正文永远自洽。被本端截断时补上全文行数
* ([PreviewSlice.totalLines] 是按整份字节数出来的,本端确实持有那份字节)——只说「显示 N 行」
* 会让人以为文件就这么长。
*/
private fun linesItem(slice: PreviewSlice): String {
val shown = grouped(DocumentPreview.lineCount(slice.text))
return if (slice.truncated) {
"显示 $shown 行 / 共 ${grouped(slice.totalLines)} 行"
} else {
"$shown 行"
}
}
/** 字符数一项:服务端报了就报它那句(补口径),没报才用本端的计数(另一句口径)。 */
private fun charsItem(text: String, serverCharsLabel: String?): String =
if (serverCharsLabel != null) {
serverCharsLabel + SERVER_CHARS_SCOPE
} else {
grouped(codePointCount(text)) + " 字" + LOCAL_CHARS_SCOPE
}
/**
* 「字」数按 **Unicode 码点**算(`codePointCount`):一个 emoji / 一个汉字都算一个字。
* 直接取 `String.length` 会把 BMP 之外的字符算成两个(UTF-16 代理对),与「字数」这个说法不符。
*/
private fun codePointCount(text: String): Int =
text.codePointCount(0, text.length)
/** 千分位分组(只用于计数,纯展示)。 */
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
// —— 每一项的措辞都收在这里,测试锚的也是这几句 ——
/** 本端解码口径。**限定语不许省**:省了就成了对文件真实编码的断言(见本对象 KDoc)。 */
const val DECODED_AS_UTF8: String = "UTF-8(本端解码口径)"
/** 服务端 `chars` 的口径:那是**注入上下文**的字数,不是这份文件的字数。 */
const val SERVER_CHARS_SCOPE: String = "(服务端注入口径)"
/** 本端自己数出来的字数口径:只数了预览内容(截断时就不是全文)。 */
const val LOCAL_CHARS_SCOPE: String = "(本端预览内容)"
/** 服务端的 `truncated`:注入被预算截断,**正文照样进了上下文**(与「读不到」是两件事)。 */
const val TRUNCATED_FOR_CONTEXT: String = "正文被截断(进上下文的部分)"
/** 拍摄日期前缀:一个光秃秃的日期在这一行里会被当成「文件修改时间」之类的东西。 */
const val CAPTURED_AT_PREFIX: String = "拍摄于 "
/** 字节数拿不到时的占位:交给 [DocumentChips.sizeLabel],它会回空串(于是整项不出现)。 */
const val UNKNOWN_BYTES: Long = -1L
private const val MIN_YEAR = 1
/** EXIF 规范里的日期时间形状;只认这一种,见 [formatExifDateTime]。 */
private val EXIF_DATE_TIME =
Regex("""(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})""")
}
@@ -0,0 +1,238 @@
package top.yeij.cyrene.domain.chat
/**
* 一段**要发出去的语音**:录音参数、WAV 封装、体积闸门与幂等键(纯函数,可穷举单测)。
*
* ## 为什么自己封 WAV,而不是用 `MediaRecorder`
* 契约 v0.96 给 `voice_input.format` 的白名单是 **`wav|mp3|ogg`**,而 Android `MediaRecorder`
* 的默认产物是 **`m4a/aac`**——**不在白名单里**。要用它就得另接一个编码器把 aac 转成 mp3/ogg,
* 那是新依赖(项目纪律:加依赖先问)。而 `AudioRecord` 给的 PCM + 一个 44 字节的 RIFF 头
* 就是 WAV:**零依赖、零转码、逐字节可预期**,也能在单测里把头的每个字段钉死。
*
* ## 采样参数:16 kHz / 单声道 / 16-bit
* - **采样率 16 kHz**:语音识别的行业口径就是 16 kHz(电话带宽 8 kHz 会把擦音削掉,44.1 kHz
* 对 STT 没有任何增益)。同一句话的字节数因此只有 44.1 kHz 的 **36%**——这一条在本端格外重要,
* 因为 `voice_input.audio` 是**内联 base64**(4/3 膨胀)走一条 WS 文本帧。
* - **单声道**:麦克风阵列的立体声对 STT 只有副作用(两路几乎相同,白背一倍体积)。
* - **16-bit**:`AudioRecord` 的原生 PCM 编码(`ENCODING_PCM_16BIT`),不做压缩——压缩要依赖。
*
* 由此得出一个**只有一条**的换算:`1 ms = 32 字节`,`1 s = 32 000 字节`。
*
* ## 体积闸门(为什么是 5 MiB)
* 契约 v0.96 的 `voice_input` **没写** `audio` 的体积上限(`chat.image` 写了:解码后 5 MiB /
* 字符串 6990572 字符)。本端按**已知最严的那一档**设计,也就是 [MAX_AUDIO_BYTES] =
* `ChatImageBudget.SERVER_MAX_DECODED_BYTES`(5 MiB,解码后)——同一个部署对「内联 base64 字段」
* 已经明确接受的量级,voice 没有理由比它更宽。超限**明确拒绝**([VOICE_TOO_LARGE_MESSAGE]),
* 绝不静默截断:截断后的音频在服务端听来就是「话说到一半断了」。
*
* 60 秒的录满长度([MAX_RECORDING_MILLIS])只用到这条闸门的 **36%**(1 920 044 字节),所以
* 正常路径永远撞不到它——它是**第二道**闸(硬件偶尔会多给几块缓冲,谁也说不准),不是主判据。
*
* ## 时长下限
* 按下就松手的误触会得到几十毫秒的音频:那不是「说话」,送给服务端只会换来一次空转写。
* 不足 [MIN_RECORDING_MILLIS] 的录音在**本端**就被挡下并说明([VOICE_TOO_SHORT_MESSAGE])。
*/
object VoiceClip {
/** 采样率(Hz)。16 kHz = STT 的行业口径,见类注释。 */
const val SAMPLE_RATE_HZ: Int = 16_000
/** 声道数:单声道(立体声对 STT 无益,只翻倍体积)。 */
const val CHANNEL_COUNT: Int = 1
/** 位深:`AudioRecord` 的原生 16-bit PCM。 */
const val BITS_PER_SAMPLE: Int = 16
/** 每采样的字节数。 */
const val BYTES_PER_SAMPLE: Int = BITS_PER_SAMPLE / 8
/** RIFF/WAVE 的 PCM 头长度:`RIFF`(4) + size(4) + `WAVE`(4) + `fmt `(16+8) + `data`(8)。 */
const val HEADER_BYTES: Int = 44
/** 上行 `format` 的取值。契约白名单 `wav|mp3|ogg` 里我们**只产出**这一个。 */
const val FORMAT: String = "wav"
/** 每秒的 PCM 字节数(= [SAMPLE_RATE_HZ] × [CHANNEL_COUNT] × [BYTES_PER_SAMPLE])。 */
val BYTES_PER_SECOND: Int = SAMPLE_RATE_HZ * CHANNEL_COUNT * BYTES_PER_SAMPLE
/**
* 一段语音(**含头**)的字节上限:与 `chat.image` 解码后同一条线,见类注释。
* 刻意**引用**那个常量而不是再写一遍 `5 * 1024 * 1024`:上限只留一处,改的时候不会漂。
*/
const val MAX_AUDIO_BYTES: Int = ChatImageBudget.SERVER_MAX_DECODED_BYTES
/**
* 一次录音的**最长**时长(毫秒)。60 秒取自 IM 的通行口径(微信 / QQ 都是这个量级),
* 也对得上服务端 STT 的现实:再长的语音转写质量与等待都不划算。
*
* 到点由录音机自己置「满了」标记(见 `VoiceRecorder`),UI 随即收尾发送——
* 不是悄悄截断,用户看到的是「到 60 秒了,这段发出去」。
*/
const val MAX_RECORDING_MILLIS: Int = 60_000
/** 短于这个时长的录音**不送**(按下就松手的误触),见类注释。 */
const val MIN_RECORDING_MILLIS: Int = 1_000
/** 60 秒对应的 PCM 字节数(录音机据此封顶,不在读循环里另算一遍公式)。 */
val MAX_PCM_BYTES: Int = pcmBytesFor(MAX_RECORDING_MILLIS)
/** 这段 PCM 加上 WAV 头之后的**总**字节数。 */
fun totalBytesFor(pcmBytes: Int): Int = if (pcmBytes <= 0) 0 else HEADER_BYTES + pcmBytes
/** 时长 → PCM 字节数(向下取整;`<= 0` 的时长回 0)。 */
fun pcmBytesFor(durationMillis: Int): Int =
if (durationMillis <= 0) 0 else (durationMillis.toLong() * BYTES_PER_SECOND / 1000L).toInt()
/** PCM 字节数 → 时长(毫秒,向下取整)。 */
fun durationMillisOf(pcmBytes: Int): Int =
if (pcmBytes <= 0) 0 else (pcmBytes.toLong() * 1000L / BYTES_PER_SECOND).toInt()
/** 总字节数是否在 [MAX_AUDIO_BYTES] 以内(**等于**算通过:`<=`,与 `withinServerLimit` 同口径)。 */
fun withinLimit(totalBytes: Int): Boolean = totalBytes <= MAX_AUDIO_BYTES
/**
* WAV 头(44 字节,PCM / 单块 / 无扩展)。
*
* 逐字段按 RIFF 规范写:`RIFF` + 文件长 - 8 + `WAVE` + `fmt `(16 字节,PCM=1) + 声道 /
* 采样率 / 字节率 / 块对齐 / 位深 + `data` + 数据长。全部**小端**。
*/
fun wavHeader(pcmBytes: Int): ByteArray {
val data = ByteArray(HEADER_BYTES)
putAscii(data, 0, "RIFF")
putIntLe(data, 4, HEADER_BYTES - 8 + pcmBytes)
putAscii(data, 8, "WAVE")
putAscii(data, 12, "fmt ")
putIntLe(data, 16, 16)
putShortLe(data, 20, AUDIO_FORMAT_PCM)
putShortLe(data, 22, CHANNEL_COUNT)
putIntLe(data, 24, SAMPLE_RATE_HZ)
putIntLe(data, 28, BYTES_PER_SECOND)
putShortLe(data, 32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
putShortLe(data, 34, BITS_PER_SAMPLE)
putAscii(data, 36, "data")
putIntLe(data, 40, pcmBytes)
return data
}
/** 头 + PCM = 一个可直接交给任何 `WAV` 解码器的字节数组。 */
fun toWav(pcm: ByteArray): ByteArray {
val header = wavHeader(pcm.size)
val out = ByteArray(HEADER_BYTES + pcm.size)
header.copyInto(out, 0)
pcm.copyInto(out, HEADER_BYTES)
return out
}
/**
* 这段 PCM 该不该发、发出去长什么样(**唯一**的准入判据,录音机与 VM 共用)。
*
* 判序是刻意的:**空 → 超限 → 太短 → 可发**。
* - 空:一个字节都没有(设备没给 / 权限刚被撤),连「太短」都谈不上;
* - 超限:长度或字节数已经越线,绝不能发(哪怕时长看起来正常——硬件也可能给出异常长的一块);
* - 太短:见 [MIN_RECORDING_MILLIS]。
*/
fun plan(pcm: ByteArray): VoiceClipPlan {
if (pcm.isEmpty()) return VoiceClipPlan.Empty
val total = totalBytesFor(pcm.size)
if (!withinLimit(total)) return VoiceClipPlan.TooLarge(total)
val duration = durationMillisOf(pcm.size)
if (duration < MIN_RECORDING_MILLIS) return VoiceClipPlan.TooShort(duration)
return VoiceClipPlan.Ready(toWav(pcm), duration)
}
/** RIFF 的 `audioFormat`:1 = PCM(整数)。 */
private const val AUDIO_FORMAT_PCM: Int = 1
private fun putAscii(target: ByteArray, offset: Int, text: String) {
for (index in text.indices) target[offset + index] = text[index].code.toByte()
}
private fun putIntLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
target[offset + 2] = ((value ushr 16) and 0xFF).toByte()
target[offset + 3] = ((value ushr 24) and 0xFF).toByte()
}
private fun putShortLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
}
}
/** [VoiceClip.plan] 的四档结论。 */
sealed interface VoiceClipPlan {
/** 一段**可以发**的语音:已经封好 WAV,[durationMs] 是它的实际时长。 */
class Ready(val wav: ByteArray, val durationMs: Int) : VoiceClipPlan
/** 一个字节都没录到。 */
data object Empty : VoiceClipPlan
/** 短于 [VoiceClip.MIN_RECORDING_MILLIS](按下就松手那种误触)。 */
data class TooShort(val durationMs: Int) : VoiceClipPlan
/** 总字节数超过 [VoiceClip.MAX_AUDIO_BYTES]。 */
data class TooLarge(val totalBytes: Int) : VoiceClipPlan
}
/** 空录音的如实告知(**不是错误**:什么都没发生,用户重来一次即可)。 */
const val VOICE_EMPTY_MESSAGE: String = "没有录到声音,再按一次试试"
/** 太短的如实告知。 */
const val VOICE_TOO_SHORT_MESSAGE: String = "录得太短了,说满一秒再发"
/**
* 超限的如实告知。刻意说「没发出去」:那句话必须与事实上一致(这条一个字节都没上行),
* 也不能把责任推给用户(他确实就说了那么久,是本端的闸门挡下的)。
*/
const val VOICE_TOO_LARGE_MESSAGE: String = "这段语音太长,本机没有发出去"
/** 这一条被本端挡下时该说的那句话;`null` = 可以发(纯函数)。 */
fun voiceClipRejection(plan: VoiceClipPlan): String? = when (plan) {
is VoiceClipPlan.Ready -> null
VoiceClipPlan.Empty -> VOICE_EMPTY_MESSAGE
is VoiceClipPlan.TooShort -> VOICE_TOO_SHORT_MESSAGE
is VoiceClipPlan.TooLarge -> VOICE_TOO_LARGE_MESSAGE
}
/**
* 录音中 / 待重发那一段的时长文案(纯函数):`m:ss`,**从 0:00 起就有值**。
*
* 与接收侧的 [voiceDurationLabel] 差一条:那个对「不足一秒」回 `null`(服务端没给准数时宁可
* 不显示),而这里显示的是**本端自己正在数的时间**——0:00 是事实,不是冒充。
* 负数按 0 处理(时钟回拨 / 调用方算错时也不出现 `-1:-1`)。
*/
fun voiceElapsedLabel(elapsedMs: Int): String {
val totalSeconds = elapsedMs.coerceAtLeast(0) / 1000
return "${totalSeconds / 60}:${(totalSeconds % 60).toString().padStart(2, '0')}"
}
/** 语音消息的幂等键前缀:与文本那条(`local-`)/ 分享那条(`share-`)同一手法,见 [voiceClientMsgIdFor]。 */
const val VOICE_CLIENT_MSG_ID_PREFIX: String = "voice-"
/**
* 一条**录好、还没发出去**的语音在本端的记账(只有小字段,audio 不在这里)。
*
* `clientMsgId` 是契约 v0.96 §3.15 的幂等键:同一个非空值服务端**只承认第一条**,重复到达回
* `chat_duplicate`、不落库不起回合。所以「重发这一段」必须**复用**它([voiceClientMsgIdFor]),
* 换了新键就是让服务端把同一句话再答一遍。
*
* `audio`(base64)刻意**不进** [top.yeij.cyrene.ui.chat.ChatUiState]:那是一格会被反复 `copy`
* 并逐字段比较的状态(流式期间每秒几十次),几 MB 的字符串放进去等于每次比较都扫一遍它。
* 字节留在 VM 自己那个字段上(生命周期与 VM 相同,转屏不丢)。
*/
data class VoiceDraftInfo(
val clientMsgId: String,
val durationMs: Int,
val totalBytes: Int,
)
/**
* 这一段该用哪个幂等键(纯函数):
* - [draft] 非空 = **重发刚才没发出去的那一段** ⇒ 原样复用它当时的键(契约要求);
* - [draft] 为空 = 一段**新**录音 ⇒ 现铸一个:前缀 + [mint] 给的随机串(随机而不是自增,
* 理由与文本那条相同:服务端的判重窗口是整个会话历史)。
*/
fun voiceClientMsgIdFor(draft: VoiceDraftInfo?, mint: () -> String): String =
draft?.clientMsgId ?: "$VOICE_CLIENT_MSG_ID_PREFIX${mint()}"
@@ -0,0 +1,311 @@
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.data.api.dto.VoiceDto
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.VoiceTooLargeException
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
/**
* `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得;
* `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`);
* `tts` = **我们自己合成的出站语音**(她的 `speak` 工具产出的那一段,后端 M57-M59 新增)。
*
* 三个取值在**接收侧**没有分叉:合成成功的那条与入站语音同形(`status = transcribed` + 四键
* 副本齐全),照同一条路渲染;合成降级的那条是 `status = failed`(见 [VoiceStatus.FAILED])。
*/
object VoiceSource {
const val ASR: String = "asr"
const val PLATFORM: String = "platform"
const val TTS: String = "tts"
}
/**
* `voice.status` 的闭集(契约 §4.4,**三态**)。
*
* 与 [DocumentStatus] 同一条纪律:**只认闭集里的取值,认不出的不猜**——但两者收尾的方式不同。
* 文档的未知 `status` 要**原样显示**(那是给用户看的结论),语音的未知 `status` 只是
* 「本端不多说一句话」(正文由服务端给,见 [voiceNote])。
*/
object VoiceStatus {
/** 正文**就是**转写文本(出站方向 = 合成成功、有副本可播)。 */
const val TRANSCRIBED: String = "transcribed"
/** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */
const val EMPTY: String = "empty"
/**
* 这一条**没有可播的语音**,正文是一句话(见 [isPlayableVoice])。
*
* 两个方向同形、语义统一(后端 M57-M59 预告,契约 v0.98 待正式回执):
* - **入站**:没拿到转写文本 ⇒ 正文是服务端给的那行中文诚实提示;
* - **出站**:她本想用语音、合成降级回文字 ⇒ 正文是普通文字,另带 `fallbackReason`
* (见 [voiceFallbackNote])。
*
* 入站方向设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
* 无字节一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
*/
const val FAILED: String = "failed"
}
/**
* 「转写成功但没识别出文字」时本端补的那一句。
*
* 为什么这句是**本端的**而不是服务端的原话:这一态的语义由契约定义(§4.4 的三态之一),而
* `empty` 的正文按定义就是空的——不补一句的话,一条「微信语音、没有音频副本、也没转出文字」
* 的消息在屏幕上**只剩一个「语音」标签**,看的人无从知道发生了什么。这不是替服务端改文案
* (那句话说的是**这一态的语义**,不是失败原因),也**不覆盖**任何正文。
*/
const val VOICE_EMPTY_NOTE: String = "没有识别出文字"
/**
* `status == "failed"` 的那一行**灰色小标**的固定前半句(后面可能跟服务端给的 `fallbackReason`)。
*
* 为什么要有这一行:这一态的消息 `voice` 键**在**、但**没有可播的语音**(见 [isPlayableVoice]),
* 所以屏幕上不会有语音形态。少了这一行,一条「她本想发语音、合成降级成文字」的消息与一条普通文字
* 消息**逐像素相同**——看的人无从知道这条本来是一段声音。它只说本端能确定的那件事(这条没有可播的
* 声音),**不解释原因**(原因归服务端,见 [voiceFallbackNote] 里那段原话)。
*
* ⚠️ 措辞要同时站得住两个方向(入站转写失败 / 出站合成降级),所以**不写**「转写失败」也**不写**
* 「合成失败」:本端分不清,也不该替服务端选一个说法。
*/
const val VOICE_FALLBACK_NOTE: String = "这条语音没有可播放的声音"
/**
* 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。
*
* 与取字节失败([voicePlaybackFailureMessage])分开:两者要用户做的事不一样——字节没拿到是
* 「网络 / 过期 / 服务端没有这份副本」,可以再点一次;这一段是「字节在手上,本机解不开」
* (格式不支持、文件损坏),再点一次多半还是同样的结果。
*/
const val VOICE_UNPLAYABLE_MESSAGE: String = "这段语音本机播不了"
/**
* 一条语音的**可播放副本**:四个键(`name` / `mime` / `size` / `url`)齐全时才构造得出来。
*
* 存在的理由是把「能不能播」这件事**收成一个类型**,而不是让 UI / 播放器各自去测四个 `!= null`:
* 拿到 [VoiceAudio] 就等于「有副本、可以画播放控件、可以发请求」;拿不到就**一个播放控件都不画**
* (不能给一个点下去必然失败的按钮)。
*
* @property durationMs 从 [VoiceDto] 原样带过来;`null` = 服务端没给,**不显示时长**。
*/
data class VoiceAudio(
val name: String,
val mime: String,
val size: Long,
val url: String,
val durationMs: Int? = null,
)
/**
* `voice` 键在不在(纯函数)—— 「这条消息**来自**一条语音」。
*
* 这是契约 §4.4 给的**唯一**来源判据:非语音消息整键缺席(不是 `{}` 也不是 `null`),
* `content` 里有没有 `[语音]` 这类字样与本判据无关(QQ 的占位标记自 v0.67 起不再下发,
* 别人也可以自己打出一模一样的字)。
*
* ⚠️ 它与「要不要画语音气泡」**不是同一件事**了:合成降级 / 转写失败的那些行 `voice` 键照样在,
* 但**没有可播的语音**(见 [isPlayableVoice])。判「是不是语音消息」用本函数,判「画不画语音
* 形态」用 [isVoiceMessage]——两个问题分开问,混用会让降级行被画成一个必然播不了的气泡。
*/
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`。
*
* 服务端保证「四者同生共死」,本端仍然逐项检查:**这条判据的下游是「给不给播放控件」**,
* 而契约里那份保证一旦被某个部署打破,唯一的后果就是用户点到一个必然失败的按钮
* ——宁可少画一个控件,也不要画一个假的。
*
* `size` 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西
* (尤其**不反算时长**,见 [voiceDurationLabel])。
*/
fun voiceAudio(voice: VoiceDto?): VoiceAudio? {
if (voice == null) return null
val name = voice.name?.takeIf { it.isNotBlank() } ?: return null
val mime = voice.mime?.takeIf { it.isNotBlank() } ?: return null
val url = voice.url?.takeIf { it.isNotBlank() } ?: return null
val size = voice.size ?: return null
return VoiceAudio(name = name, mime = mime, size = size, url = url, durationMs = voice.durationMs)
}
/**
* 时长文案(纯函数):`m:ss`(如 `0:03` / `1:05`)。
*
* **缺席就不显示**是契约的硬要求(服务端「读不出来就省略键,绝不估算」,本端同理):
* - `null` ⇒ 回 `null`(调用方因此**一个字符都不画**,更不会出现 `0:00` 那种冒充);
* - `<= 0` ⇒ 也回 `null`:`0` 毫秒的语音不存在,把服务端的一个异常取值显示成 `0:00`
* 就是拿一个编出来的数字冒充事实;
* - **不足一秒**(`1..999`)⇒ 也回 `null`:`m:ss` 只能把它显示成 `0:00`,而 `0:00` 与
* 「没有时长」在屏幕上是同一个样子——与其显示一个看起来像没读到的时间,不如什么都不显示
* (渠道也不会真的发这种语音);
* - 不设上限:语音消息不会长到需要小时制,超过 99 分钟就照实显示 `124:03`(**不折算**成
* 「2:04:03」——那是另一种自己发明的表达)。
*/
fun voiceDurationLabel(durationMs: Int?): String? {
if (durationMs == null || durationMs < 1_000) return null
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
/**
* 语音形态**之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
*
* 只对 `status == "empty"` 且**正文为空**时成立:
* - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补);
* - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾**
* (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话);
* - 未知 `status`(含空串):**不猜**,只显示正文。
*
* ⚠️ `failed` 那一态**不走这里**(它连语音形态都不画):正文由服务端给,本端另加一行小标
* ([voiceFallbackNote])。本函数仍对 `failed` 回 `null`,因为「语音形态旁边多说一句」这个位置
* 对那一态根本不存在。
*/
fun voiceNote(status: String, content: String): String? =
if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null
/**
* `status == "failed"` 时那行灰色小标该写什么;别的状态回 `null`(纯函数)。
*
* 两段拼成一句话:**固定前半句**([VOICE_FALLBACK_NOTE],说的是本端能确定的事实)+ 服务端给的
* `fallbackReason`(**一个字符都不改**,原样放进括号里)。
*
* ## 为什么原样放、不做映射
* `fallbackReason` 的取值集合**后端还没定**(`devdocs/02` 里我们已经问了「是闭集还是自由文本」,
* 尚未答复;契约 v0.98 的正式回执也还没到)。在有答案之前,本端**不猜**它是不是闭集、更不替它
* 编一套友好文案:认不出的取值被映射成一句话,比原样显示更难排查——用户报「显示成『语音超时』」
* 时,我们连服务端到底给了什么都不知道。原样显示,最坏是屏幕上出现一个 `tts_timeout` 这样的词,
* 而它**正是**我们和后端对账时要的那个字符串。
*
* ## `fallbackReason` 缺席时
* 只有固定前半句。这一档同时是**空正文 + `failed`** 的兜底:这一态的消息正文按契约应当非空
* (服务端那行诚实提示 / 降级后的原文本),但本端**不靠**这条保证——真收到一条空正文的 `failed`
* 行时,屏幕上至少还有这行小标,而不是一个**什么都没有**的空白(那正是空气泡那种最糟的呈现)。
*/
fun voiceFallbackNote(voice: VoiceDto?): String? {
if (voice == null || voice.status != VoiceStatus.FAILED) return null
val reason = voice.fallbackReason?.takeIf { it.isNotBlank() } ?: return VOICE_FALLBACK_NOTE
return "$VOICE_FALLBACK_NOTE($reason)"
}
/**
* 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。
*
* 契约明写 `url` 是唯一的派生字段(`/api/v1/media/<name>`),**客户端绝不自己拼路径**——所以
* 本函数只做「保留 path、换成当前 host」这一件事,与 [ImageHostRebase] 同一取向
* (图片那条要换的是**别人给过的绝对地址**里的 host,这里要接的是**服务端给的相对路径**)。
*
* 判据取最小容忍度,任何一条不满足就回 `null`(调用方据此如实报「暂时播不了」,**绝不**
* 退回「自己拼一条 `/api/v1/media/<name>`」——那正是契约禁止的那件事):
* - [url] 必须以 `/api/v1/media/` 开头,且后面**恰好**是一段名字:非空、不是 `.` / `..`、
* 不含 `/` `\` `%` `?` `#` 与 NUL;
* - [baseUrl] 必须能解出 `scheme://authority`(空串 / 没有 host 都解不出)。
*
* 用的是 [baseUrl] 的 scheme + authority(所以 `http → https`、端口变更都跟着走),**不用**
* baseUrl 里的路径:这条地址挂在根路径上,部署形态本来就没有路径前缀这一说。
*/
fun voiceMediaUrl(url: String, baseUrl: String): String? {
val name = url.removePrefix(MEDIA_PREFIX)
if (!url.startsWith(MEDIA_PREFIX) || !isSafeMediaName(name)) return null
val origin = originOf(baseUrl) ?: return null
return origin + url
}
/**
* 取字节失败时的文案(纯函数,可单测)。
*
* 与 `documentLoadFailureMessage` / `imageLoadFailureMessage` 同一纪律:服务端能说清的用它的
* 原话,本端能分类的分开说,**绝不把失败渲染成「静音」**——一条点了没反应的语音消息在用户眼里
* 与「坏了」没有区别。
*
* 三档本地失败分开写,因为它们要用户做的事不一样:地址拼不出来 = 本端环境不对(重新登录 / 换
* 服务器);太大 = 这条本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
*/
fun voicePlaybackFailureMessage(e: Throwable): String = when (e) {
is VoiceUrlUnavailableException -> "当前服务器地址不可用,暂时播不了这条语音"
is VoiceTooLargeException -> "这条语音太大,本机放不了"
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时播不了这条语音"
is CyreneException -> when {
// ⚠️ 刻意**不**走 `toDocumentFailure()`:那个映射只认文档那四个码(`document_not_found`),
// `media_not_found` 在它眼里是「未知码」⇒ 会把「服务端上已经没有这条语音了」说成
// 「语音加载失败,media not found」。这一档本端说得准,就说得准一点。
e.error.isMediaNotFound() -> "服务端上已经没有这条语音了"
else -> "语音加载失败," + e.error.userMessage
}
else -> "语音加载失败,请稍后重试"
}
/** 服务端对「名字非法 / 穿越 / 不存在」统一回这一个码(U19:**不区分**,不给探测面)。 */
private const val MEDIA_NOT_FOUND_CODE = "media_not_found"
private fun CyreneError.isMediaNotFound(): Boolean =
this is CyreneError.Server && code == MEDIA_NOT_FOUND_CODE
/** 服务端相对路径的前缀(契约 §1.2 U19)。 */
private const val MEDIA_PREFIX = "/api/v1/media/"
/**
* 媒体名是不是「恰好一段」:不许有分隔符、百分号编码、查询串,也不许是 `.` / `..`。
*
* 与 `ImageHostRebase` 的同名判据同一条理由:这个字符串来自**别人给的**数据,
* 看不懂的形态就不该被我们拼进一条请求地址。
*/
private fun isSafeMediaName(name: String): Boolean {
if (name.isEmpty() || name == "." || name == "..") return false
return name.none { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }
}
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
private fun originOf(baseUrl: String): String? {
val rest = when {
baseUrl.startsWith("http://", ignoreCase = true) -> baseUrl.substring("http://".length)
baseUrl.startsWith("https://", ignoreCase = true) -> baseUrl.substring("https://".length)
else -> return null
}
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
val authority = rest.substring(0, end)
if (authority.isBlank()) return null
return baseUrl.take(baseUrl.length - rest.length) + authority
}
@@ -0,0 +1,93 @@
package top.yeij.cyrene.domain.chat
/**
* `voice_output` 的**判据**(纯函数,可穷举单测):契约 v0.96 §3.15 下行帧。
*
* 帧形状(后端 `回执三十六`):
* ```json
* { "type": "voice_output",
* "data": { "audio": "<base64>", "format": "wav", "text": "...", "reply": "她的话",
* "sessionId": "web", "deviceId?": "...", "provider?": "silent|emotiontts|..." } }
* ```
*
* ## `text` 与 `reply` 是两个东西(本端怎么用)
* - **[text]**:这一轮**转写出来的话**——也就是我们那段音频被识别成什么。它与
* `voice_input.text`(「已自行转写则跳过 STT」)同一个键名、同一个语义,只是方向相反。
* - **[reply]**:**她说的话**(音频念的就是它),也就是用户真正要听/要看的那一句。
*
* 于是「屏幕上该显示哪一句」的答案是 [voiceReplyText]:**`reply` 优先**,它缺席时才退到 `text`
* (宁可显示转写也不显示空白),两者都缺席就什么都不显示(绝不编一句)。
*
* ## `provider` 是**降级信号**,不是装饰
* `provider == "silent"` = 这一轮**没有真音频**(`audio` 空,但 `text` / `reply` 有)。
* 契约明写「UI 别把它画成播放器」——画了就是一个点下去必然失败的按钮(与接收侧
* `voiceAudio` 的「四个键不齐就不画播放控件」同一条纪律)。
*
* 除此之外 `audio` 空白也按「没有音频」处理([voiceOutputHasAudio]):契约说 silent 时
* `audio` 为空,反过来「空 audio + 没给 provider」也绝不能画出一个播放器。
*
* ## 它与 `voice` 键那条(接收侧放别人发来的语音)**互不相干**
* 见 [isVoiceMessage]/[voiceAudio]:那是消息 DTO 的历史副本,这里是**本端刚发出去的那一段**的
* 实时应答——两者不共用判据,共用只会把两套字段口径搅在一起。
*/
/** `provider` 里那个**降级档**的取值(契约 v0.96)。 */
object VoiceProvider {
/** 这一轮**没有真音频**(`audio` 空、`text`/`reply` 有)。 */
const val SILENT: String = "silent"
}
/**
* 这一帧是不是「没有真音频」那一档(纯函数)。
*
* 只认字面 `silent`(忽略首尾空白与大小写:它是机器可读码,不是文案)。未知取值一律**不当**
* silent——那是另一个 provider,`audio` 在不在由 [voiceOutputHasAudio] 按字节判,不靠猜。
*/
fun isSilentVoiceProvider(provider: String?): Boolean =
provider?.trim().equals(VoiceProvider.SILENT, ignoreCase = true)
/**
* 这一轮**有没有真音频**(纯函数):`provider != silent` **且** `audio` 非空白。
*
* 两个条件都不许省:silent 档的 `audio` 契约上就是空的(万一真给了也不算数,因为服务端已经
* 明说这一轮没发音),而非 silent 档的 `audio` 也可能因为编码失败缺席。
*/
fun voiceOutputHasAudio(audio: String?, provider: String?): Boolean =
!isSilentVoiceProvider(provider) && !audio.isNullOrBlank()
/**
* 屏幕上该显示的那一句(纯函数,见文件头「`text` 与 `reply` 是两个东西」)。
* `reply` 非空白 ⇒ 它;否则 `text` 非空白 ⇒ 它;都没有 ⇒ `null`(一个字符都不显示)。
*/
fun voiceReplyText(reply: String?, text: String?): String? =
reply?.takeIf { it.isNotBlank() } ?: text?.takeIf { it.isNotBlank() }
/**
* 这张卡片下面要补的那一句(纯函数:不需要补时回 `null`)。
*
* 两档**都要解释,但说的不是同一件事**:
* - [silent]:**服务端**这一轮没有真音频(`provider == "silent"`)⇒「她只回了文字」。不解释的话,
* 用户按着麦克风说话、回来的却是一段文字,分不清「她选择只回文字」与「播放器坏了」。
* - 有音频却没落地([hasAudioFile] 为假):那是**本端**解码 / 写缓存失败 ⇒ 说「这段语音本机没
* 准备好」,绝不把本机的故障说成「她只回了文字」(那是替服务端下了一个它没下的结论)。
*/
fun voiceReplyNote(silent: Boolean, hasAudioFile: Boolean): String? = when {
silent -> VOICE_SILENT_NOTE
!hasAudioFile -> VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE
else -> null
}
/**
* 「这一轮没有音频」的说明。刻意说清是**服务端**这一轮没给音频(`provider` 的降级信号),
* 而不是「本机播不了」——后者会把人引向「换个播放器」这种无用的动作。
*/
const val VOICE_SILENT_NOTE: String = "这一轮她只回了文字,没有语音"
/**
* 「音频有、但本机没准备好」的说明(解码 / 写缓存失败)。
*
* 与 [VOICE_SILENT_NOTE] 分开是必须的:这一档的真相是**本端**的问题,而文字照样在
* ([voiceReplyText] 已经把它取出来了),所以用户至少还能读到她说的话。
*/
const val VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE: String = "这段语音本机没能准备好,先看文字"
@@ -0,0 +1,121 @@
package top.yeij.cyrene.domain.chat
/**
* 语音播放的**状态**(纯数据,可单测)。
*
* ## 为什么要有它,而不是让每个气泡各自 `remember { mutableStateOf(false) }`
* 同一时刻**只能有一条语音在响**(一台设备一个喇叭,两条语音叠着放一定是 bug),而
* 「哪一条在响」是**跨气泡**的事实:用户点了另一条,前一条必须停下来并释放解码器。
* 于是这份状态由**一条播放通道**持有,气泡只是它的视图。四个态刻意只有四个:
*
* - [Idle]:什么都没在放(也是「停下来之后」唯一的终点);
* - [Loading]:字节在取(首次播放要先下载;第二次起走本地缓存,一闪而过);
* - [Playing]:真的在响(`MediaPlayer` 已经 `start()` 过,还没走完 / 还没被停);
* - [Failed]:**这一次尝试**没成功,[Failed.message] 是给用户看的那句话。
*
* [Failed] 携带的是**这条消息**的失败(不是全局横幅):用户点的是这一条,出问题的是这一条。
* 它**不粘**:再点一次就从 [Loading] 重新开始(见 [pressed])。
*
* ## 每条转换都带 `messageId`,因为回调会迟到
* `MediaPlayer` 的回调(prepared / completion / error)与取字节的协程都可能**在用户已经改主意
* 之后**才到达。所以每一次转换都要**比对目标**:过期的回调**一个都不许**改状态
* ([prepared] / [failed] / [finished] 的同一条纪律)。这不是防御性编程的洁癖——
* 少了它就会出现「播放器已经换了、界面还显示上一条在响」。
*/
sealed interface VoicePlaybackState {
/** 没有被这条通道占着的消息(初始态,也是停 / 出错 / 放完之后的终点)。 */
data object Idle : VoicePlaybackState
/** 正在取这一条的字节(下载 / 读缓存)。 */
data class Loading(val messageId: String) : VoicePlaybackState
/** 这一条正在响。 */
data class Playing(val messageId: String) : VoicePlaybackState
/** 这一条的上一次尝试没成;[message] 是给用户看的那句话(见 `voicePlaybackFailureMessage`)。 */
data class Failed(val messageId: String, val message: String) : VoicePlaybackState
}
/**
* 这条消息此刻正被这条播放通道占着吗([VoicePlaybackState.Loading] 或
* [VoicePlaybackState.Playing])。
*
* [VoicePlaybackState.Failed]**不算**:它表示这条已经不在放,用户再点一下就是「重试」。
*/
fun VoicePlaybackState.isActiveFor(messageId: String): Boolean = when (this) {
is VoicePlaybackState.Loading -> this.messageId == messageId
is VoicePlaybackState.Playing -> this.messageId == messageId
else -> false
}
/** 这条消息**正在响**吗(只认 [VoicePlaybackState.Playing],加载中不算)。 */
fun VoicePlaybackState.isPlayingFor(messageId: String): Boolean =
this is VoicePlaybackState.Playing && messageId == this.messageId
/**
* 这条消息**正在取字节**吗(只认 [VoicePlaybackState.Loading])。
*
* 与 [isPlayingFor] 分开是必须的:「正在加载」与「正在响」对用户是两件事,界面上那句
* 「加载中…」只能在**真的在加载**时出现——混着用会让一条 30 秒的语音在整段播放期间都写着
* 「加载中…」。
*/
fun VoicePlaybackState.isLoadingFor(messageId: String): Boolean =
this is VoicePlaybackState.Loading && messageId == this.messageId
/**
* 这条消息上一次尝试失败时那句提示;不是它 / 没失败过就回 `null`(气泡据此决定画不画那一行)。
*/
fun VoicePlaybackState.failureOf(messageId: String): String? =
(this as? VoicePlaybackState.Failed)?.takeIf { it.messageId == messageId }?.message
/**
* 用户点了这条消息的播放 / 暂停。
*
* - 点的是**正在放 / 正在加载的那一条** ⇒ 回 [VoicePlaybackState.Idle](= 停,调用方据此停下
* 并释放;「再点一下取消」对加载中同样成立——用户不该被一个慢下载困住);
* - 点的是别的(或上一次失败的)那一条 ⇒ 换成它的 [VoicePlaybackState.Loading]。
*
* 有一条**调用方职责**不在状态里:进入 [VoicePlaybackState.Loading] 之前必须把上一条真的停掉
* (见 `ui.chat.VoicePlayer.toggle`)。纯函数只说「状态应该变成什么」。
*/
fun VoicePlaybackState.pressed(messageId: String): VoicePlaybackState =
if (isActiveFor(messageId)) VoicePlaybackState.Idle else VoicePlaybackState.Loading(messageId)
/**
* 字节到手、`MediaPlayer` 已经 `start()` 过:`Loading(同一条)` ⇒ `Playing(同一条)`。
*
* 目标不对(用户已经点了别的 / 点了停)或当前不是 `Loading` 时**原样返回**——迟到的回调不许
* 把界面从「另一条在响」拽回来。
*/
fun VoicePlaybackState.prepared(messageId: String): VoicePlaybackState =
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
VoicePlaybackState.Playing(messageId)
} else {
this
}
/**
* 取字节 / 起播失败:`Loading(同一条)` ⇒ `Failed(同一条, message)`;其余情况原样返回。
*
* 只从 [VoicePlaybackState.Loading] 出发是刻意的:**正在响的那一条**不会被一个迟到的失败
* 回调说成失败(`Playing` 阶段的解码错误走 [finished] 那条路,由播放器自己先停下再报)。
*/
fun VoicePlaybackState.failed(messageId: String, message: String): VoicePlaybackState =
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
VoicePlaybackState.Failed(messageId, message)
} else {
this
}
/** 放完了:`Playing(同一条)` ⇒ [VoicePlaybackState.Idle](播完就回初始外观,不留痕迹)。 */
fun VoicePlaybackState.finished(messageId: String): VoicePlaybackState =
if (isPlayingFor(messageId)) VoicePlaybackState.Idle else this
/**
* 停下来(用户点了停 / 换了另一条 / 离开会话 / 组件销毁)⇒ [VoicePlaybackState.Idle]。
*
* **任何态都收**,包括 [VoicePlaybackState.Failed]:失败那句话的生命周期与这一次尝试绑定,
* 换了目标或页面重进就不该再挂着。
*/
val VoicePlaybackState.silenced: VoicePlaybackState get() = VoicePlaybackState.Idle
@@ -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"
@@ -14,11 +14,28 @@ import kotlin.math.min
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
* - `<br>`(`<br>` / `<br/>` / `</br>` 三种写法等价,大小写不敏感,属性不解析):**与 `<p>` 同级
* 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 `<br>` 始终处理为多条消息
* 隔断」)。它**不是行内标签**:`<p>甲<br>乙</p>` ⇒ 两条气泡,`甲<br>乙`(无 `<p>`)⇒ 同样两条;
* 中间的空气泡照旧丢弃(`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`)。切分在 [tokenize] 的**段/块这一层**
* 完成([appendRegion]),所以行内解析层(`InlineMarkup`)几乎看不见它。
* ⚠️ `回执三十三 §三②` 把「**始终**分隔」收紧了:**只有普通文本层的 `<br>` 才分隔**——
* `<code>` / `<action>` / `<narration>` **内部**的不分隔([appendRegion] 的保护区,
* 口径与理由写在它和 [protectedSpans] 的 KDoc 里)。
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
* 注记内部**不做行内渲染**(既有口径),所以里面的 `<br>` 与 `<b>` 一样只是字面文本。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<u>`
* (**六项**标签名——`u` 由 `回执三十三 §一` 加回、`s` 仍不在内),成对才认、**标签名大小写不敏感**、
* **属性一律不解析**(`<b class=x>` 就是粗体)、白名单外按纯文本原样显示、
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
* (口径与理由见 `parseInlineMarkup`)。
* 它在**段落与 `<br>` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**),
* 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
@@ -35,7 +52,10 @@ import kotlin.math.min
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
* 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
@@ -47,7 +67,17 @@ import kotlin.math.min
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 行内开标签同样入栈扣留(词表见 [INLINE_TAG_NAMES],判据见 [parseInlineTag]):不扣的话
* 中间态会把 `<b>` 当字面吐上屏(`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的
* 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`<B class=x>` 在扣留侧同样是
* 开标签,否则 `日落 <B class=x>18` 会先按字面吐上屏、收口时再变成粗体。
* `<br>` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`<br`、`<br/`、
* `</br`)时按词表扣留([isPotentialTagPrefix] → [couldBeTagName]),完整的 `<br>` 直接放行
* ——分隔符一出现,前缀就已经可解析了。
* **保护区内的 `<br` 另外还有一道**:`<code>` / `<action>` / `<narration>` 的开标签本来就在
* 栈里,所以整个作用域(连同里面还没写完的 `<br`)一起被扣到闭合为止,残缺的 `<br` 不会
* 在半截作用域里先露一次脸(由 `streamingWithholdsPartialLineBreakInsideProtectedScopes` 钉着)。
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`、`<br`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
@@ -83,6 +113,16 @@ data class ParsedBubble(
val parts: List<ContentPart>,
val head: List<ContentPart>,
val tail: List<ContentPart>,
/**
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>` / `<u>`,见 [parseInlineMarkup]):非空时
* 拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
* 惊动任何一个既有调用方。
*/
val runs: List<InlineRun> = emptyList(),
) {
val isEmpty: Boolean
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
@@ -114,15 +154,42 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
private const val FULL_STAR_MAX_LEN = 40
/**
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
*
* `<br>` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
* [couldBeLineBreakNamePrefix],否则 `<BR` 会被漏扣、中间态把 `<BR` 当正文吐上屏。
*/
private val TAG_NAMES = listOf("action", "narration", "p")
private val STRUCTURE_TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^</p>$")
/** `<br>` 分隔符的标签名([BR_TAG_RE] 与流式扣留侧共用,别在两处各写一遍)。 */
private const val LINE_BREAK_TAG_NAME = "br"
/**
* `<br>` **分隔符**(契约 v0.89 更正:`<br>` 不是行内标签,而是与 `<p>` 同级的「气泡/消息分隔符」)。
*
* 认下来的形状:`<br>` / `<br/>` / `<br />` / `</br>` / `<br class=x>` …——
* - **三种写法等价**,前导 `/` 与自闭合 `/` 都照收(HTML5 口径,契约写死);
* - **大小写不敏感**(`<BR>` / `<Br>` 同样算):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,
* 且后端 IM 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 的原话);
* - **属性不解析**(`<br class=x>` 照样分隔),但名字后面必须紧跟 `>`、空白或 `/`:
* `<br2>` / `<boolean>` / `<br-x>` 因此**不是**分隔符,只是普通文本(这一条是「字符一个不丢」的闸门)。
*
* ⚠️ 已知边界:它**不看外层标签的属性结构**——所以写成 `<b title="<br>">甲</b>` 这种「属性里带 `<br>`」的
* 文本会被切开(`回执三十三 §三③` 登记为两端口径相同、不修)。`<p …>` 开标签是唯一例外,因为它在更早
* 一层就被 [P_BLOCK_RE] 整体吃掉了,属性里的 `<br>` 根本走不到这里。真实产出里没有这种形状
* (属性本身就不是产出侧的东西)。
*
* ⚠️ 它只认**普通文本层**的那些:`<code>` / `<action>` / `<narration>` **内部**的 `<br>` 由
* [appendRegion] 按保护区跳过(`回执三十三 §三②`)——所以 `findAll` 的结果**不再是**「所有分隔点」,
* 用它时要么走 [appendRegion](会跳保护区),要么自己判一遍(别处没第二个消费者)。
*/
private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE)
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List<ParsedBubble> {
if (raw.isEmpty()) return emptyList()
@@ -170,44 +237,217 @@ private sealed interface Tok {
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
/**
* 先切 `<p>` 块(决定气泡边界),再按区剥落单标签与切 `<br>` 分隔符,最后在每个文本区做旁白/星号解析。
*/
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
// 完整块必然以 `</p>` 收尾,所以最后一个 `</p>` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话,
// 每个 `<p` 候选点都会带着懒惰的 `([\s\S]*?)</p>` 一路回溯到串尾——正文里 N 个落单标签时
// 这里就是 O(N²):实测 20000 个要 8.2 秒(同一输入下 `stripLooseTags` 那边还要 23 秒,见
// [ContentAfterQuery])。切掉后同一输入 1 毫秒内出结果,且匹配结果逐字不变(每个匹配都整体落在
// 被切下的前缀里,正则的尝试顺序也没变)。
val lastClose = raw.lastIndexOf("</p>")
val pMatches =
if (lastClose < 0) {
emptyList()
} else {
P_BLOCK_RE.findAll(raw.substring(0, lastClose + "</p>".length)).toList()
}
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
appendAnnotations(
stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false),
out,
splitBlankLines = true,
)
appendRegion(raw, out, markupMode = false, stripStrayRoleTags = false, splitBlankLines = true)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) {
appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false)
appendRegion(lead, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
}
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) {
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false)
appendRegion(inner, out, markupMode = true, stripStrayRoleTags = false, splitBlankLines = false)
}
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) {
appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false)
appendRegion(trail, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
}
return out
}
/**
* 解析一个文本区:先剥落单标签([stripLooseTags],`markupMode` / `stripStrayRoleTags` 由调用方给),
* 再按 `<br>` 分隔符切成若干段,每段各自做旁白/星号解析([appendAnnotations]),段与段之间补一个
* [Tok.Break](气泡边界)。
*
* **为什么 `<br>` 在这一层切**:契约 v0.89 的更正把 `<br>` 定成**与 `<p>` 同级的分隔符**
* (`回执三十一`「对消息内的 `<br>` 始终处理为多条消息隔断」)。它既然与 `<p>` 同级,就该与 `<p>`
* 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
* 行内层(`InlineMarkup`)因此几乎看不见 `<br>` —— 那里只剩六个白名单标签(唯一例外见下面的保护区:
* `<code>` 内部的那个 `<br>` 会随正文交到行内层,在那里按「内容」字面显示)。
*
* **顺序:先切分、再逐段各自做行内解析**(`<b>甲<br>乙</b>` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理,
* 两段都回落原文)。理由:
* 1. 「与 `<p>` 同级」这句话本身就规定了层序——分隔符先于行内解析,行内解析永远只看到**一段**;
* 跨过分隔符的标签在结构上不可能成立(一个气泡 = 一条消息,IM 出站也是先按气泡切开再剥标签,
* 后端 `sanitize.go` 的「成对平衡 ⇒ 剥标签留文本 / 不平衡 ⇒ 原样保留」同样是在**单条消息内**
* 判平衡的)⇒ 两端对同一段文本的结论天然一致;
* 2. 反过来(先整体行内解析、再按 `<br>` 切)会让「渲染文本」与「气泡」互相依赖:`<b>甲<br>乙</b>`
* 会先变成一个粗体片段再被切开,切点落在片段内部,`body` / `runs` 的一致性(`runs` 拼起来
* **就是** `body`)当场就没了;
* 3. 实现上也最省:行内那层一个字节都不用改(它本来就逐气泡跑一次)。
*
* 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `<p>` 后面还有没有正文」看的是
* 整段原文,`<br>` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。
*
* ## 保护区:`<code>` 与角色注记**内部**的 `<br>` 不分隔(`回执三十三 §三②`)
* `回执三十一` 说的是「**始终**分隔」,`回执三十三` 把它收紧了:**只有普通文本层的 `<br>` 才分隔**。
* 后端给的两条理由在我们这边一字不差地同样成立,所以照改:
* 1. **会漏注记正文**:`<action>她<br>点点头</action>` 若切成两段,就成了「她」「点点头」**两条消息**
* ——注记本来是不该当正文露出来的那一层,切开等于把它摆到台面上(后端原话:「那等于把注记正文
* 发给了对方」);
* 2. **`<code>` 会被切坏**:它是**成对**元素,从中间切开必然两半都不平衡,按「不平衡 ⇒ 不渲染」的
* 硬口径**整段降级为字面量**,用户直接看到裸标签。
*
* 实现:先扫出保护区区间([protectedSpans]),切分时**跳过**落在区间里的 `<br>`(它留在原文里,随该
* 作用域自己的语义走)。扫的是**已经剥过落单标签**的那一份文本,与切分用的是同一个对象。
*
* ### 作用域内部算什么语义(我们定的口径):**普通字符、逐字原样**(不换行、也不消掉)
* - `<code>`:这是它**既有的**口径——「里面连白名单标签也按内容处理」,`<code><b>甲</b></code>`
* 显示字面的 `<b>甲</b>`(`codeContentIsNotParsed` 钉着)。`<br>` 在这一层本来不是标签,用不着
* 另立规矩;`<code>` 也正好是「想原样显示尖括号怎么办」的现成答案。
* - `<action>` / `<narration>`:**必须是字面**。注记那一行画的是 `parts` 里的原文
* (`MessageBubble.AnnotationLine` 直接 `parts.joinToString`),而 `parts` = **逐字原文**是红线
* ——换成 `\n` 或删掉,就是在静默改用户内容(编辑框、搜索、通知读的都是它)。顺带也就与既有
* 口径对齐:`<action>笑<b>了</b></action>` 里的 `<b>` 同样是字面(`narrationTextIsLeftExactlyAsWritten`)。
* - 两个作用域取**同一条**规矩:它们是同一件事(`<br>` 落在作用域内部),各给一种读法才是真的
* 「各端各说各话」。
* - 为什么不把它当换行(`<code>甲<br>乙</code>` ⇒ `甲\n乙`):那必须在一处破例——要么在这里改掉
* 片段文本(`parts` 当场不再是原文),要么在行内层给 `<br>` 单开一条「代码里它算换行」的规则
* (W68 刚把「`<br>` → `\n`」从行内层整体删掉,而且那会让**同两个字符**在 `<code>` 与 `<action>`
* 里各是一种意思)。两条都不值当,所以不做。
*
* ### 未闭合的作用域:**不算作用域**,里面的 `<br>` 照旧分隔
* 判据只看「**成对**」,且与各自的消费侧同一把尺子:`<code>` 要找到它的 `</code>`(大小写不敏感、
* 可带属性——行内层就这么认),`<action>` / `<narration>` 要找到**同名**闭合(只认小写、不带属性
* ——`ROLE_RE` 就这么认)。找不到闭合的开标签不是作用域,于是 `<code>甲<br>乙` 照旧切成两条。
* - 理由一:**消费侧本来就不认它**。未闭合的 `<code>` 在行内层不是作用域(整段降级为原文),
* 未闭合的 `<action>` 在 `ROLE_RE` 那里也配不出旁白。去保护一个**消费侧根本不认**的作用域,
* 等于让「切分层」与「消费层」对同一段文本有两种结构判断——正是本项目一路在防的那种分叉
* (W68/W69 的教训:消费侧与扣留侧的词表必须同源)。
* - 理由二:**爆炸半径**。若把「未闭合 ⇒ 保护到这一段文本结尾」也算上,一个漏掉 `</code>` 的笔误
* 就会把**后面所有** `<br>` 一起吞掉、把本该分开的多条消息并成一条。「一个畸形标签改变整段的
* 分段」比「照旧分隔」坏得多。
* - 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切;
* 我们选与消费侧一致的那种。
* - **两支各算各的**([protectedSpans] 用两个独立状态):`<code>` 与角色注记各自配对,因为那本来
* 就是两个消费侧各自的尺子——行内层在 `<code>` 里只认自己的闭合、别的一律当内容;`ROLE_RE`
* 则完全不看 `<code>`(它在代码里照样配对)。所以「某一支没闭合」只影响**那一支**:
* `<code>甲<action>她<br>点点头</action>乙` 里那个**已经配对好**的 `<action>` 照样保护它的
* `<br>`(不产生区间的只是没闭合的 `<code>` 那一支)。
*/
private fun appendRegion(
text: String,
out: MutableList<Tok>,
markupMode: Boolean,
stripStrayRoleTags: Boolean,
splitBlankLines: Boolean,
) {
val stripped = stripLooseTags(text, markupMode, stripStrayRoleTags)
// 保护区只在「这一区里真有作用域」时才扫:绝大多数消息连 `<` 都没有(一个字节都不多花)。
val protected = if (stripped.indexOf('<') < 0) emptyList() else protectedSpans(stripped)
var last = 0
var span = 0
for (m in BR_TAG_RE.findAll(stripped)) {
val at = m.range.first
// 区间按起点升序,跟着 `<br>` 的推进往前走一个游标就够(不回头、不重扫)。
while (span < protected.size && protected[span].last < at) span++
if (span < protected.size && at in protected[span]) continue
val piece = stripped.substring(last, at)
if (piece.isNotEmpty()) appendAnnotations(piece, out, splitBlankLines)
out.add(Tok.Break)
last = m.range.last + 1
}
val tail = stripped.substring(last)
if (tail.isNotEmpty()) appendAnnotations(tail, out, splitBlankLines)
}
/**
* 扫出这一区里**已配对**的保护区区间(左闭右开,按**起点**升序):`<code>…</code>` 与
* `<action>…</action>` / `<narration>…</narration>`——[appendRegion] 据此跳过落在里面的 `<br>`
* (口径与理由写在它的 KDoc 里)。
*
* 一趟线性扫描,判据全部借现成的尺子:「哪儿是一个标签」用行内层的 [inlineTagEnd];`code` 用
* 行内层的 [openInlineTagName] / [closeInlineTagName](大小写不敏感、可带属性);角色注记用
* [openNoteTagName] / [closeNoteTagName](与 `ROLE_RE` 同口径:只认小写、不带属性、找**同名**闭合)。
*
* **两支各自独立配对**,因为那本来就是两个消费侧各自的尺子:`<code>` 那一支只认自己的开/闭
* (里面别的标签都是内容,与行内层一致);角色那一支同理(与 `ROLE_RE` 的非贪婪配对一致——它
* 在 `<code>` 里照样配对,两边各算各的)。任一支没闭合 ⇒ **那一支**不产生区间。
*
* `indexOf('<')` + 逐段推进的扫描区间互不重叠(见 [inlineTagEnd]),每个 `<br>` 的判定是两个游标
* 各走一次,因此整体仍是 O(n):成串的尖括号或成串的作用域都不会让它退回 O(N²)。
*/
private fun protectedSpans(text: String): List<IntRange> {
val codeSpans = ArrayList<IntRange>(2)
val noteSpans = ArrayList<IntRange>(2)
var openCode = -1
var openNoteName: String? = null
var openNote = -1
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) break
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是标签:往后挪**一个字符**(与 `claimTags` 同一把尺子,
// 不能跳到某个「下一个 `>`」——那会把中间真正的标签一起跳过)。
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
val end = gt + 1
if (openCode < 0) {
if (openInlineTagName(tag) == CODE) openCode = lt
} else if (closeInlineTagName(tag) == CODE) {
codeSpans.add(openCode until end)
openCode = -1
}
val noteOpen = if (openNoteName == null) openNoteTagName(tag) else null
if (noteOpen != null) {
openNoteName = noteOpen
openNote = lt
} else if (openNoteName != null && closeNoteTagName(tag) == openNoteName) {
noteSpans.add(openNote until end)
openNoteName = null
}
i = end
}
// 两支各自升序,合起来也按**起点**升序——[appendRegion] 的包含判定(「第一个右端 ≥ 这个 `<br>`
// 的区间」)以「按起点升序」为前提。合并后两支的区间可能相互嵌套(`<code>` 套注记、注记套
// `<code>`),那不影响判定:嵌套的那些都在同一个升序序列里。
return when {
noteSpans.isEmpty() -> codeSpans
codeSpans.isEmpty() -> noteSpans
else -> (codeSpans + noteSpans).sortedBy { it.first }
}
}
/** 角色注记的**开**标签名(只认小写的 `action` / `narration`、不带属性——与 `ROLE_RE` 同口径)。 */
private fun openNoteTagName(tag: String): String? = OPEN_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
/** 角色注记的**闭**标签名(同上;配对要求**同名**,与 `ROLE_RE` 的 `</\s*\1\s*>` 一致)。 */
private fun closeNoteTagName(tag: String): String? = CLOSE_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
/**
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
*
@@ -224,6 +464,9 @@ private fun tokenize(raw: String): List<Tok> {
private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String {
if (text.indexOf('<') < 0) return text
val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList()
// 「这个落单 `<p>` 后面还有没有正文」按位置记表:正文里 N 个落单标签,现扫是 O(N²)(见
// [ContentAfterQuery] 的 KDoc)。常规消息(没有落单 `<p>` 开标签)一次都不建表。
val contentAfter = ContentAfterQuery(text)
val out = StringBuilder(text.length)
var last = 0
var i = 0
@@ -234,7 +477,7 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
if (gt < 0) break
val tag = text.substring(lt, gt + 1)
val drop = when {
OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1)
OPEN_P_TAG_RE.matches(tag) -> markupMode || !contentAfter.hasContentAfter(gt + 1)
CLOSE_P_TAG_RE.matches(tag) -> true
!stripStrayRoleTags -> false
roleRanges.any { lt in it } -> false
@@ -253,27 +496,78 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
return out.toString()
}
/** [from] 之后是否还有正文:跳过空白与后续的 `<p>` 族标签后仍非空即为有。 */
private fun hasContentAfter(text: String, from: Int): Boolean {
var i = from
while (i < text.length) {
if (text[i].isWhitespace()) {
i++
continue
/**
* [stripLooseTags] 判落单 `<p>` 用的「[from] 之后还有没有正文」查询:空白跳过,`<p>` / `</p>`
* 整段跳过,其余字符(别的标签、落单的 `<`)一律算有正文——跳过空白与后续 `<p>` 族标签后仍非空
* 即为有。判据与原实现逐字一致,只多了记忆化。
*
* **为什么要记忆化**:这条判据原本每次查询都一路现扫到串尾,而 [stripLooseTags] 对**每一个**
* 落单 `<p>` 都要问一次,正文里 N 个落单标签就是 O(N²)——20000 个落单 `<p>` 的输入实测这条判据
* 要 23 秒(同一次 `parse` 合计 31 秒),而 `parse` 跑在主线程上(`ui/chat/MessageBubble.kt` 的
* `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。
*
* **为什么可以只算一次**:答案只取决于**位置**——同一个位置再问一次,走的一定是同一条链、到的
* 一定是同一个终点。所以走链时把沿途每个位置的答案落表,之后 O(1) 命中;每个位置至多被算一次,
* N 个落单标签整体就是线性的(同一输入实测这条判据从 23 秒降到毫秒级)。
*
* **为什么不改变语义**:判定用的就是原来那段扫描本身([next] 逐分支对应原循环),没有另立一套
* 「什么算 `<p>` 标签」的判据——仍是同一组 [OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE]。落表的答案是同一条
* 扫描推出来的,因此与逐次现扫逐字相同;连「起点落在标签内部」这种查询也一致(原扫描看到的是
* 标签里的字符、判有正文,[next] 在这里同样返回 null)。
*
* 惰性:没遇到落单 `<p>` 开标签的常规消息一次也不建表,一个字节都不多花。
*/
private class ContentAfterQuery(private val text: String) {
/** 每个位置的答案:`0` = 还没算、[NOISE_ONLY] = 后面只剩空白与 `<p>` 族标签、[HAS_CONTENT] = 有正文。 */
private val answers: ByteArray by lazy(LazyThreadSafetyMode.NONE) {
ByteArray(text.length + 1).also { it[text.length] = NOISE_ONLY }
}
/** 本次走链途经的位置,用来把 [answers] 一次落表。 */
private val path: IntArray by lazy(LazyThreadSafetyMode.NONE) { IntArray(text.length) }
/** 与原先「每次现扫到串尾」的判定逐字等价。 */
fun hasContentAfter(from: Int): Boolean {
var walked = 0
var i = from
while (true) {
val known = answers[i]
if (known != UNKNOWN) return finish(walked, known)
path[walked++] = i
val step = next(i)
if (step == null) return finish(walked, HAS_CONTENT)
i = step
}
if (text[i] == '<') {
}
/** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */
private fun finish(walked: Int, answer: Byte): Boolean {
for (k in 0 until walked) answers[path[k]] = answer
return answer == HAS_CONTENT
}
/**
* 原扫描的一步:空白 → 下一位;`<p>` 族标签 → 标签之后;其余(别的标签、落单的 `<`)→ `null`,
* 即「有正文」,扫描到此为止。走到 `text.length` 时由 [answers] 里预置的答案收口。
*/
private fun next(i: Int): Int? {
val c = text[i]
if (c.isWhitespace()) return i + 1
if (c == '<') {
val gt = text.indexOf('>', i)
if (gt >= 0) {
val tag = text.substring(i, gt + 1)
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) {
i = gt + 1
continue
}
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) return gt + 1
}
}
return true
return null
}
private companion object {
const val UNKNOWN: Byte = 0
const val NOISE_ONLY: Byte = 1
const val HAS_CONTENT: Byte = 2
}
return false
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
@@ -416,9 +710,21 @@ private fun toBubble(tokens: List<Tok>): ParsedBubble? {
tail.add(part)
}
}
val bodyText = body.toString().trim()
// 行内标签在**段落与 `<br>` 都定稿之后**才消费(就在这一行,且是**逐气泡各自一份**):上面那套
// 分段与归属一个字都不动,分段判据因此完全看不见行内标签(`<br>` 已经在 [appendRegion] 那层
// 变成气泡边界了)。runs 为空 = 这段正文按字面画(没有可渲染的行内标签、或行内标签结构不平衡
// 被整段降级),一个字都不会变。
val rawBody = body.toString().trim()
val runs = parseInlineMarkup(rawBody)
val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text }
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
return ParsedBubble(
body = bodyText,
parts = parts.toList(),
head = head.toList(),
tail = tail.toList(),
runs = runs,
)
}
// ---------------------------------------------------------------------------
@@ -463,28 +769,62 @@ private fun stablePrefixEnd(buf: String): Int {
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
if (OPEN_P_TAG_RE.matches(tag)) return "p"
// 行内开标签也入栈(`<B class=x>` 这类形态由 [parseInlineTag] 判,与解析侧同源)。
// `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在它前面,后面的话再也
// 显示不出来)——[openInlineTagName] 对 `<br>` / `<br/>` / `</br>` 一律返回 null。
return openInlineTagName(tag)
}
private fun closeTagName(tag: String): String? {
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
if (CLOSE_P_TAG_RE.matches(tag)) return "p"
return closeInlineTagName(tag)
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
/**
* 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。
*
* 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
* - 段落/旁白只认小写(`<P>` / `<Action>` 原样显示,既有契约)——名字里出现大写就与它无关;
* - 行内标签与 `<br>` 分隔符**大小写不敏感**(契约 v0.89,[parseInlineTag] / [BR_TAG_RE])
* ——`<B` 与 `<b`、`<BR` 与 `<br` 一样要扣留。
* 少扣了就是「中间态把标签当正文吐上屏、收口时才吃掉」,屏幕上显示过的字会改口。
* 名字之后是空白或 `/` 都算「还可能补全」(属性 / 自闭合,v0.89 允许带属性,所以 `<b class=x>`
* 与 `<br/` 这两个中间态也必须扣住);其它字符说明它压根不是标签(`<boolean>`、`<b2`)。
*/
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
var j = lt + 1
if (j < buf.length && buf[j] == '/') j++
while (j < buf.length && buf[j].isWhitespace()) j++
val nameStart = j
while (j < buf.length && buf[j] in 'a'..'z') j++
while (j < buf.length && isTagNameLetter(buf[j])) j++
val name = buf.substring(nameStart, j)
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return false
if (!buf[j].isWhitespace()) return false
return TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return j >= buf.length
if (!couldBeTagName(name)) return false
if (j >= buf.length) return true
return buf[j].isWhitespace() || buf[j] == '/'
}
/**
* [name] 是否可能是某个已知标签名的前缀;三半各按自己的大小写口径(见 [isPotentialTagPrefix]):
* 段落/旁白小写、`<br>` 与行内标签大小写不敏感。
*/
private fun couldBeTagName(name: String): Boolean {
if (name.all { it in 'a'..'z' } && STRUCTURE_TAG_NAMES.any { it.startsWith(name) }) return true
val lower = name.lowercase()
if (couldBeLineBreakNamePrefix(lower)) return true
return couldBeInlineTagNamePrefix(lower)
}
/**
* [name](已小写)是否可能是 `<br>` 分隔符的标签名前缀——`<br` / `</br` / `<BR` 这种残缺形态要不要
* 扣留由它说了算。判据与 [BR_TAG_RE] 同口径(大小写不敏感的 `br`):两边一分叉,
* `甲<br` 会先把字面 `<br` 吐上屏,等 `>` 到了再变成两条气泡。
*/
private fun couldBeLineBreakNamePrefix(name: String): Boolean =
name.isNotEmpty() && LINE_BREAK_TAG_NAME.startsWith(name)
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
var count = 0
@@ -0,0 +1,461 @@
package top.yeij.cyrene.domain.parse
/*
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
*
* 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 `<b>`
* 在她那台设备上粗、在这台上不粗)。四条硬口径:
* 1. 白名单 = `b` / `strong` · `i` / `em` · `code` · `u`(**成对**,六项标签名);标签名**大小写不敏感**;
* 2. **属性一律不解析**:`<b class=x onclick=y>` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
* 3. **白名单外一律按纯文本原样显示**:`<script>` 显示成字面量,字符一个不丢;
* 4. **结构不平衡 ⇒ 整段降级为原文**(未闭合 / 落单闭标签 / 交错)——粗粒度是有意的,换来
* 「字符一个不丢」可以被逐字断言。
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`code` → 等宽 + 轻微底色(且**里面的标签不解析**),
* `u` → 下划线。`s` **不在白名单里**:它按第 3 条逐字原样显示(见下)。
*
* ## `br` 不在这里(契约同日更正)
* `回执三十一`:「对消息内的 `<br>` **始终处理为多条消息隔断**」⇒ `<br>` **不是行内标签**,而是与
* `<p>` 同级的**气泡/消息分隔符**,切分在 [tokenize](ChatContentParser.kt) 的段/块那一层
* (`ChatContentParser.appendRegion`)完成,`<br>` / `<br/>` / `</br>` 三种写法等价。
* 这一层因此**只认成对标签**:`<br>` 到了这里只是几个普通字符,与 `<br2>`、`<boolean>` 同类。
* 它**真的会**走到这里,只是只有一条路:`<code>` 作用域内部——分隔符切分层有一个**保护区**
* (`ChatContentParser.protectedSpans`,`回执三十三 §三②`),落在 `<code>` / `<action>` / `<narration>`
* 内部的 `<br>` 不分隔,于是 `<code>` 里的那个 `<br>` 会随正文一起交到这一层,在代码作用域里按
* 「内容」处理(字面显示,与 `<code><b>甲</b></code>` 里的 `<b>` 同一规矩)。
*
* ## 白名单六项(`u` 加回、`s` 不做)
* `回执三十` 先把白名单收窄到五项、撤掉 `u`/`s`(理由是「多支持 = 制造跨端不一致」),但
* `回执三十三 §一` 更正:**那份「收窄到五项」是后端的疏漏**——主人随后裁定下划线保留、后端把 `u`
* 加回了契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。最终白名单 = **六项**:
* `b` / `strong` / `i` / `em` / `code` / **`u`**,`u` 在这里渲染成下划线。
* **`s` 仍不在内**(逐字原样)——「多支持反而制造跨端不一致」这条理由对它依然成立:Web 不渲染它。
*
* ## 为什么是「整段降级」而不是「只降级那一处」
* W67 那版是**局部**降级:`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗,只有后面那半个 `<b>` 留着字面。
* 契约把「结构不平衡」定成**整段不渲染**:一处不平衡,这一整段就一个标签都不认、原文逐字显示。
* 代价是「好好的一对 `<b>` 会被邻居的落单标签连累」,换来的是**可以逐字断言**的粗粒度:
* 「渲染文本 == 原文」或者「渲染文本 == 原文消费掉全部配对标签」,只有这两种形状,没有中间态
* (局部降级那种「一半认、一半留」在跨端对齐时最容易各说各话)。见 [parseInlineMarkup]。
*
* ## 「整段」是哪一段(我们采用的口径 + 理由)
* **整段 = 该气泡的正文**,也就是 [toBubble](ChatContentParser.kt) 里 `body.toString().trim()`
* 出来的那一段(`ParsedBubble.body` 对应的文本,通常正好是一个 `<p>…</p>` 块的正文,也可能是
* 块外文本并入相邻气泡后的那一份)。不是整条消息,也不是单个 `<p>` 块:
* - **不是整条消息**:一条消息可以有多个分隔(`<p>` / `<br>`)= 多条气泡,一个气泡里写坏了一个
* 标签,不该把**别的**气泡一起按字面画出来——那既不是契约想说的「这一段」,也会让「她写坏了
* 一个标签」这种局部事故看起来像整条消息都坏了。
* - **不是单个 `<p>` 块**(虽然大多数时候两者重合):本端渲染的**最小单位就是气泡**,
* `body` / `runs` 也都是按气泡算的。降级粒度与渲染粒度取同一个,实现上才有一条清晰的边界
* (`toBubble` 一次调用 = 一次判定),断言也才写得出「这条气泡是原文、那条气泡照常渲染」。
* - 后端已认可这一点(`回执三十`:「契约只承诺『不平衡 ⇒ 不渲染、字符一个不丢』,**具体粒度算
* 实现细节**」)。
* - 因为切分(`<p>` / `<br>`)**先于**行内解析,跨分隔符的标签天然分属两段:
* `<b>甲<br>乙</b>` ⇒ 两段各不平衡 ⇒ 两段都回落原文。
*
* ## 为什么不改 `parts`,只在 `ParsedBubble` 上加一个字段
* `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、
* 搜索片段(`SessionSearchModel.plainSearchText`)、通知文案(`ProactiveNotify`)读的都是 `parts`,
* 在那里把标签改掉就是**静默改用户内容**。所以行内解析不改片段,只在 `toBubble` 里对**已经定稿的
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(白名单标签已消费),
* 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。
*
* ## 与段落解析的分工(为什么不在 `tokenize` 里做)
* 行内解析发生在**段落/分隔符边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 与
* `<br>` 分气泡、空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。
* 这也让 W63 在 `tokenize` 里去掉的两处 O(N²) 一行都不受影响:这里是对最终正文的一趟线性扫描。
*
* ## 大小写不敏感(v0.89)与「扣留侧必须一起改」
* `<B>` / `<STRONG>` 都认——但**消费侧与流式扣留侧([ChatContentParser.stablePrefixEnd])
* 必须同源**,否则两边分叉:`<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>`
* 到了再吃掉,**屏幕上已经显示过的字就改口了**。所以标签名的字母判据([isTagNameLetter])与
* 「这个名字还可能补全吗」([couldBeInlineTagNamePrefix],以及 `ChatContentParser.isPotentialTagPrefix`
* 里的 `couldBeTagName`)都收在**这一份文件**里,两边引用同一份名单。
* 段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍然**只认小写**:这是既有契约
* (`plainAngleBracketsAreNeverEaten` 钉着 `<P>` 原样、`MessageEditText` 同一口径),
* 且契约明确「`<action>`/`<narration>` 的既有行为逐字未改」。
*
* ## 明确不做(逐条理由)
* 1. **块级(`<p>` 之外的 `<div>` / `<h1>` / 列表…)不做**:契约写死「段落仍走 `<p>`」。
* 2. **`<a href>` 不做**:它不是一个样式,是一个**点击行为**(要唤浏览器),必须配一套安全策略
* (只认 http/https、不许 `intent://` / `javascript:` 之类)。契约把它划成独立安全面,实测 0 命中。
* 3. **Markdown 的 `*` / `_` 一律不做**:星号这个字形在本项目**已经被占走了**——`ROLE_RE` 把全角
* `*…*` 与半角 `*(动作|旁白|内心|神态|轻声)…*` 判成旁白/动作(`*笑*` 是动作,不是斜体),
* 既有测试钉着这条语义。再给同两个星号一个「斜体」的说法,同一条消息就有两种读法,还要先猜
* 哪一种是她的本意——那是改既有语义,不是加渲染。
* 4. **`#` 标题 / `- ` 列表 / 表格 / ``` 围栏 / `[链接](url)` / `~~删除~~` 一律不做**:真后端最近
* 100 条实测 0 命中;而且这些是**行级**规则,会把「正文里真有一行以 `- ` 开头」这种普通文本
* 改写成别的东西——0 命中 + 有改写风险 = 不做。
* 5. **HTML 实体(`&lt;` `&amp;` `&nbsp;` …)不做**:实测 0 命中;实体是一张**开放的清单**
* (`&#39;` / `&#x27;` / 各种命名实体……),做一半比不做更糟——会出现「`&amp;` 解了、`&#x27;`
* 却原样显示」这种谁也说不清的口径。想在正文里显示尖括号,今天有 `<code>` 这一档。
* 6. **`<script>` / `<style>` 之类只会「原样显示」,绝不会被当 HTML 注入**:渲染侧是
* `AnnotatedString` + `SpanStyle`(结构化),正文从来没有、也不会走任何 HTML 解析/注入路径。
*
* 契约与边界口径见 [parseInlineMarkup]。
*/
/**
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
*
* 只有这四种:换行不是样式——`<br>` 是气泡分隔符(切在 `ChatContentParser.tokenize` 那一层),
* 它只会在 `<code>` 作用域内部作为**内容**走到这里(按文件头那条口径字面显示),永远不成样式。
*/
enum class InlineStyle {
Bold,
Italic,
Code,
/** `<u>`:下划线(渲染成 `TextDecoration.Underline`)。`s` 不在白名单里,所以没有对应项。 */
Underline,
}
/**
* 正文里**样式一致的一段连续文本**:片段按顺序拼起来就是渲染文本,样式区间由「起始下标 + 长度」
* 就地累加得到(第 n 段的区间 = 前面各段长度之和 until 加上自己)。
*
* 相邻且样式相同的片段由 [parseInlineMarkup] 合并,所以同一段文本只有一种片段形状——
* 断言起来就是在断言「哪段字是什么样式」。
*/
data class InlineRun(val text: String, val styles: Set<InlineStyle>)
/**
* `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。
* `internal` 是因为 `ChatContentParser.protectedSpans` 也要判它(保护区扫描),
* 名字只写这一处、两边不许各写一遍。
*/
internal const val CODE = "code"
/**
* 成对标签的名字(契约 v0.89 最终版的**六项**,见文件头):`strong` / `em` 是 `b` / `i` 的同义写法,
* `u` 是 `回执三十三 §一` 加回来的下划线。
* 只写这一处,[parseInlineTag] 与 [couldBeInlineTagNamePrefix] 都由它推出来。
* `br` 不在这里(它是分隔符,见文件头);`s` 也不在(逐字原样)。
*/
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE, "u")
/**
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
* 同一份名单既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
* 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。
*/
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES
/**
* 白名单行内标签认下来的两种形态。**认不出来的(白名单外、形状不对)一律返回 null**,
* 由调用方当普通文本原样显示(契约第 3 条:字符一个不丢)。
*/
internal sealed interface InlineTag {
val name: String
/** 成对的开标签(`<b>`、`<STRONG>`、`<b class=x onclick=y>`…):要入栈等它的闭合。 */
data class Open(override val name: String) : InlineTag
/** 成对的闭标签(`</b>`、`</EM>`…)。 */
data class Close(override val name: String) : InlineTag
}
/** 标签名里允许出现的字母:ASCII 大小写,消费侧与扣留侧共用这一把尺子。 */
internal fun isTagNameLetter(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
/**
* 把一段 `<…>` 文本判成白名单行内标签(不是则 null)。**一处判据,两个消费者**:解析侧用它认标签,
* 流式扣留侧用它算「这个开标签要不要入栈扣留」——同源才不会分叉。
*
* 认下来的形状(`<` 与名字之间可以有空白,与既有 `</\s*(action|narration)\s*>` 同一口径):
* - 名字 = ASCII 字母串,**大小写不敏感**,落成小写后必须在 [INLINE_TAG_NAMES] 里;
* - 名字之后紧跟的必须是 `>`、空白或 `/`——`<boolean>` / `<br2>` / `<b-x>` 因此都不是标签
* (否则 `b` 会把一大票以 b 开头的普通单词吃成粗体),这是「字符一个不丢」的第一道闸门;
* - 再往后到 `>` 之间的一切(属性、`/`、空白)**整段丢掉**:契约第 2 条,只取标签名。
* 丢掉的只是白名单标签自己那对尖括号里的东西,正文一个字符都不动。
*
* `<br>` 在这里返回 null(它不再是行内标签,是气泡分隔符)——所以它落到「白名单外」那条路:
* 它只是几个普通字符。真实链路上**只有一条路**能把它送到这一层:`<code>` 作用域内部
* (分隔符切分层的保护区不分隔它,见 [appendRegion] 与 `ChatContentParser.protectedSpans`)。
*/
internal fun parseInlineTag(tag: String): InlineTag? {
val end = tag.length - 1
if (end < 2 || tag[0] != '<' || tag[end] != '>') return null
var i = 1
val closing = tag[i] == '/'
if (closing) i++
while (i < end && tag[i].isWhitespace()) i++
val nameStart = i
while (i < end && isTagNameLetter(tag[i])) i++
if (i == nameStart) return null
val name = tag.substring(nameStart, i).lowercase()
if (i < end && !tag[i].isWhitespace() && tag[i] != '/') return null
if (name !in PAIRED_TAG_NAMES) return null
return if (closing) InlineTag.Close(name) else InlineTag.Open(name)
}
/** 开标签名(不是行内开标签则 null)。 */
internal fun openInlineTagName(tag: String): String? =
(parseInlineTag(tag) as? InlineTag.Open)?.name
/** 闭标签名(不是行内闭标签则 null)。 */
internal fun closeInlineTagName(tag: String): String? =
(parseInlineTag(tag) as? InlineTag.Close)?.name
/**
* [name](已小写)是否可能是某个行内标签名的**前缀**——流式扣留侧判「残缺的 `<b` 还可能补全吗」
* 用它。大小写不敏感与 [parseInlineTag] 一致,名单也是同一份。
*/
internal fun couldBeInlineTagNamePrefix(name: String): Boolean =
INLINE_TAG_NAMES.any { it.startsWith(name) }
/** 标签名 → 样式;`strong` / `em` 落到与 `b` / `i` 同一个样式上。 */
internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
"i", "em" -> InlineStyle.Italic
CODE -> InlineStyle.Code
"u" -> InlineStyle.Underline
else -> InlineStyle.Bold
}
/**
* 把一段**已经定稿的正文文本**解析成行内片段(纯函数)。
*
* 契约(都由单测钉着):
* 1. **返回空列表 = 这段文本按字面渲染**(正文一个字符都不动)。三种情形都落在这里:
* 没有可渲染的行内标签、**结构不平衡**(契约第 4 条,整段降级)、以及整段只有标记没有字。
* 这是绝大多数消息的路径(实测 100 条里只有 2 条带 `<b>`),也是 `plainBubble` 那条路。
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:白名单标签已被消费,且两端空白已去(见下)。
* **除此之外逐字等于原文**——白名单外的标签(`<script>`)、属性、`<code>` 里的内容全都原样在
* 片段里。
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>`)时回落成第 1 条,
* 把标记原样显示——宁可让她的话里多几个尖括号,也不能给出一条空白气泡。
*
* ## 口径一:配成对且正确嵌套,否则**整段**降级为原文(契约第 4 条)
* 判定只看白名单标签的结构(白名单外的尖括号一律是普通字符,不参与判定):
* - **未闭合的开标签**(`<b>没有闭合`、`日落 <b>18:35`)、**落单的闭标签**(`没有闭合</b>`、
* 只有 `</b>`)、**交错**(`<b>甲<i>乙</b>丙</i>`:`</b>` 关不掉栈顶的 `i`)——三者任一出现,
* 这一整段**一个标签都不认**、原文逐字显示。
* - 代价与收益都写清:`甲<b>粗</b>乙<b>没闭合` 里的 `粗` 也不再加粗(粗粒度),
* 换来的是渲染文本与原文之间只有两种关系(相等 / 消费掉全部配对标签),跨端可以逐字对齐。
* - 判据只有这一条,不发明「宽容关闭」(`</b>` 顺手把里面没关的 `<i>` 也关掉)、也不按行/按句
* 局部生效:那种写法每端都能自圆其说,合起来就是跨端不一致。
* - **跨分隔符的标签各自算**:`<b>甲<br>乙</b>` 里的 `<br>` 在更早一层就把气泡切开了,
* 到这里是两段文本(`<b>甲` 与 `乙</b>`),各自不平衡 ⇒ 各自回落原文。层序与理由见
* `ChatContentParser.appendRegion` 的 KDoc。
*
* ## 口径二:`<code>` 里不再解析(与契约第 3 条一致,不是例外)
* `<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`(等宽 + 底色),`<code>1 < 2 && a<b</code>`
* 原样显示。它与契约第 3 条(白名单外按纯文本显示)**不冲突、方向一致**,但**不是同一条规则**:
* - 第 3 条管的是**白名单外的标签名**(`<script>`、`<div>`)——它们从来不是「标签」;
* - `<code>` 是一条**作用域**规则:在白名单标签 `<code>` 内部,连白名单标签也按内容处理。
* `code` 表达的是「这段是代码」,代码里的 `<b>` 就是四个字符——这是 W67 就定下的口径,
* 契约没有就「`<code>` 内部」单独措辞,我们**保留原行为**(换 HTML 那种「元素照常嵌套解析」的
* 语义,改动只是删掉 [claimTags] 里的跳过分支,等后端确权再动);
* - 两者的结果都落在同一条硬口径上:**字符一个不丢**。`<code>` 也正好是「想原样显示尖括号
* 怎么办」的现成答案。
* 内部的东西**不参与结构判定**:`<code><b>甲</code>` 里的 `<b>` 不算未闭合(它只是内容),
* 所以不会因为它把整段拖成字面——判定与显示用的都是同一套「里面不算标签」的口径。
* 代价是代码里的 `<b>` 不生效(实测 0 命中,也不存在「代码里也要粗体」这个需求)。
*
* ## 为什么两端空白要去掉
* 被标签让出来的空白(`<b> 甲 </b>` 里那两个字)留着的唯一效果是「气泡里看起来多了两个空格」,
* 而正文两端的空白本来就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见
* 字符都不会掉;它同时兜住契约第 3 条:整段只有空白(`<b> </b>`)时回落字面,绝不给出一条
* 看起来空白的怪气泡。
*
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇「到 `>` 或下一个 `<` 为止」的窗,绝不
* `indexOf('>')` 一路找配对);正文里没有 `<` 时连集合都不建,一次就返回。
*/
fun parseInlineMarkup(text: String): List<InlineRun> {
if (text.indexOf('<') < 0) return emptyList()
// 结构不平衡 ⇒ 整段降级为原文(消费侧返回空列表 = 调用方按字面渲染)。
val claimed = claimTags(text) ?: return emptyList()
if (!claimed.any) return emptyList()
// 整段被吃空(`<b></b>` 这类只有标记没有字的形状)⇒ 回落字面路径,见契约第 3 条。
return runsOf(text, claimed).trimEdges().takeIf { it.isNotEmpty() } ?: emptyList()
}
/** 一趟扫下来的「哪些标签被认领」:开/闭标签的起始下标。 */
private class ClaimedTags {
val openStarts = HashSet<Int>()
val closeStarts = HashSet<Int>()
val any: Boolean
get() = openStarts.isNotEmpty() || closeStarts.isNotEmpty()
}
/**
* 第一趟:扫出配成对且正确嵌套的那些标签(起始下标落在 [ClaimedTags] 的两个集合里)。
*
* 返回 **null = 结构不平衡**(未闭合 / 落单闭标签 / 交错),调用方据此整段降级为原文。
* 因为不平衡时整段都不认,所以有返回值时**每一个**白名单成对标签都是配好的(闭标签只关得掉栈顶
* 那个同名开标签,严格嵌套)。
*
* 白名单外的尖括号**完全不参与判定**:它们是普通字符(契约第 1、3 条),既不入栈、也不会因为
* 「没闭合」把整段拖成字面——`甲<b>粗</b><script>` 里粗体照常生效,`<script` 原样显示。
*
* `<code>` 开着的期间**整段跳过**:里面的标签一个都不认、也不算结构(见 [parseInlineMarkup] 的
* 口径二)。因为跳过的是「认不认」,所以 `<code>` 自己那一对仍照常被认领。
*/
private fun claimTags(text: String): ClaimedTags? {
val claimed = ClaimedTags()
val open = ArrayList<Pair<String, Int>>()
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) break
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是行内标签:往后挪**一个字符**再找。不能跳到某个「下一个 `>`」——
// 那会连同中间真正的标签一起跳过(`1 < 2 <b>甲</b>` 里的 `<b>` 就是这么丢的)。
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
val parsed = parseInlineTag(tag)
if (open.lastOrNull()?.first == CODE) {
// `<code>` 里的一切都是它的内容:`<b>` 是四个字符,也都不算结构(所以里面那个「未闭合的
// `<b>`」不会把整段拖成字面)。只有闭合它的 `</code>` 例外。
if (parsed == InlineTag.Close(CODE)) {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
} else {
when (parsed) {
is InlineTag.Open -> open.add(parsed.name to lt)
is InlineTag.Close ->
// 落单的闭标签(栈空)、或交错(`</b>` 关不掉栈顶的 `i`)⇒ 整段降级。
if (open.lastOrNull()?.first != parsed.name) return null
else {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
null -> Unit
}
}
i = gt + 1
}
// 还有开标签没配到闭合 ⇒ 整段降级。
if (open.isNotEmpty()) return null
return claimed
}
/**
* [lt] 处这个 `<` 起头的**行内**标签在哪结束(不是行内标签则 -1)。
*
* 一路走到 `>` 或**下一个 `<`** 为止:契约要求「属性一律不解析」,标签多长因此不再有上界
* (`<b class="…" onclick="…">` 可以很长),W67 那版「只看一扇定宽窗」的做法行不通了。
* 仍然刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
* (W63 在同一片区域刚踩过这个坑),而且它还会把中间真正的标签一起跳过。
* 逐段推进仍是线性的:每次从 `lt + 1` 扫到「下一个 `<`」或串尾,这些区间互不重叠。
*
* `internal` 是因为**标签边界只该有一把尺子**:分隔符切分层扫 `<br>` 保护区
* (`ChatContentParser.protectedSpans`)时也用它,两边若各写一套,「哪儿是一个标签」就会分叉。
*/
internal fun inlineTagEnd(text: String, lt: Int): Int {
var i = lt + 1
while (i < text.length) {
when (text[i]) {
'>' -> return i
'<' -> return -1
}
i++
}
return -1
}
/**
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字),其余**逐字**进片段
* (白名单外的标签、`<code>` 里的内容、没有被认领的整段标记,都在这里原样吐回)。
* 因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。
*/
private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
val runs = ArrayList<InlineRun>()
val piece = StringBuilder()
val open = ArrayList<String>()
var styles = emptySet<InlineStyle>()
fun flush() {
if (piece.isEmpty()) return
val text0 = piece.toString()
piece.setLength(0)
val last = runs.lastOrNull()
if (last != null && last.styles == styles) {
runs[runs.lastIndex] = last.copy(text = last.text + text0)
} else {
runs.add(InlineRun(text0, styles))
}
}
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) {
piece.append(text, i, text.length)
break
}
if (lt > i) piece.append(text, i, lt)
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是行内标签(`1 < 2` 里那个 `<`、残缺的 `<b`、属性里带 `<` 的…):
// 把这个字符原样收下、从下一个字符继续。**必须与第一趟用同一把尺子**——这里若改用
// `indexOf('>', lt)` 找标签边界,第一趟认下的那对标签就会在这里被当成一个字面整段吞掉
// (`1 < 2 <b>甲</b>`)。
piece.append('<')
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
when {
claimed.openStarts.contains(lt) -> {
flush()
open.add(openInlineTagName(tag) ?: closeInlineTagName(tag).orEmpty())
styles = stylesOf(open)
}
claimed.closeStarts.contains(lt) -> {
flush()
open.removeAt(open.lastIndex)
styles = stylesOf(open)
}
else -> piece.append(tag)
}
i = gt + 1
}
flush()
return runs
}
private fun stylesOf(open: List<String>): Set<InlineStyle> =
if (open.isEmpty()) emptySet() else open.mapTo(LinkedHashSet(), ::inlineStyleOf)
/** 去掉渲染文本两端的空白,并同步裁掉因此变空的片段——只吃空白,一个可见字符都不会掉。 */
private fun List<InlineRun>.trimEdges(): List<InlineRun> {
var head = 0
for (run in this) {
val leading = run.text.takeWhile { it.isWhitespace() }.length
head += leading
if (leading < run.text.length) break
}
var tail = 0
for (i in indices.reversed()) {
val trailing = this[i].text.takeLastWhile { it.isWhitespace() }.length
tail += trailing
if (trailing < this[i].text.length) break
}
val total = sumOf { it.text.length }
val from = minOf(head, total)
val to = total - minOf(tail, total - from)
return cut(from, to)
}
/** 按字符下标从两端裁掉(只用于裁空白,所以裁点必然落在片段边界或空白中间)。 */
private fun List<InlineRun>.cut(from: Int, to: Int): List<InlineRun> {
if (from == 0 && to == sumOf { it.text.length }) return this
val out = ArrayList<InlineRun>(size)
var pos = 0
for (run in this) {
val start = pos
pos += run.text.length
val head = maxOf(start, from)
val end = minOf(pos, to)
if (head < end) out.add(run.copy(text = run.text.substring(head - start, end - start)))
}
return out
}
@@ -12,9 +12,27 @@ package top.yeij.cyrene.domain.share
*
* 还有第三件要说的:[contentUnreadable]——对方给了东西、本端**连地址都没认下来**。它连丢件计数
* 都留不下(本端一条都没接收),所以单独一格;否则「分享点下去什么都不发生」正是它造成的。
*
* 正文那一格也多带一件要看的东西:[textSource]——这一段是从 `EXTRA_TEXT` 来的,还是从
* `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的。有些发送方根本不填 `EXTRA_TEXT`,本端为此把取正文
* 改成一条回落链([ShareRules.extract]);而回落来的正文与真正文长得一模一样,用户只能靠这一格分辨。
*/
data class SharedContent(
/**
* 这次分享的正文(两侧空白已去掉;全空白算没有正文,见 [ShareRules.extract])。
*
* 它可能是从 `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的(有些发送方根本不填 `EXTRA_TEXT`),
* 来源记在 [textSource] 里:回落来的那一段与真正文长得一模一样,用户只能靠那一格分辨。
*/
val text: String = "",
/**
* 正文是从哪个 extra 拿到的(默认 [ShareTextSource.Text] = 正文本来就是正文)。
*
* ⚠️ 它**不参与发送的内容**:这条消息发出去的正文只有 [text] 本身。它的唯一去处是**本端渲染层**
* 的那一句如实小字(见 `shareTextOriginNotice`)——回落来的正文必须能让用户察觉,否则「预览里只有
* 一句话」会被读成「对方发来的就是这句正文」。
*/
val textSource: ShareTextSource = ShareTextSource.Text,
val images: List<String> = emptyList(),
/**
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
@@ -31,13 +49,14 @@ data class SharedContent(
/** 已接收但**拷贝失败**的文件条数。 */
val failedDocuments: Int = 0,
/**
* 这次分享**带着内容迹象**(`EXTRA_STREAM` 有值、或 `ClipData` 有条目),却一条可用内容都没
* 提取出来。
* 这次分享**带着内容迹象**(三个文本 extra 里躺着值、或 `EXTRA_STREAM` 有值、或 `ClipData`
* 有条目),却一条可用内容都没提取出来。
*
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连地址都没认下来」(多为发送方把
* `EXTRA_STREAM` 塞成了本端认不出的形态,或用的是本端不读的 scheme)。它是**唯一**一种既没有
* 内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是「用户点了分享、界面毫无反应」。
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连可用的内容都没认下来」(多为发送方把
* `EXTRA_STREAM` 塞成了本端认不出的形态、用本端不读的 scheme,或把正文放在本端认不出的地方)。
* 它是**唯一**一种既没有内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是
* 「用户点了分享、界面毫无反应」。
*/
val contentUnreadable: Boolean = false,
) {
@@ -55,13 +74,43 @@ data class SharedContent(
*
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
* 整条分享里什么都没有(`EXTRA_TEXT` 没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
* 时它是 `false`:那次分享确实无话可说,静默才是对的。
* 整条分享里什么都没有(三个文本 extra 都没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
* 时它是 `false`:那次分享确实无话可说,静默才是对的。而「有值、只是本端一段都读不出来」(含
* 空白与类型不符)**不**属于「什么都没有」:它由调用方以 `textPresent` 如实报进来,正是
* [contentUnreadable] 那一格要接的。
*/
val worthDelivering: Boolean
get() = !isEmpty || droppedImages > 0 || droppedDocuments > 0 || contentUnreadable
}
/**
* 这次分享的正文**是从哪个 extra 拿到的**。
*
* ## 为什么要把来源留下来
* 真实世界里有些发送方不填 `EXTRA_TEXT`:内容在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。本端
* 此前只读 `EXTRA_TEXT`,那条分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句解释
* (正是本项目最忌讳的那种失败)。所以取正文改成一条**回落链**([ShareRules.extract])。
*
* 但「这段字其实只是对方给的标题」必须能让用户分辨:预览里只有一句话时,他会以为对方发来的就是这句
* 正文。于是一并记下**来源**,由渲染层如实标一句(`shareTextOriginNotice`)——那句话只出现在界面上,
* **绝不**进正文(来源标记不许拼进用户正文是项目红线,见 [ShareRules] 的「为什么正文一个字都不加」)。
*
* ## 为什么分成三档而不是一个布尔
* 三档是**如实登记**对方把内容放在哪个字段里:排查时「这条是从 SUBJECT 还是 TITLE 来的」是要看得见
* 的事实,而这两种形态在真实发送方里的出现频率差得很远(SUBJECT 常见、TITLE 罕见)。但两者对用户
* 说的是同一句话——标题与主题在用户眼里本来就是同一件事。
*/
enum class ShareTextSource {
/** `EXTRA_TEXT`:规范路径,也是唯一一条「正文本来就是正文」的路。 */
Text,
/** 从 `EXTRA_SUBJECT` 回落的(对方没给 `EXTRA_TEXT`,或给的是一段空白)。 */
Subject,
/** 从 `EXTRA_TITLE` 回落的。 */
Title,
}
/**
* 一份分享进来的**非图片**文件。
*
@@ -120,10 +169,19 @@ fun classifySharedStream(mime: String?): SharedStreamKind {
* ⚠️ **字符串形态只认 `content://`**(判据见 [stringStreamUri]):那是本端**新开**的一条读入口,
* 松不得。被拒的条目**逐条跳过**——一次分享里有几条坏的,不该把整次分享废掉。
*
* ## 正文的回落链(本模块补上的另一条兼容读取)
* 真实世界里有发送方**不填** `EXTRA_TEXT`:内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。
* 本端此前只读 `EXTRA_TEXT`,那样一次分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句
* 解释。所以取正文改成 `EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE` 的**回落链**([extract]),
* 来源记在 [SharedContent.textSource] 里,由渲染层如实标一句(见 [ShareTextSource])。
*
* ## 为什么正文一个字都不加
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
* 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端
* 渲染层。把标记拼进正文 = 让用户正文冒充系统指令,是项目红线。分享进来的正文**原样**发出。
*
* 回落来的正文(从 `EXTRA_SUBJECT` / `EXTRA_TITLE` 取到的那一段)同样**原样**发出:来源提示是界面
* 上的一句小字,**不是**正文的一部分——所以面板里那段预览与真正发出去的那条消息逐字相同。
*/
object ShareRules {
@@ -170,9 +228,49 @@ object ShareRules {
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
/**
* 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。
*
* ## 两个事实合起来只剩两种答案
* - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张
* 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断
* (后端口径 `02` F4:他们那边不需要做任何事)。
* - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断
* 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个**
* 这样的意思。
*
* 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。
*
* ## 为什么提成一个纯函数
* 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传
* `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直
* 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是
* 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与
* [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。
*
* @param fromShare 这条(这一批)是不是分享进来的
* @param index 在这次发送切出的消息里的位置(0 = 第一条)
*/
fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0
/**
* 从分享 Intent 的原始字段提取内容。
*
* ## 正文的回落链:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`
* 取**第一个非空白**的那一段当正文(口径与原先只读 `text` 时**完全一致**:两侧去空白、全空白算
* 没有);来源记在 [SharedContent.textSource] 里,好让渲染层如实说一句(见 [ShareTextSource])。
*
* 为什么要有这条链:真实世界里有些发送方**不填** `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`
* (少数在 `EXTRA_TITLE`)里。此前只读 `EXTRA_TEXT` ⇒ 那样一次分享**整条静默**——用户点了分享,
* 界面毫无反应,后端零请求,也没有一句解释。前后两种口径的对外差别只有一个:**`EXTRA_TEXT`
* 非空时回落链一定不生效**(它是第一站),所以既有情形的结果一个字都没变。
*
* ## 三个文本 extra 都没换来正文时**不许闭嘴**
* 只要三个里**任意一个躺着原始值**(哪怕是一段空白、哪怕类型读不出来),这一次就不是「用户什么都
* 没分享」,而是「本端什么都没读出来」——[SharedContent.contentUnreadable] 如实告知,绝不静默。
* 「里面躺着值」这件事只有调用方知道(纯函数看不到原始值),所以由 [textPresent] 报进来,与
* [streamPresent] 同一个形态。
*
* ## 优先顺序与去重(一次分享里多种来源并存时)
* 顺序 = `EXTRA_STREAM` 的规范形态([stream] 在前、[streams] 在后)→ 字符串兼容形态(保持原顺序)
* → `ClipData`(只在前两者一条都没认下时兜底)。同一个地址串被两处各给一次时**只留先到的那一条**:
@@ -184,7 +282,10 @@ object ShareRules {
*
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
* HTML 富文本),读成 `String` 会直接拿到 `null`
* HTML 富文本),读成 `String` 会直接拿到 `null`。**回落链的第一站**
* @param subject `EXTRA_SUBJECT`——第二站。发送方「对象 / 标题」那个字段,正是有些 App 拿来放
* 正文的地方(`EXTRA_TEXT` 一个值都没有)
* @param title `EXTRA_TITLE`——第三站(少见,但真实发送方里确实有)
* @param stream `EXTRA_STREAM` 的单值形态([ACTION_SEND] 那个只放一条的字段)
* @param streams `EXTRA_STREAM` 的列表形态([ACTION_SEND_MULTIPLE] 的字段)
* @param streamStrings `EXTRA_STREAM` 的**字符串兼容形态**(`String` / `String[]` /
@@ -195,6 +296,10 @@ object ShareRules {
* @param streamPresent `EXTRA_STREAM` 里确实躺着**原始值**——由调用方读了原始值判出来(本函数
* 看不到那个值),含本端**认不出形态**的那种。它只有一个用处:形态认不出时本端既不猜、也不该
* 因此闭嘴,那照样是一次「带着内容迹象却什么都没读进来」。
* @param textPresent 三个文本 extra([text] / [subject] / [title])里**至少一个躺着原始值**——
* 同样由读了原始值的调用方判出来,含**读不出类型**与**只是一段空白**的那两种(`textPresent`
* 与 [streamPresent] 一样是「原料里有」「认下来的内容里没有」,用后者判会把「有值但读不出来」
* 误当成「什么都没分享」)。正文一旦读出来(哪怕是从标题回落的)它就不起作用了。
* @param clipUris `ClipData` 各条目的 URI,纯文本条目是 `null`(本端**不读** `ClipData` 里的
* 文本,所以「只有纯文本条目」等同于「读不进来」,用来判 [SharedContent.contentUnreadable])
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
@@ -204,17 +309,30 @@ object ShareRules {
fun extract(
action: String?,
text: CharSequence? = null,
subject: CharSequence? = null,
title: CharSequence? = null,
stream: String? = null,
streams: List<String>? = null,
streamStrings: List<String> = emptyList(),
streamPresent: Boolean = false,
textPresent: Boolean = false,
clipUris: List<String?> = emptyList(),
mimeOf: (String) -> String? = { null },
): SharedContent {
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
if (!recognized) return SharedContent()
val body = text?.toString().orEmpty().trim()
// 回落链(见 KDoc):三段一起按同一个口径归一(`toString` → 两侧 trim → 非空才算有),
// 取先到的那个**非空白**值。`EXTRA_TEXT` 非空时它一定赢 ⇒ 既有情形逐字不变。
val picked = listOf(
ShareTextSource.Text to text,
ShareTextSource.Subject to subject,
ShareTextSource.Title to title,
).firstNotNullOfOrNull { (source, value) ->
value?.toString()?.trim()?.takeIf(String::isNotEmpty)?.let { source to it }
}
val body = picked?.second.orEmpty()
val textSource = picked?.first ?: ShareTextSource.Text
// 两个字段一并收:单值 / 列表是历史形态(当年靠 getParcelableExtra 与
// getParcelableArrayListExtra 两个读法分开取,才不得不按 action 二选一),
@@ -239,9 +357,9 @@ object ShareRules {
}
}
// 「带着内容迹象」的判据取的是**原料**(EXTRA_STREAM 里躺着的原始值 / ClipData 的条目数),
// 不是「认下来的地址」——「有值但一条都认不出」正是要说的那种情形,用后者判反倒会把它当成
// 「什么都没分享」而闭嘴。
// 「带着内容迹象」的判据取的是**原料**(三个文本 extra 里躺着的原始值 / EXTRA_STREAM 里躺着的
// 原始值 / ClipData 的条目数),不是「认下来的内容」——「有值但一条都认不出」正是要说的那种
// 情形,用后者判反倒会把它当成「什么都没分享」而闭嘴。
val hadStreamValue = streamPresent ||
!stream.isNullOrBlank() ||
streams.orEmpty().any(String::isNotBlank) ||
@@ -249,6 +367,7 @@ object ShareRules {
return SharedContent(
text = body,
textSource = textSource,
images = images.take(MAX_IMAGES),
// 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI;
// ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。
@@ -256,7 +375,7 @@ object ShareRules {
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
contentUnreadable = unique.isEmpty() && body.isEmpty() &&
(hadStreamValue || clipUris.isNotEmpty()),
(hadStreamValue || textPresent || clipUris.isNotEmpty()),
)
}
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.chat
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@@ -47,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
@@ -63,6 +65,9 @@ import top.yeij.cyrene.core.prefs.accountFingerprint
import top.yeij.cyrene.core.session.ChatSessionSlots
import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.data.voice.VoiceRecorder
import top.yeij.cyrene.data.voice.VoiceRecorderState
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DocumentDelivery
@@ -75,7 +80,10 @@ import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button
@@ -92,6 +100,7 @@ import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.File
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */
@@ -109,6 +118,14 @@ private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
private const val SEARCH_NOTICE_MILLIS = 8_000L
/**
* 麦克风相关的告知(没授权 / 起不来)挂多久(毫秒)。
*
* 与同页其它几条「动作刚发生的如实告知」取同一个值:它描述的也是刚刚那一次按键,
* 不是持续状态(权限是不是真的没有,用户再按一次麦克风就知道了)。
*/
private const val VOICE_FAILURE_NOTICE_MILLIS = 8_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
@@ -386,6 +403,136 @@ fun ChatScreen(
}
var imageTarget by remember { mutableStateOf<ImageSource?>(null) }
// —— 语音播放(接收侧)——
// 取字节的缓存层:服务端相对路径(`voice.url`)接到**当前服务器地址**上再发请求,字节落在
// `cacheDir/media/`(按存储名内容寻址 ⇒ 重复播放一个请求都不发)。`baseUrlProvider` 每次现读:
// 换服务器必然重进本组合,而 `mediaApi` 那条客户端也按 baseUrl 重建(见 AppContainer)。
val voiceStore = remember(container) {
VoiceStore(
download = container.mediaRepository::download,
cacheDir = container.appContext.cacheDir,
baseUrlProvider = { container.authManager.currentBaseUrl },
)
}
// 一条播放通道归**这一个会话**:换会话 = 换实例 = 上一条立刻停并释放;离开聊天页(本组合被
// 丢弃)时 `onDispose` 释放(MediaPlayer 不 release 会一直占着解码器,见 VoicePlayer 的 KDoc)。
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
val voicePlayback by voicePlayer.state.collectAsState()
// —— 语音输入(契约 v0.96 §3.15 ①「用户按住麦克风说话」)——
// 录音机归**这一个会话**:换会话 = 换实例(与上面那条播放通道同一个生命周期纪律),
// 离开聊天页时 `onDispose` 收掉麦克风——一段没发出去的录音不该在后台继续占着它。
//
// ⚠️ 录音中**转屏**会重建组合 ⇒ 这一段丢失。本端不为此加一个「录音仓」:录音是几秒钟的
// 动作,转屏 / 分屏重建时丢掉一次比在 VM 里维护一条 AudioRecord 生命周期便宜得多。
val recorder = remember(sessionId) { VoiceRecorder() }
DisposableEffect(recorder) {
onDispose { recorder.release() }
}
val recording by recorder.state.collectAsState()
val recordingState = recording as? VoiceRecorderState.Recording
// 麦克风起不来 / 没授权时的如实告知(本端的问题,不走 VM 的 `error` 通道:
// 那条通道的语义是「服务端 / 发送」,而这里一次都没上过路)。
var voiceFailure by remember { mutableStateOf<String?>(null) }
LaunchedEffect(voiceFailure) {
if (voiceFailure != null) {
delay(VOICE_FAILURE_NOTICE_MILLIS)
voiceFailure = null
}
}
val requestAudioPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
// 授权刚拿到手就直接开录(用户按麦克风的意思就是「现在说」,不必再按第二次)。
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = VOICE_PERMISSION_DENIED_MESSAGE
}
}
// 开始录音:有权限就直接起;没有就申请。起不来时**如实说一句**,绝不假装在录。
fun startVoiceRecording() {
// 已经在录就不重入(录音中麦克风那枚按钮本来就不在屏幕上,这里是第二道闸)。
if (recorder.state.value is VoiceRecorderState.Recording) return
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = null
requestAudioPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}
// 「这一次收尾已经在飞」:防双击「发送」,也防它和「录满 60 秒自动收尾」撞在同一帧上。
// 第二次进入时录音机已经不在录了,`stop()` 会回 `null`——那会被误读成「没有录到声音」。
var finishingVoice by remember { mutableStateOf(false) }
// 结束录音并交给 VM:准入判据(空 / 太短 / 超限)全在 `VoiceClip.plan` 里,这里一个字节
// 都不自己判——空录音也照旧交给它,好让用户拿到那句「没有录到声音」。
fun finishVoiceRecording() {
if (finishingVoice) return
finishingVoice = true
scope.launch {
try {
val pcm = recorder.stop()
viewModel.sendVoice(pcm ?: ByteArray(0))
} finally {
finishingVoice = false
}
}
}
// 录满 60 秒:录音机只置位不自动发送(见 VoiceRecorder 的 KDoc),收尾由这一层做。
val reachedLimit = recordingState?.reachedLimit == true
LaunchedEffect(reachedLimit) {
if (reachedLimit) finishVoiceRecording()
}
// `voice_output` 到了就放(契约:这一帧只定向回本连接、断线不补投 ⇒ 不播就永远听不到)。
// 键里带 `file`:音频先落成本地文件再进状态,落好那一刻才是真正能播的时刻。
// `autoPlay` 只用一次:这一帧到达时用户可能在别的会话,切回来时不该突然响一段旧语音
// (判据见 VoiceReply.autoPlay)。「已经在放 / 正在载入」时不重新触发——否则第二次状态
// 到达会把正在响的那条停掉。
val voiceReply = state.voiceReply
LaunchedEffect(voiceReply?.id, voiceReply?.file) {
val reply = voiceReply ?: return@LaunchedEffect
val file = reply.file ?: return@LaunchedEffect
if (!reply.autoPlay) return@LaunchedEffect
viewModel.consumeVoiceReplyAutoPlay()
if (!voicePlayer.state.value.isActiveFor(reply.id)) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
}
// 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:<messageId>`,
// 与语音副本天然互斥——点一个停另一个)。键里带 `file`:`null → 文件` 那一刻才是真正能播的
// 时刻。`autoPlay` 只用一次,理由与上面那条完全相同(用户可能已经切走了)。
//
// ⚠️ `autoPlay` 必须进键:用户「放 → 停 → 再点同一条」时 `(clipId, file)` 一对**没变**
// (同一份缓存文件),只按那一对做键就再也不会起播。置假那一次会把键改成 `false`、下一轮
// 点击改回 `true`,于是每一点都重新点着一次;置假本身触发的重入在第一行就返回了,不成环。
//
// 起播之后这一格**不再变**:播放走到哪一步是播放通道的事,VM 不需要知道(见 `TtsClip` 的
// 类 KDoc)。
val ttsClip = state.ttsClip
LaunchedEffect(ttsClip?.clipId, ttsClip?.file, ttsClip?.autoPlay) {
val clip = ttsClip ?: return@LaunchedEffect
val file = clip.file ?: return@LaunchedEffect
if (!clip.autoPlay) return@LaunchedEffect
viewModel.consumeTtsAutoPlay()
if (!voicePlayer.state.value.isActiveFor(clip.clipId)) {
voicePlayer.toggleClip(clip.clipId) { Result.success(file) }
}
}
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
DisposableEffect(viewModel) {
onDispose { viewModel.dropPendingTtsAutoPlay() }
}
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
@@ -552,8 +699,29 @@ fun ChatScreen(
images = state.draftImages,
documents = state.draftDocuments,
busyText = busyLabel(state),
recordingMs = recordingState?.elapsedMs,
voiceDraft = state.voiceDraft,
voiceReply = state.voiceReply,
voicePlayback = voicePlayback,
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onStartRecording = { startVoiceRecording() },
onStopRecording = { finishVoiceRecording() },
onCancelRecording = { recorder.cancel() },
onRetryVoice = viewModel::retryVoice,
onDiscardVoice = viewModel::discardVoice,
// 播放 / 停止与关闭都在这一个动作里收口:关掉卡片之后再响一秒是明确的 bug。
onToggleVoiceReply = {
val reply = state.voiceReply
val file = reply?.file
if (reply != null && file != null) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
},
onDismissVoiceReply = {
voicePlayer.stop()
viewModel.dismissVoiceReply()
},
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
@@ -615,6 +783,14 @@ fun ChatScreen(
// 自己的外观,①②⑤ 与今天逐字相同。判定是纯函数,接线层不自己猜。
phase = state.phaseOf(item.message),
legBadge = state.legBadgeForMessage(item.message),
// 语音播放(接收侧):状态来自**这一个会话**的那条播放通道,
// 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。
voicePlayback = voicePlayback,
// 朗读(W75):**只有这一条**的朗读状态进来(对不上的当没点过)。
// 播放状态仍是同一条通道——朗读的键是 `tts:<messageId>`,所以两枚
// 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。
ttsClip = state.ttsClip?.takeIf { it.messageId == item.message.id },
ttsPlayback = voicePlayback,
onDocumentClick = { ref ->
previewNotice = null
previewTarget = DocumentTarget.of(ref)
@@ -629,6 +805,21 @@ fun ChatScreen(
// 各自用同一份产品口径的纯函数判定。
onEdit = viewModel::startEdit,
onDelete = viewModel::startDelete,
onVoiceToggle = voicePlayer::toggle,
// 朗读:请求、失败分类、落盘全在 VM;但**这一下是停还是念**必须在
// 这里分(判据是纯函数 [ttsSpeakIntent])。直通 `viewModel::speak`
// 会让「停止朗读」变成空操作(P1-1):播放中 `TtsClip.pending` 是假
// ⇒ VM 的防抖放行、整格换成新 pending、缓存命中秒回,而下面那个起播
// effect 又因为「已经在放同一条」跳过 `toggleClip`——三处各自都对,
// 合起来点了没反应。唯一带停止语义的入口在播放通道上,所以在这一层分。
onSpeak = { message ->
val clip = state.ttsClip
?.takeIf { it.messageId == message.id }
when (ttsSpeakIntent(voicePlayer.state.value, message.id, clip)) {
TtsSpeakIntent.Stop -> voicePlayer.stop()
TtsSpeakIntent.Speak -> viewModel.speak(message)
}
},
)
is ChatDisplayItem.Pending -> PendingBubble(
@@ -732,6 +923,14 @@ fun ChatScreen(
state.sendQueueNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 语音输入被本端挡下(没录到 / 太短 / 太大):与上面几条同档的如实告知。
state.voiceNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 麦克风起不来 / 没授权:这是真错误(功能整个不可用),所以用红色。
voiceFailure?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
}
searchNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
@@ -885,14 +1084,43 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null)
}
}
/**
* 输入栏:文本 / 图片 / 附件 / **语音**(契约 v0.96 §3.15 ①)。
*
* ## 三段语音形态各占一行(从上到下)
* 1. [VoiceReplyRow]——她的语音回复(`voice_output` 的产物)。放在最上面,因为它说的是
* 「刚刚那一轮的答复」,而下面几行是用户还要做的动作;
* 2. [VoiceDraftRow]——一段录好但没发出去的语音(重发 / 丢弃);
* 3. **录音中**:整行换成 [VoiceRecordingRow](录音中 / 取消 / 发送)。
*
* ## 录音中为什么把整行换掉
* 录音是一条有去无回的动作:那一行里同时留着一个「发送」按钮(文本的)与一个「结束录音」按钮,
* 手指落错就是把半句话当成文本发出去。换掉之后**文本发送那条路一个字节都没变**(不录音时
* 逐字与之前相同),而录音中屏幕上有且只有与该动作有关的两个选择。
*
* ## 语音入口为什么放在「发送」左边
* 与图片 / 附件同一侧(都是「加内容」),发送键仍在最右(肌肉记忆不变)。麦克风常驻——
* 它是与打字并列的**另一种输入方式**,不是某个附加能力。
*/
@Composable
private fun InputBar(
value: String,
images: List<String>,
documents: List<DraftDocument>,
busyText: String,
recordingMs: Int?,
voiceDraft: VoiceDraftInfo?,
voiceReply: VoiceReply?,
voicePlayback: VoicePlaybackState,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onCancelRecording: () -> Unit,
onRetryVoice: () -> Unit,
onDiscardVoice: () -> Unit,
onToggleVoiceReply: () -> Unit,
onDismissVoiceReply: () -> Unit,
onPickImage: () -> Unit,
onRemoveImage: (Int) -> Unit,
onPickDocument: () -> Unit,
@@ -905,6 +1133,25 @@ private fun InputBar(
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
// 她的语音回复:最上面一行,因为它说的是「刚刚那一轮的答复」。
voiceReply?.let { reply ->
VoiceReplyRow(
reply = reply,
playback = voicePlayback,
onTogglePlay = onToggleVoiceReply,
onDismiss = onDismissVoiceReply,
modifier = Modifier.padding(bottom = 6.dp),
)
}
// 录好但没发出去的那一段:只在 `voice_input` 没能上行时出现。
voiceDraft?.let { draft ->
VoiceDraftRow(
draft = draft,
onRetry = onRetryVoice,
onDiscard = onDiscardVoice,
modifier = Modifier.padding(bottom = 6.dp),
)
}
DraftImagePreviews(images = images, onRemove = onRemoveImage)
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
@@ -913,46 +1160,64 @@ private fun InputBar(
onRemove = onRemoveDocument,
onClick = onPreviewDocument,
)
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
if (images.size < ShareRules.MAX_IMAGES) {
IconButton(onClick = onPickImage) {
if (recordingMs != null) {
VoiceRecordingRow(
elapsedMs = recordingMs,
onCancel = onCancelRecording,
onSend = onStopRecording,
)
} else {
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
if (images.size < ShareRules.MAX_IMAGES) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.width(4.dp))
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
) {
Text("发送")
Spacer(Modifier.width(4.dp))
// 麦克风(点按开始 / 点按结束,见 [VOICE_RECORD_DESCRIPTION] 的措辞)。
// 换行后的入口与图片 / 附件同档;不读偏好、不占文本发送的任何一条判据。
IconButton(onClick = onStartRecording) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = VOICE_RECORD_DESCRIPTION,
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = onSend,
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
) {
Text("发送")
}
}
}
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
@@ -2,9 +2,11 @@ package top.yeij.cyrene.ui.chat
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Base64
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.io.File
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,9 +30,12 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.DocumentCache
import top.yeij.cyrene.data.repository.MessageEditDraft
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.PagedMessages
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.data.voice.SpeechClient
import top.yeij.cyrene.data.repository.applyMessageEdit
import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages
@@ -55,6 +60,9 @@ import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.chat.TurnBinding
import top.yeij.cyrene.domain.chat.TurnBucket
import top.yeij.cyrene.domain.chat.VoiceClip
import top.yeij.cyrene.domain.chat.VoiceClipPlan
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.allClosed
import top.yeij.cyrene.domain.chat.closingBucketOf
import top.yeij.cyrene.domain.chat.closingTurn
@@ -64,7 +72,14 @@ import top.yeij.cyrene.domain.chat.parallelForSend
import top.yeij.cyrene.domain.chat.parallelWireValue
import top.yeij.cyrene.domain.chat.readBounded
import top.yeij.cyrene.domain.chat.toDocumentFailure
import top.yeij.cyrene.domain.chat.ttsClipIdOf
import top.yeij.cyrene.domain.chat.ttsFailureOf
import top.yeij.cyrene.domain.chat.ttsTextOf
import top.yeij.cyrene.domain.chat.turnBucketOf
import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor
import top.yeij.cyrene.domain.chat.voiceClipRejection
import top.yeij.cyrene.domain.chat.voiceOutputHasAudio
import top.yeij.cyrene.domain.chat.voiceReplyText
import top.yeij.cyrene.domain.chat.withSteeredMessage
import top.yeij.cyrene.domain.chat.withTurnBegin
import top.yeij.cyrene.domain.parse.ParsedBubble
@@ -109,6 +124,15 @@ data class PendingMessage(
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
*/
val documents: List<DraftDocument> = emptyList(),
/**
* 这一条是**语音**时它的时长(毫秒);`null` = 不是语音(绝大多数消息)。
*
* 语音的 [text] 恒为空:本端不做本地转写,用户说的话要等服务端 STT 之后才会作为
* user 消息的 `content` 落库。而气泡**必须立刻出现**(用户刚说完,屏幕上没有东西就是
* 「没发出去」的样子)⇒ 这一条消息在正文之外还需要一个「这是语音、多长」的可见凭据。
* 它只影响渲染(`PendingBubble` 画那一枚「语音 · 0:07」),不参与发送与指纹。
*/
val voiceDurationMs: Int? = null,
)
/**
@@ -395,9 +419,24 @@ data class ToolCallState(
val tool: String = "",
val arguments: String = "",
val output: String = "",
/** `tool_call.end` 已到(参数流完整)。 */
/**
* 执行结果摘要(`tool_execution.end` 的可选 `resultSummary`;服务端当前**未部署**该键)。
*
* `null` 与 `""` 都是**「不知道」**:`null` 是键缺席(老服务端 / 这一轮没拿到结果),
* `""` 是万一被空串顶位。两者**都不等于**「没有输出」——那是我们替服务端下的结论,
* 契约明确不要这么读。渲染判据见 [toolResultTextOf]。
*/
val resultSummary: String? = null,
/**
* `tool_call.end` 已到(参数流完整)——**或者**连接重建收口时这一张卡不再可能续上
* ([settledAfterReconnect])。
*
* 两个来源合并成一位是刻意的:它在本端**只服务渲染**([ToolCard] 在 `phase == null` 时用
* 它选标题),而两种成因要说的都是同一件事——**这一张卡没有省略号可挂了**(见
* [settledAfterReconnect] 为什么必须去掉那个省略号)。
*/
val done: Boolean = false,
/** 执行阶段;`null` = 尚未进入执行。 */
/** 执行阶段;`null` = 尚未进入执行,或收口后不再有「进行中」可言(见 [settledAfterReconnect])。 */
val phase: ToolPhase? = null,
)
@@ -422,11 +461,35 @@ fun toolExecutionTargetIndex(tools: List<ToolCallState>, id: String?): Int {
return -1
}
/**
* 工具卡的**结果区该显示什么**(纯函数):返回 `null` = **一个像素都不画**。
*
* 判据只有两条来源,优先级如下:
* 1. [ToolCallState.resultSummary] 非空白 ⇒ 显示它。它是服务端脱敏 + 截断 ≤500 字符的**成品**,
* 本端**只展示**:不再截断、不解析、不当 markdown。
* 2. 否则回落到 [ToolCallState.output](`tool_execution.update` 累积的局部输出)——这是这张卡
* 今天唯一的来源,**逐字节保持不变**。
*
* ⚠️ 两个方向都不能反过来读:
* - 摘要**缺席或为空**时**绝不**合成「没有输出」/「无结果」这类文案。契约明说缺席只表示
* 「不知道」(这一轮的结果没拿到,可能是失败 / 取消 / 超时 / 未落图),替服务端下结论就是撒谎。
* - 空串与 `null` **同等对待**(都按不画):服务端承诺不会发空串,但只要它真发了,
* 渲染出一个空的「结果」区块比什么都不画更糟。
*/
fun toolResultTextOf(tool: ToolCallState): String? {
val summary = tool.resultSummary
if (!summary.isNullOrBlank()) return summary
return tool.output.takeIf { it.isNotBlank() }
}
/**
* 把一帧 `tool_execution.*` 应用到工具卡列表(纯函数,VM 与测试共用)。
*
* 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字,
* `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。
*
* [resultSummary] 与 `delta` 不同:它是**整段成品**,非空白时直接顶替(不是追加)。缺席(`null`)
* 或空白时**保留卡上已有的值**——三帧里只有 `.end` 会带它,一次落不到不该把上一次的抹掉。
*/
fun applyToolExecution(
tools: List<ToolCallState>,
@@ -434,6 +497,7 @@ fun applyToolExecution(
tool: String,
phase: String,
delta: String? = null,
resultSummary: String? = null,
): List<ToolCallState> {
val target = toolExecutionTargetIndex(tools, id)
if (target < 0) return tools
@@ -445,11 +509,48 @@ fun applyToolExecution(
tool = tool.ifBlank { state.tool },
phase = toolPhaseOf(phase) ?: state.phase,
output = if (delta.isNullOrEmpty()) state.output else state.output + delta,
resultSummary = resultSummary?.takeIf { it.isNotBlank() } ?: state.resultSummary,
)
}
}
}
/**
* 连接重建时一张工具卡的收口(纯函数):**还在跑**的那一张不再声称自己在跑。
*
* 现场(主人报的):后端卡在工具执行里被重启 ⇒ `tool_execution.end` 永远不会来 ⇒ 卡片永久停在
* 「执行 X…」/「调用工具…」那一格,一个**进行中**的字面承诺就此永远挂在那里。
*
* ## 为什么落点是 `phase = null` + `done = true`,而不是别的
* 1. **不许**给一个不知道结局的工具下结论:`phase = Error` 会说出「工具调用失败」——服务端从没
* 这么说过(它可能只是本端没等到),这正是 `toolResultTextOf` 那条纪律(「摘要缺席只表示
* *不知道*」)同一个方向的事,只是换了一张卡;
* 2. **不许**给 [ToolPhase] 加第四个取值:它逐字对齐后端流程图的 tool 节点 status
* (契约 v0.50 §3.1/§3.5),加一个本端专有的档就是在本端自造一套枚举,而枚举是**契约面**;
* 3. 于是只剩「把它退回**还没进入执行**那一档」这一条路:`phase = null` + `done = true` 在
* [ToolCard] 里渲染成「工具调用 · <工具名>」——**不再有省略号、也不下任何结论**。这比
* 「执行 X…」多一点诚实:工具调用确实发生过(`tool_call.*` 帧都在),而它的结局本端不知道。
*
* `done = true` 是为了让 `phase` 已是 `null` 的那张卡(只到了 `tool_call.*`、没到
* `tool_execution.*`)也从「调用工具…」的省略号里出来——**省略号就是「还在跑」**,
* 收口之后它不再在跑,那个省略号必须去掉。
*
* 幂等:已经 `Done` / `Error` 的卡**一个字节都不动**(那是服务端给的结论,本端不许改写它,
* 也不许把它降级成 `null`);重复调用返回同一个实例。
*/
fun List<ToolCallState>.settledAfterReconnect(): List<ToolCallState> =
if (none { it.phase == ToolPhase.Running || !it.done }) {
this
} else {
map {
when {
it.phase == ToolPhase.Running -> it.copy(phase = null, done = true)
!it.done -> it.copy(done = true)
else -> it
}
}
}
/** 正在流式的助手回复。 */
data class StreamingState(
val bubbles: List<ParsedBubble> = emptyList(),
@@ -539,6 +640,76 @@ fun ChatUiState.applyMessageActionFailure(
}
}
/**
* 点「朗读」这一步的状态迁移(纯函数,VM 与测试共用)。
*
* 返回 `null` = **这一次点击该被忽略**:同一条消息的请求还在飞([TtsClip.pending])。这是防抖的
* 唯一判据。已经落好(`file != null`)或已经失败过(`failure != null`)的**不算**在飞 ——
* 前者是「再放一遍」,后者是用户手动重试,两种都该放行。
*
* 返回新状态 = 放行,并且这一格已经换成「请求在飞」。`autoPlay` 保持默认的真:字节到手那一刻
* 要起播一次(见 [TtsClip.autoPlay]),置假只在真的起播之后(`ChatViewModel.consumeTtsAutoPlay`)。
*/
fun ChatUiState.beginTtsSpeak(messageId: String, clipId: String): ChatUiState? {
val current = ttsClip
if (current != null && current.clipId == clipId && current.pending) return null
return copy(ttsClip = TtsClip(messageId, clipId, null, null, false))
}
/**
* 请求回来了,把结论落到这一格(纯函数,VM 与测试共用)。
*
* 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、
* 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。
*
* `autoPlay` **从旧那一格继承**而不是重置成真:用户可能在请求落地之前就离开了这一屏
* ([ChatUiState.dropPendingTtsAutoPlay] 把这一位置了假),结果晚到之后若又把它抬回真,
* 切回来时就会突然响一段刚才点的朗读 —— 那正是要拦的那件事。
*
* 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的
* 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。
*/
fun ChatUiState.applyTtsOutcome(
messageId: String,
clipId: String,
result: Result<File>,
): ChatUiState {
val current = ttsClip
if (current == null || current.clipId != clipId) return this
val autoPlay = current.autoPlay
return result.fold(
onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false, autoPlay)) },
onFailure = { error ->
val failure = ttsFailureOf(error)
copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable, autoPlay))
},
)
}
/**
* 这一屏要走了:把**还没到手**的那一次自动起播作废(纯函数,VM 与测试共用)。
*
* ## 为什么需要它
* 起播闸门活在渲染层那个 `LaunchedEffect` 里(见 `ChatScreen`),而它只在**这一屏在的时候**
* 才会被跑一次。用户点了朗读之后立刻切走(换会话 / 进详情页),合成还在飞;结果落地的瞬间这一屏
* 不在,闸门没被消费过;等他切回来,那个 effect 一挂上就以「文件到了 + `autoPlay` 还是真」为由
* 起播 —— 突然响一段**已经过去**的朗读,不是他此刻的动作。
*
* ## 为什么在「离开」这一刻判,而不是给 clip 记一个发起时刻再按超龄丢
* 时间不是这件事的判据:合成一段长文本本来就可能花上十几秒,按秒数丢会把**人就在看着屏幕**
* 的那一次也一起丢掉(点了却不出声)。「这一屏还在不在」才是真正的分界 —— 离开时把没消费的
* 自动起播划掉,正好只拦掉「他不在」的那些。
*
* ## 只动「还没到手」的那一格
* 文件已经在手(`file != null`)说明闸门要么已经跑过(`autoPlay` 已被 [consumeTtsAutoPlay]
* 置假)、要么就是正在起播的这一刻:这两种情况一律原样返回,绝不打断一次真的播放。
*/
fun ChatUiState.dropPendingTtsAutoPlay(): ChatUiState {
val clip = ttsClip ?: return this
if (!clip.autoPlay || clip.file != null) return this
return copy(ttsClip = clip.copy(autoPlay = false))
}
sealed interface ChatDisplayItem {
val key: String
@@ -732,6 +903,45 @@ data class ChatUiState(
* `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
*/
val sessionGone: Boolean = false,
/**
* `voice_output` 到达后的那一份「她的语音回复」(契约 v0.96 §3.15);null = 没有要显示的那一条。
*
* 它**不是一条消息**,所以不在 [messages] / [pending] 里:这一帧只定向回发起 `voice_input` 的
* 那条连接、断线不补投(契约明写),回合的正文本来也会经流式 / `message_persisted` 落进历史。
* 这里存的是「这一次语音回合的音频 + 她说的话」这一次性产物,渲染在输入栏上方一张卡片上,
* 用户可以随时关掉([ChatViewModel.dismissVoiceReply])。
*
* 音频先落成本地文件再进这一格([VoiceReply.file]):把 base64 塞进状态会让**每一次**
* 状态 `copy` 都在几 MB 的字符串上做相等比较(流式期间每秒几十次)。
*/
val voiceReply: VoiceReply? = null,
/**
* 一段**录好、但没发出去**的语音(`voice_input` 没能上行);null = 没有等着重发的。
*
* 只有小字段(键 / 时长 / 字节数),audio 本体留在 VM 的私有字段里——理由同上。
* 界面上它是输入栏上方一条带「重发 / 丢弃」的卡片:重发**复用同一个 `clientMsgId`**
* (契约:同一个非空值服务端只承认第一条),所以重发不会让同一句话落成两条。
*/
val voiceDraft: VoiceDraftInfo? = null,
/**
* 语音输入被**本端**挡下时的如实告知(没录到 / 太短 / 太大,见 `domain.chat.voiceClipRejection`);
* null = 没有要说的。
*
* 与 [error] 分开:这些都不是「服务端说不行」,而是本端的准入闸门,且用户要做的事很具体
* (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。
*/
val voiceNotice: String? = null,
/**
* 「把这一条念出来」这一次的产物(W75,`POST /api/v1/audio/speech`);null = 用户还没点过朗读。
*
* **只有一格**:用户同一时刻点的是**一条**消息(见 [TtsClip])。它同时承载三件事 ——
* 请求在飞([TtsClip.pending])、字节已经落到本机([TtsClip.file])、上一次为什么没成
* ([TtsClip.failure])。音频本体是**文件路径**而不是字节,理由同 [voiceReply]:每次状态
* `copy` 都在几 MB 的数组上做相等比较是不可接受的。
*
* 「正在响」不在这一格里 —— 那归唯一的播放通道(`ui.chat.VoicePlayer`),键是 [TtsClip.clipId]。
*/
val ttsClip: TtsClip? = null,
) {
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
val items: List<ChatDisplayItem>
@@ -841,13 +1051,26 @@ class ChatViewModel(
it.draftDocuments.isNotEmpty() ||
it.pending.isNotEmpty() ||
it.queuedForConnection.isNotEmpty() ||
it.shareConfirm != null
it.shareConfirm != null ||
// 一段录好但没发出去的语音:它只在本端内存里(没有任何服务端副本),
// 交掉这一格 = 让用户重说一遍——与「还没发出去的字」同一条理由。
it.voiceDraft != null
}
private var socket: ChatSocket? = null
private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null
/**
* 一段**录好、但没发出去**的语音的字节(`voice_input` 没能上行)。
*
* 刻意是 VM 的字段而不是 [ChatUiState] 的一格:状态会被反复 `copy` 并逐字段比较(流式期间
* 每秒几十次),几 MB 的 `ByteArray` 放在那里等于每次比较都扫一遍;而它的生命周期与 VM
* 完全相同(转屏不丢、切走会话仍在**这一格**里,见 `ChatSessionSlots`)。
* 状态里只留小字段 [ChatUiState.voiceDraft](键 / 时长 / 字节数)供渲染。
*/
private var voiceRetry: VoiceRetryClip? = null
/**
* **每一路**一个流式正文解析器(键与 `ChatUiState.streams` 同源)。
*
@@ -893,6 +1116,19 @@ class ChatViewModel(
*/
private var gapCursor: Long? = null
/**
* 本 VM 是否**曾经**观测到过 [WsState.Connected] —— 「这一次 Connected 是**第一次接上**还是
* **接回来**」的唯一凭据(判据见 [isReconnect],它决定要不要 [settledAfterReconnect])。
*
* 为什么用「一位只置不翻的记忆」而不是「上一跳是不是 `Reconnecting`」:`ws.state` 是
* **StateFlow**,中间那一跳会被**合流吞掉**(收集器只看到 `Connected → Connected`),
* 而那一刻它仍然是一次**真实的重建**。一个只置不翻的位不受合流影响。
*
* 为什么在 VM 里而不是在 `ChatSocket` 上:问的是「**本 VM** 有没有见过连上」——连接是进程内
* 共享的一条,别的会话见过不算(本 VM 可能是离线期间才建起来的,见 [isReconnect] 第 3 条)。
*/
private var everConnected = false
/**
* 本会话的**首屏**([loadFirstPage] 那一次)走到哪儿了;判据见 [reconnectPull]。
*
@@ -1098,9 +1334,22 @@ class ChatViewModel(
*
* 补拉**必须先判「本端已知它没了」**([sessionKnownGone]):那个会话的 VM 在会话被删之后
* 仍然活着,不判就会在每一次重连都发一次注定 `404 session_not_found` 的往返(S7)。
*
* ⚠️ 本方法还承担一件**与补拉无关**的事:**连接重建那一刻把「进行中」全部收口**
* ([isReconnect] + [settledAfterReconnect])。理由与触发时机的取舍写在 `ReconnectReset.kt`
* 顶上:后端重启之后那一轮在服务端**已经不存在了**,本端等不到任何收口帧,不自己收就会永久
* 停在灰态 /「思考中…」/「正在思考」那一格,而且那条永远 `active` 的流式路会让「跟随底部」
* 永久失效。它**只**改「进行中的外观」:不碰消息、不碰待发、不碰草稿、不重拉。
*/
private fun onConnectionChanged(connection: WsState) {
val previous = _state.value.connection
// 收口刻意放在下面那道「同值早退」**之前**:状态流会合流,`Connected → Reconnecting →
// Connected` 中间那一跳**可能被吞掉**,那时收集器只看到 `Connected → Connected`,
// 而它仍然是一次**真实的重建**。它幂等(无变化时 [settledAfterReconnect] 返回同一个实例),
// 所以多跑一次没有代价。
if (isReconnect(previous, connection, everConnected)) _state.update { it.settledAfterReconnect() }
if (connection == WsState.Connected) everConnected = true
_state.update { it.copy(connection = connection) }
if (connection == previous) return
@@ -1377,12 +1626,311 @@ class ChatViewModel(
emitPlannedMessages(
plan = imageSendPlan(text, images, documents),
clearDraft = true,
applySendPreference = true,
fromShare = false,
localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX,
)
}
}
/**
* 语音输入(契约 v0.96 §3.15 的 ①「用户按住麦克风说话」):一段**已录好的 PCM** → `voice_input`。
*
* ## 准入判据只有一处([VoiceClip.plan],纯函数)
* 空 / 太短 / 超限三档都在这里被挡下并**明确说一句**([ChatUiState.voiceNotice],到期自己收走):
* 一段被静默吞掉的录音在用户眼里就是「按了没反应」。封 WAV 也在那里面(44 字节 RIFF 头),
* 本函数只做「发」这一件事。
*
* ## 乐观气泡与文本那条同源
* 上屏走的是**同一个** `pending` 列表([PendingMessage],只是 `text` 空、带上
* [PendingMessage.voiceDurationMs]),失败回滚走的是**同一个** `rollbackPending`——没有另造
* 一套「语音待发」的状态机。用户说完话气泡立刻出现,转写的正文之后由服务端的 user 消息
* 落库回来(按 `clientMsgId` 收编,判据在 [consumePendingFor])。
*
* ## 为什么没有补发队列
* 文本那条会进 [ChatUiState.queuedForConnection] 等连接;语音不进——audio 是几 MB 的内存,
* 钉在队列里等一个可能永远不来的连接不划算,而且「这段还发不发」应当由用户决定。
* 所以发不出去就把这一段**留着**([ChatUiState.voiceDraft]),由用户按「重发」
* ([retryVoice],**复用同一个 `clientMsgId`**)或「丢弃」([discardVoice])。
*
* @param pcm `AudioRecord` 收到的 16-bit / 16 kHz / 单声道 PCM(未封头)。
*/
fun sendVoice(pcm: ByteArray) {
viewModelScope.launch {
// 封 WAV + base64 编码都是几 MB 的活(PCM 复制 + 4/3 膨胀),放默认调度器上做,
// 别占主线程——用户按下「发送」之后这一帧就该没有可感的停顿。
val plan = withContext(Dispatchers.Default) { VoiceClip.plan(pcm) }
when (plan) {
is VoiceClipPlan.Ready -> deliverVoice(plan.wav, plan.durationMs, retry = null)
// Ready 之外的每一档都有一句人话(判定与文案都在 domain,能穷举单测)。
else -> voiceClipRejection(plan)?.let(::showVoiceNotice)
}
}
}
/**
* 重发**刚才没发出去的那一段**(复用它的 `clientMsgId`,契约要求)。
*
* 没有待重发的就什么都不做(按钮本不该出现;这里是第二道闸)。字节取的是 VM 里留着的那一份
* WAV(**不重新封装**),所以重发的是**同一个字节序列**——服务端按幂等键判重之后留下的那份
* 音频与用户眼前这段永远一致。
*/
fun retryVoice() {
val clip = voiceRetry ?: return
viewModelScope.launch { deliverVoice(clip.wav, clip.durationMs, retry = clip) }
}
/** 丢掉那段待重发的语音(用户点了「丢弃」)。 */
fun discardVoice() {
voiceRetry = null
_state.update { it.copy(voiceDraft = null) }
}
/** 关掉「她的语音回复」那张卡片(播放器由渲染层在同一个动作里停掉)。 */
fun dismissVoiceReply() {
val file = _state.value.voiceReply?.file
_state.update { it.copy(voiceReply = null) }
if (file != null) {
viewModelScope.launch { withContext(Dispatchers.IO) { file.delete() } }
}
}
/**
* 这一条语音回复的**自动播放**已经用掉了(渲染层在真的起了播放之后调)。
*
* 置位之后即使组合被重建(转屏 / 切走再回来)也不会再自动响一遍——见 [VoiceReply.autoPlay]。
* 幂等:已经置假时 `copy` 出的是同一个值,不会多一次有意义的变更。
*/
fun consumeVoiceReplyAutoPlay() {
_state.update { state ->
val reply = state.voiceReply
if (reply == null || !reply.autoPlay) state else state.copy(voiceReply = reply.copy(autoPlay = false))
}
}
/**
* 用户点了某条消息上的「朗读」(W75,`POST /api/v1/audio/speech`)。
*
* ## 念什么
* 只念**她**的消息(`assistant`):`user` 那一侧点朗读没有意义,而且服务端的发音人由
* **会话的生效人格**决定 —— 念主人自己说的话会用她的声音,那是错的。判据就是角色这一条,
* 其余(要念哪段字)全交给纯函数 [ttsTextOf]:它剥掉抬头与旁白,只留正文;一个字都不剩
* (纯附件 / 纯旁白)时**直接不发请求** —— 服务端对空 `text` 回 `400`,本端没有理由先跑一趟。
*
* ## 防重入
* 同一条消息的请求**在飞的时候再点一次**直接忽略:`_state.ttsClip` 已经是那条消息的
* `pending` 态。用户点**另一条**则照常发起(旧那条被整格替换,它的音频文件留在缓存里,
* 下次点它命中、不发请求 —— 见 `data.voice.SpeechClient`)。
*
* 播放那半边**不在这里**:这里只把「字节到手」写进 [ChatUiState.ttsClip],起播由渲染层那个
* `LaunchedEffect` 交给唯一的播放通道(`ui.chat.VoicePlayer`)。分开是必须的 —— VM 不该碰
* `MediaPlayer`,而播放通道要能在「换了一条语音副本」时独立停下,不必回头改这一格。
*
* ## 失败
* 分类是纯函数 [ttsFailureOf]:`tts_persona_unmapped` / `tts_no_character` 这两档是**配置**缺失
* (重试永远不会成功,文案要说清缺什么),其余一律柔和提示。**不重试**:这条路上没有任何
* 自动重试 —— 用户再点一次就是那次重试,而配置缺失那一档连这句话都不该说。
*
* 失败**不弹窗、不走 [ChatUiState.error] 横幅**:那是「这一次请求失败了」的全局通道,而朗读
* 是一次附带的小动作,把它抬成横幅会盖住真正的发送失败。文案挂在**这条消息**下面(见
* `MessageBubble`)。
*/
fun speak(message: HistoryMessageDto) {
if (message.role == "user") return
val text = ttsTextOf(message) ?: return
val clipId = ttsClipIdOf(message.id)
// 判据与落位都在纯函数里([beginTtsSpeak] / [applyTtsOutcome]):同一条正在飞 ⇒ 这里
// 拿回 null,直接返回。用 compareAndSet 而不是 update,是因为「放行与否」和「写进哪一
// 格」必须是同一次读写的结论——分开写会让并发的两次点击都以为自己放行了。
while (true) {
val current = _state.value
val begun = current.beginTtsSpeak(message.id, clipId) ?: return
if (_state.compareAndSet(current, begun)) break
}
viewModelScope.launch {
val result = speechClient.speak(text, sessionId)
_state.update { it.applyTtsOutcome(message.id, clipId, result) }
}
}
/**
* 这一次朗读的**自动起播**已经用掉了(渲染层在真的起了播放之后调)。
*
* 与 [consumeVoiceReplyAutoPlay] 同一条纪律、同一个理由:请求发出去之后用户可能已经切到别的
* 会话 / 别的页面,切回来时不该突然响一段刚才点的朗读(见 [TtsClip.autoPlay])。
*/
fun consumeTtsAutoPlay() {
_state.update { state ->
val clip = state.ttsClip
if (clip == null || !clip.autoPlay) state else state.copy(ttsClip = clip.copy(autoPlay = false))
}
}
/**
* 这一屏走了:把**还没到手**的那一次自动起播作废(判据与取舍全在纯函数
* [dropPendingTtsAutoPlay] 里)。
*
* 由渲染层的 `DisposableEffect.onDispose` 调:起播闸门只在这一屏活着的时候跑得到,人走了
* 而结果后到,切回来时就不该突然响一段旧朗读。
*/
fun dropPendingTtsAutoPlay() {
_state.update { it.dropPendingTtsAutoPlay() }
}
/**
* 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造
* (与 `AppContainer` 那些按需 getter 同一手法)。
*
* `baseUrlProvider` / `tokenProvider` 都传**函数**而不是值:换服务器 / 令牌续期之后,
* 下一次朗读自动用新的(与 `mediaApi` 同源同纪律)。凭据只走 owner Bearer ——
* 主人 2026-10-26 拍板不设 API Key 输入框(见 `data.voice.SpeechClient` 的类 KDoc)。
*/
private val speechClient: SpeechClient by lazy {
SpeechClient(
baseUrlProvider = { container.authManager.currentBaseUrl },
tokenProvider = container.authManager::currentToken,
unauthorizedPolicy = container.authManager.unauthorizedPolicy,
cacheDir = container.appContext.cacheDir,
)
}
/**
* 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。
*
* 键由纯函数 [voiceClientMsgIdFor] 决定:**重发复用 [retry] 那个键**,新录音现铸一个。
* 判定「发出去没有」用的是 `sendVoiceInput` 的返回值 + **发出那一刻**的连接状态
* (与文本那条 [sendThroughLadder] 同一手法):连接就绪却仍写不出去,与「还没连上」
* 是两句不同的话,用户要做的事也不同。
*/
private suspend fun deliverVoice(wav: ByteArray, durationMs: Int, retry: VoiceRetryClip?) {
val draft = retry?.let { VoiceDraftInfo(it.clientMsgId, it.durationMs, it.totalBytes) }
val clientMsgId = voiceClientMsgIdFor(draft) { UUID.randomUUID().toString() }
val audio = withContext(Dispatchers.Default) { Base64.encodeToString(wav, Base64.NO_WRAP) }
val totalBytes = wav.size
// 乐观气泡:与文本消息**同一个** pending 列表(正文空、带语音时长)。
_state.update {
it.copy(
pending = it.pending + PendingMessage(
localId = clientMsgId,
text = "",
voiceDurationMs = durationMs,
),
error = null,
voiceDraft = null,
voiceNotice = null,
)
}
val ws = socket
val sent = ws?.sendVoiceInput(sessionId, audio, VoiceClip.FORMAT, DEVICE_ID, clientMsgId) ?: false
if (sent) {
voiceRetry = null
return
}
val connection = ws?.state?.value ?: WsState.Idle
val reason = if (connection == WsState.Connected) {
VOICE_SEND_FAILED_MESSAGE
} else {
VOICE_NOT_CONNECTED_MESSAGE
}
// 回滚走的是**同一个** rollbackPending(摘掉刚才那枚气泡、把原因挂进 error 通道),
// 但不走 [rollback]:语音没有可以退回输入栏的正文 / 图 / 附件,那段字节留在
// [voiceRetry] 里等用户决定(重发或丢弃)。
voiceRetry = VoiceRetryClip(clientMsgId, wav, durationMs, totalBytes)
_state.update {
it.rollbackPending(clientMsgId, reason).copy(
voiceDraft = VoiceDraftInfo(clientMsgId, durationMs, totalBytes),
)
}
}
/**
* `voice_output` 落地(契约 v0.96 §3.15):**她的语音回复**(音频 + 她说的话)。
*
* ## 判据全在 domain(纯函数)
* [isSilentVoiceProvider] / [voiceOutputHasAudio] 决定「这一轮有没有真音频」,
* [voiceReplyText] 决定显示哪一句(`reply` = 她说的,`text` = 转写)。`silent` 档
* **不画播放控件**,只显示文字 + 一句说明(见 `VoiceReplyRow`)。
*
* ## 音频先落成本地文件
* base64 解码 + 写缓存都在 IO 上做(几 MB 的活不能占主线程),解码 / 落盘失败就退化成
* 「只有文字 + 那句『本机没能准备好』」——**绝不**把本机的失败说成「她只回了文字」
* (那是替服务端下一个它没下的结论)。
*
* ## 只定向回原连接、断线不补投
* 本方法**不做任何补救**(不拉历史、不重发 `voice_input`):契约明写这一帧不补投,
* 回合的正文与转写本来就会经流式 / `message_persisted` 落到消息列表里。
*/
private fun onVoiceOutput(event: WsEvent.VoiceOutput) {
val provider = event.provider
val hasAudio = voiceOutputHasAudio(event.audio, provider)
val id = "voice-reply-${UUID.randomUUID()}"
val previous = _state.value.voiceReply?.file
_state.update {
it.copy(
voiceReply = VoiceReply(
id = id,
reply = voiceReplyText(event.reply, event.text),
transcript = event.text?.takeIf { text -> text.isNotBlank() },
format = event.format,
provider = provider,
hasAudio = hasAudio,
file = null,
),
)
}
// 这一轮没有音频 ⇒ 到这里就结束了(正文已经在上面那一格里)。
val audio = event.audio?.takeIf { hasAudio } ?: return
viewModelScope.launch {
val file = withContext(Dispatchers.IO) {
previous?.delete()
storeVoiceReply(audio, event.format)
}
_state.update { state ->
// 只有「这一格还是我刚放进去的那一条」才补文件:迟到的解码不许覆盖更新的那一条。
if (state.voiceReply?.id == id) {
state.copy(voiceReply = state.voiceReply.copy(file = file))
} else {
file?.delete()
state
}
}
}
}
/**
* 把 `voice_output` 的 base64 音频落成 `cacheDir/media/` 下一个文件(**IO 线程调用**)。
*
* 目录与写入纪律直接复用 [VoiceStore] / [DocumentCache]:同一个 `media/` 根(与接收侧的
* 语音副本同一层,与 `voice.url` 的 `/api/v1/media/` 同源)、同样的「先写 `.part` 再改名」。
* 名字是现铸的随机名(`reply-<uuid>.<ext>`)而不是内容寻址:这一份是**一次性**的实时产物,
* 没有第二个人会来按名字取它。
*
* @return 可直接交给 `MediaPlayer` 的文件;任何一步失败回 `null`(调用方据此退化成文字)。
*/
private fun storeVoiceReply(base64: String, format: String?): File? = runCatching {
val bytes = Base64.decode(base64, Base64.NO_WRAP)
if (bytes.isEmpty()) return null
// 扩展名只影响可读性:`MediaPlayer` 按文件头嗅探,不认扩展名。不认识的格式一律 `.bin`。
val extension = format?.trim()?.lowercase()?.takeIf { it in VOICE_REPLY_FORMATS } ?: "bin"
val file = File(
VoiceStore.directory(container.appContext.cacheDir),
"reply-${UUID.randomUUID()}.$extension",
)
DocumentCache.write(file, bytes)
file
}.getOrNull()
/** 记一条语音输入的如实告知,到期自动收走(与 [showAttachmentNotice] 同一形态)。 */
private fun showVoiceNotice(text: String) {
_state.update { it.copy(voiceNotice = text) }
viewModelScope.launch {
kotlinx.coroutines.delay(VOICE_NOTICE_MILLIS)
_state.update { if (it.voiceNotice == text) it.copy(voiceNotice = null) else it }
}
}
/**
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
@@ -1406,7 +1954,7 @@ class ChatViewModel(
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
*
* ## 分享不声明(`applySendPreference` 恒为 false)
* ## 分享不声明(`fromShare = true` ⇒ [ShareRules.declaresSendPreference] 恒为 false)
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递,
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
* 两种声明在这里**都**是错的:
@@ -1467,7 +2015,7 @@ class ChatViewModel(
emitPlannedMessages(
plan = imageSendPlan(text, share.images, files),
clearDraft = false,
applySendPreference = false,
fromShare = true,
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
)
}
@@ -1491,12 +2039,19 @@ class ChatViewModel(
*
* 新认领的那一份描述从空开始([ShareConfirmDraft.caption] 的初值):它是用户要附加的话、**不是**
* 这次分享进来的正文——预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
*
* ## 认领那一刻还要如实说一句「正文是从标题回落来的」
* 回落来的正文与真正文长得一模一样(见 [shareTextOriginNotice]):面板里那段字看着就是对方发来的
* 正文本体,而它其实只是对方给的标题。这件事必须在用户点「发送」**之前**说——所以它挂在**认领**
* 这一步上(面板正要长出来的那一刻),而不是挂在发送路径的告知里。通道是 [showShareNotice]
* 那条柔和告知(不是错误横幅):没有出错,只是这一次拿到的东西与用户以为的不一样。
*/
fun claimSharedContent() {
val holding = _state.value.shareConfirm
val claimed = shareConfirmClaim(holding) { ShareIntake.take() }
if (claimed != null && claimed !== holding) {
_state.update { it.copy(shareConfirm = claimed) }
shareTextOriginNotice(claimed.share)?.let(::showShareNotice)
}
}
@@ -1624,16 +2179,20 @@ class ChatViewModel(
* 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则
* 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。
*
* 判据**只有一处**:[ShareRules.declaresSendPreference](纯函数,穷举整批的测试在
* `ShareSendDeclarationTest`)。这里只把两条入口的事实([fromShare])与位置([index])喂给它,
* 不在本函数里再写一遍 `&& index == 0`——那种写法正是 W76 之前两处各半个判据的由来。
*
* @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送,
* 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。
* @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的
* 那条传 false(分享不是用户按的发送键,从不声明)。
* @param fromShare 这一批是不是分享进来的:分享落地那条传 true([sendShared]),输入栏那条传
* false([send])。它是 [ShareRules.declaresSendPreference] 的入参之一——分享从不声明。
* @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。
*/
private suspend fun emitPlannedMessages(
plan: ImageSendPlan,
clearDraft: Boolean,
applySendPreference: Boolean,
fromShare: Boolean,
localIdPrefix: String,
) {
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages)
@@ -1644,7 +2203,7 @@ class ChatViewModel(
imageUri = message.imageUri,
documents = message.documents,
clearDraft = clearDraft && index == 0,
applySendPreference = applySendPreference && index == 0,
applySendPreference = ShareRules.declaresSendPreference(fromShare, index),
localIdPrefix = localIdPrefix,
)
}
@@ -1656,9 +2215,9 @@ class ChatViewModel(
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false:
* [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些
* ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。
* `interrupt` 与 `parallel`)。它是**已经算好的结果**,由 [emitPlannedMessages] 按
* [ShareRules.declaresSendPreference] 逐条判出来(唯一调用方就是它)——本函数不再自己推导,
* 也没有默认值:判据只能有一个出处,给个 `= true` 的默认值等于把口径又抄了一遍。
* @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认),
* [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在
* 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动**
@@ -1671,7 +2230,7 @@ class ChatViewModel(
imageUri: String?,
documents: List<DraftDocument>,
clearDraft: Boolean,
applySendPreference: Boolean = true,
applySendPreference: Boolean,
localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX,
) {
// 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。
@@ -1751,10 +2310,10 @@ class ChatViewModel(
// 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
//
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍
// 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true
// (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,且是这一批的第一条)。
// 这个值由 [emitPlannedMessages] 按 [ShareRules.declaresSendPreference] 逐条算好后传进来——
// 分享那条入口([ChatViewModel.sendShared])因此一次都不声明:它是**批量**投递,两种声明在那儿
// 都是错的(理由与取舍见 [sendShared] 的「分享不声明」一段)。
val mode = currentInterruptMode()
val busy = _state.value.busyMode != null
if (applySendPreference) interruptRunningTurn(mode, busy)
@@ -2175,7 +2734,7 @@ class ChatViewModel(
* 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发
* `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时**
* 才调(分享那条批量路径传 false,见 [sendShared])。
* 才调(那个值出自 [ShareRules.declaresSendPreference],分享那条批量路径恒为 false,见 [sendShared])。
*
* [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**:
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
@@ -2729,7 +3288,14 @@ class ChatViewModel(
is WsEvent.ToolExecutionUpdate ->
onToolExecution(event.turnId, event.id, event.tool, event.phase, event.delta)
is WsEvent.ToolExecutionEnd -> onToolExecution(event.turnId, event.id, event.tool, event.phase)
is WsEvent.ToolExecutionEnd ->
onToolExecution(
event.turnId,
event.id,
event.tool,
event.phase,
resultSummary = event.resultSummary,
)
is WsEvent.StreamEnd -> {
val bucket = bucketToClose(event.turnId)
@@ -2786,6 +3352,12 @@ class ChatViewModel(
is WsEvent.MessageDeleted -> applyMessageDeleted(event.id)
// 语音应答(契约 v0.96 §3.15):会话归属由解析层从 `data` 提到帧上(帧壳没有
// sessionId),所以上面那道 `isForSession` 闸对它**真的生效**——别的会话的语音
// 不会在本会话响起来。未知归属(坏帧)按同一道闸的既有口径放行(「无法判定」≠
// 「不属于本会话」)。判据与取舍见 [onVoiceOutput]。
is WsEvent.VoiceOutput -> onVoiceOutput(event)
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = event.mode).withActiveTurns(event.activeTurns) }
}
@@ -2996,7 +3568,7 @@ class ChatViewModel(
/**
* 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出。落不到就整帧忽略。
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出 / 结果摘要。落不到就整帧忽略。
*
* 落哪张卡由**两件事**决定:先按 `turnId` 选**这一路**(两路并行时各自的工具批次互不可见,
* 不变量 I3′),再在那一路上按 `id` 关联。帧壳没给 `turnId` 时落回本会话当前那一路
@@ -3008,10 +3580,13 @@ class ChatViewModel(
tool: String,
phase: String,
delta: String? = null,
resultSummary: String? = null,
) {
val bucket = openBucket(frameTurnId)
updateStreaming(bucket) { streaming ->
streaming.copy(tools = applyToolExecution(streaming.tools, id, tool, phase, delta))
streaming.copy(
tools = applyToolExecution(streaming.tools, id, tool, phase, delta, resultSummary),
)
}
}
@@ -3262,6 +3837,46 @@ private const val SHARE_TEXT_NOT_STAGED = "这段内容没能存成文件,已
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
/** 语音输入被本端挡下时那句告知停留的时长(同其它几条「动作刚发生」的告知)。 */
private const val VOICE_NOTICE_MILLIS = 8 * 1000L
/**
* 语音**还没连上**、因此一个字节都没发出去时的如实告知(走 `error` 通道,与「发送失败」同档)。
*
* 说清三件事:没连上(所以确实没发出去)、这段没丢(留在 `voiceDraft` 那一行)、下一步按「重发」。
* 刻意不写「发送失败」:失败的是连接,而这一段一次都没上过路——这与文本那条
* [SEND_QUEUED_NOTICE] 的措辞纪律同源,只是语音的字节留在本端等**用户**决定(不进自动补发队列)。
*/
private const val VOICE_NOT_CONNECTED_MESSAGE = "还没连上,这段语音没发出去;连上后点「重发」再试一次"
/**
* 连接就绪却仍写不出去(socket 对象已空 / 序列化抛异常)时的如实告知。
*
* 与 [VOICE_NOT_CONNECTED_MESSAGE] 分开:这一档说的不是连接,而是这一次写帧真的失败了,
* 所以不提「连上后」——那时候它已经连着。
*/
private const val VOICE_SEND_FAILED_MESSAGE = "这段语音没发出去,点「重发」再试一次"
/**
* `voice_output` 的音频落盘时**允许照抄**的扩展名(契约白名单 `wav|mp3|ogg`,服务端目前恒 `wav`)。
* 白名单之外的取值一律用 `.bin`:扩展名只用来让文件看得懂,**不**参与解码判定。
*/
private val VOICE_REPLY_FORMATS: Set<String> = setOf("wav", "mp3", "ogg")
/**
* 一段「录好、还没发出去」的语音的**完整**内容(只在 VM 私有字段里,不进界面状态)。
*
* [wav] 是已经封好 RIFF 头的那份字节:重发时原样再编码一次 base64,**不重新封装**——
* 重发的必须是同一段字节,否则「同一个 `clientMsgId`」在服务端判重之后留下的那份音频
* 与用户眼前这段就对不上了。
*/
private data class VoiceRetryClip(
val clientMsgId: String,
val wav: ByteArray,
val durationMs: Int,
val totalBytes: Int,
)
/**
* 「并行没排上」告知在界面上停留的时长(同上:描述刚发生的那一次发送)。
*
@@ -22,7 +22,9 @@ import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.data.repository.CachedDocument
import top.yeij.cyrene.domain.chat.DocumentPreview
import top.yeij.cyrene.domain.chat.DocumentPreviewPlan
import top.yeij.cyrene.domain.chat.DocumentSource
import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.PreviewMetadata
import top.yeij.cyrene.domain.chat.PreviewSlice
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
@@ -85,6 +87,15 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false
* [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
* 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
*
* ## 正文下方那一行元数据
* 正文(非文本时是那句人话)**下方**还有一行灰色小字,写的是「这份文件是什么」的格式事实:
* 文本给行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记,非文本给服务端嗅探所得的 mime。
* 组装全在纯函数 [PreviewMetadata] 里(**「为什么某一项不显示」也写在那里**),这里只取证据、画一行。
*
* 它与上方那枚芯片刻意**不重复**:体积与服务端状态芯片上已经写着(同一个数字、同一个词,再写
* 一遍只是噪音);字符数虽然也在芯片上,但芯片那个「8,000 字」没说清是谁的口径——本行补上
* 「(服务端注入口径)」,这才不算一句会把用户带偏的话。
*
* @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
* @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。
* @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
@@ -135,22 +146,25 @@ fun DocumentPreviewDialog(
TextButton("重试", onClick = onRetry)
}
is DocumentPreviewState.Ready -> when (plan) {
DocumentPreviewPlan.Text -> {
state.slice.notice?.let { text ->
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(bottom = 8.dp),
)
is DocumentPreviewState.Ready -> {
when (plan) {
DocumentPreviewPlan.Text -> {
state.slice.notice?.let { text ->
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(bottom = 8.dp),
)
}
PreviewBody(state.slice)
}
PreviewBody(state.slice)
}
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
}
PreviewMetadataLine(state = state, plan = plan, target = target)
}
}
@@ -199,6 +213,50 @@ fun DocumentPreviewDialog(
}
}
/**
* 正文**下方**那一行灰色小字:关于这份文件的格式元数据([PreviewMetadata] 的产物)。
*
* 组装全在纯函数里(逐档穷举单测),这里只做两件事:**取证据**、**画一行**。
* 取证据的两处来源都不许在 UI 层临场发挥:
* - 服务端那两件事实(注入字数 / 注入是否被截断)从[芯片模型][DocumentChipModel]里取——
* 它就是 DTO 那几字段的唯一换算落点([DocumentChips.of]);
* - 嗅探所得的 mime 只从 [DocumentSource.Server.mime] 取。本地待发附件**没有**这个结论
* (那时还没上传过),所以它这一项缺席——**不拿扩展名编一个**(`DocumentTarget.openType()`
* 对本地来源回的是 `application/octet-stream` 这个兜底值,拿它当「类型」会是本端编的话)。
*
* 拼出来是空串时**整行不画**(一项证据都没有就没有这一行),绝不留一个空行或一排「未知」。
*/
@Composable
private fun PreviewMetadataLine(
state: DocumentPreviewState.Ready,
plan: DocumentPreviewPlan,
target: DocumentTarget,
) {
val items = remember(state.slice, plan, target) {
when (plan) {
// 文本:行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记。
// `truncatedLabel` 非空 ⟺ DTO 的 `truncated == true`(见 DocumentChipModel)。
DocumentPreviewPlan.Text -> PreviewMetadata.textItems(
slice = state.slice,
serverCharsLabel = target.chips.charsLabel,
contextTruncated = target.chips.truncatedLabel != null,
)
// 非文本:只有服务端嗅探所得的 mime(体积与状态已在上面那枚芯片里)。
is DocumentPreviewPlan.NotText ->
PreviewMetadata.fileItems((target.source as? DocumentSource.Server)?.mime)
}
}
val line = PreviewMetadata.join(items)
if (line.isEmpty()) return
Spacer(Modifier.height(10.dp))
Text(
text = line,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
/** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */
@Composable
private fun PreviewBody(slice: PreviewSlice) {
@@ -54,6 +54,7 @@ import top.yeij.cyrene.domain.chat.ImageOpenTypes
import top.yeij.cyrene.domain.chat.ImageSize
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.ImageZoom
import top.yeij.cyrene.domain.chat.PreviewMetadata
import top.yeij.cyrene.domain.chat.ZoomPan
import top.yeij.cyrene.domain.chat.imageLoadFailureMessage
import top.yeij.cyrene.ui.common.PreferenceCard
@@ -145,6 +146,28 @@ fun ImagePreviewDialog(
)
}
// 预览内容下方那一行元数据要用的事实:像素与 EXIF 拍摄日期都得先读字节,所以放到 IO 上算。
// 键是**那一份缓存**:换一张图(或重试拿回新的字节)才重算,重组不会。
val facts by produceState(
initialValue = PreviewImageFacts(),
key1 = (state as? ImagePreviewState.Ready)?.cached,
) {
val cached = (state as? ImagePreviewState.Ready)?.cached ?: return@produceState
value = withContext(Dispatchers.IO) { readPreviewImageFacts(cached.bytes) }
}
val ready = state as? ImagePreviewState.Ready
val metadata = PreviewMetadata.join(
PreviewMetadata.imageItems(
// 「图片通配」不是格式:嗅探与平台声明都判不出来时这一项就缺席,
// **不拿 `image/*` 冒充一个答案**。
mime = ready?.openType?.takeIf { it != ImageOpenTypes.ANY_IMAGE },
width = facts.width,
height = facts.height,
sizeBytes = ready?.cached?.bytes?.size?.toLong() ?: PreviewMetadata.UNKNOWN_BYTES,
capturedAt = facts.capturedAt,
),
)
// 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节**
// (不是重新编码过的位图,JPEG 存下来还是 JPEG)。
val createDocument = rememberLauncherForActivityResult(
@@ -237,7 +260,8 @@ fun ImagePreviewDialog(
}
ExitPane(
ready = state as? ImagePreviewState.Ready,
ready = ready,
metadata = metadata,
notice = notice,
note = nameNoteFor(source),
onOpen = { openElsewhere() },
@@ -344,12 +368,16 @@ private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
/**
* 底部那一列出口(与附件弹层同一形态)。
*
* 最上面那一行是**预览内容下方**的格式元数据([PreviewMetadata] 的产物,可能是空串——
* 那就整行不画,绝不留一个空行或一排「未知」);再下面是 [notice](刚才那个动作的结果)。
*
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
*/
@Composable
private fun ExitPane(
ready: ImagePreviewState.Ready?,
metadata: String,
notice: DocumentPreviewNotice?,
note: String,
onOpen: () -> Unit,
@@ -360,6 +388,14 @@ private fun ExitPane(
.fillMaxWidth()
.padding(top = 10.dp, bottom = 12.dp),
) {
if (metadata.isNotEmpty()) {
Text(
text = metadata,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp),
)
}
notice?.let { line ->
Text(
text = line.message,
@@ -24,10 +24,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs
@@ -37,9 +47,23 @@ import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageHostRebase
import top.yeij.cyrene.domain.chat.TTS_LOADING_DESCRIPTION
import top.yeij.cyrene.domain.chat.TTS_SPEAK_DESCRIPTION
import top.yeij.cyrene.domain.chat.TTS_STOP_DESCRIPTION
import top.yeij.cyrene.domain.chat.TurnPhase
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.hasVoice
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.isVoiceMessage
import top.yeij.cyrene.domain.chat.ttsClipIdOf
import top.yeij.cyrene.domain.chat.ttsTextOf
import top.yeij.cyrene.domain.chat.voiceFallbackNote
import top.yeij.cyrene.domain.chat.voiceNote
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.InlineRun
import top.yeij.cyrene.domain.parse.InlineStyle
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yukonga.miuix.kmp.basic.Icon
@@ -48,7 +72,13 @@ import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.More
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
@@ -62,6 +92,11 @@ private val MESSAGE_ACTION_ICON_SIZE = 18.dp
/** 「⋯」与来源角标之间的间距。 */
private val MESSAGE_ACTION_GAP = 4.dp
/**
* 气泡外侧那枚时间与气泡之间的空隙:时间贴气泡,但不贴成一体(也不必去够屏幕边)。
*/
private val MESSAGE_TIME_GAP = 6.dp
/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
@@ -82,6 +117,15 @@ private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传
*
* 判据刻意**窄**,只有 `user` 消息才可能成立:
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
* - **`voice` 键在就一律不成立**([hasVoice]):这一条问的是「这条里**有没有东西可看**」,不是
* 「有没有语音形态」,所以两档都要挡住:
* - `status = empty` / `transcribed`(语音形态画得出来):转写文本可能为空,但那条消息**不是
* 什么都没有** —— 它有一个语音形态(`VoiceMessageRow` 会把它画出来)。少了这一档,一条
* 「微信语音、没认出发音、也没有音频副本」的消息会被扣上「内容没有被传过来」这顶帽子,
* 而那句话是**假的**;
* - `status = failed`(入站转写失败 / 出站合成降级):**没有**语音形态,但有一条正文
* (服务端那行诚实提示 / 降级后的原文本)和那行降级小标([voiceFallbackNote] 的产物)——
* 同样不是「什么都没有」;
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
* 顺手改会波及所有助手气泡,不在本次范围内。
*
@@ -94,7 +138,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
message.content.isBlank() &&
message.image.isNullOrBlank() &&
message.documents.isEmpty() &&
message.thinking.isNullOrBlank()
message.thinking.isNullOrBlank() &&
// 判据只有一处(domain.chat.hasVoice):绝不在这里另写一个 `voice != null`。
// ⚠️ 用 hasVoice 而不是 isVoiceMessage:failed 行(转写失败 / 合成降级)也**不是**什么都没有
// —— 它有正文(服务端那行诚实提示 / 降级后的原文本)与那行降级小标(voiceFallbackNote)。
!hasVoice(message)
/**
* 一条落库消息渲染成一个或多个气泡:
@@ -103,11 +151,34 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `voice` 键在时(🎤 判据 = **这个键存在且 `status != "failed"`**,契约 §4.4 + 后端 M57-M59
* 预告,见 `isVoiceMessage`)在正文之外多画一枚语音形态([VoiceMessageRow]:播放 / 暂停 + 时长,
* 有副本时才有播放控件);正文照常渲染——转写文本 / 服务端那行诚实提示都在里面,一个字都不改;
* `status = "failed"` 那一档(入站转写失败 / 出站合成降级)**没有**语音形态可画,改为在正文之外
* 多画一行灰色小标([VoiceFallbackNote],文案由 `voiceFallbackNote` 给:固定前半句 +
* 服务端 `fallbackReason` 原样);
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
* [ImageHostRebase](见 [imageBaseUrl]);
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
*
* ## 外侧那枚时间(W69)
* 每条**落库**消息在气泡外侧再画一枚灰色小字(主人:用户的气泡靠右 ⇒ 时间贴气泡左边;她的气泡
* 靠左 ⇒ 时间贴气泡右边):文案由纯函数 [messageTimeLabel] 给(今天 `14:30` / `昨天 09:05` /
* `3月7日 09:05` / 跨年 `2025年12月31日`),布局与宽度取舍见 [BubbleWithTime]。
* 它是与气泡下方那行角标(来源 / 六态 / 「⋯」)**并列的另一块**:那块说的是「这条从哪来、能对它
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
*
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音那一块
* ——语音形态或 `failed` 行的降级小标,`voice` 键在就必画其一)
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
* (W72 补的就是它)。
*
* 只有落库消息有它:流式气泡([StreamingBubble])与待发气泡([PendingBubble],[PendingMessage]
* 本端就没有时间戳这个概念)走的都是「不显示」那一档。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*
@@ -136,6 +207,15 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 回复侧在 [StreamingBubble] 上挂一次,用户就能把它们配起来。
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
* @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。
* @param voicePlayback 那条**播放通道**此刻的状态(`ui.chat.VoicePlayer` 的 `state`)。只有指出
* 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认
* [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。
* @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。
* @param ttsClip 这一条(或**别的一条**)的朗读状态;只有 `messageId` 对得上时才影响本条。
* 见 [TtsClip]:`null` / 对不上 / 本条没点过朗读 ⇒ 与加这一块之前逐像素相同。
* @param ttsPlayback 那条**播放通道**此刻的状态。朗读与语音副本共用它,所以朗读的键是
* `ttsClipIdOf(message.id)` 而不是 `message.id` —— 两枚图标的「正在响」互不串台。
* @param onSpeak 用户点了「朗读」:交给上层发请求(本组件不自己发)。
*/
@Composable
fun MessageBubble(
@@ -143,10 +223,15 @@ fun MessageBubble(
imageBaseUrl: String = "",
phase: TurnPhase = TurnPhase.Sent,
legBadge: String? = null,
voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle,
ttsClip: TtsClip? = null,
ttsPlayback: VoicePlaybackState = VoicePlaybackState.Idle,
onDocumentClick: (DocumentRefDto) -> Unit = {},
onImageClick: (String) -> Unit = {},
onEdit: (HistoryMessageDto) -> Unit = {},
onDelete: (HistoryMessageDto) -> Unit = {},
onVoiceToggle: (HistoryMessageDto) -> Unit = {},
onSpeak: (HistoryMessageDto) -> Unit = {},
) {
val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
@@ -168,6 +253,34 @@ fun MessageBubble(
// 所以 ③ 排队中**不会**被渲染成灰态——规范 §1.3 特意点名不要合并这两态。
val gray = phase == TurnPhase.Answering
// 气泡**外侧**那枚时间(W69)。它在组合期按当下时刻算一次,**不 remember**:文案是
// (时间戳, 现在, 时区) 的函数,而「现在」只在重组时才有机会更新——冻结它就等于让跨天之后
// 还挂着昨天的口径。代价是「页面开着不动、跨度又恰好跨过零点」时它不会自己变,
// 不为这一件事加一个 ticker(见 [messageTimeLabel])。
val outsideTime = messageTimeLabel(
createdAt = message.createdAt,
nowMillis = System.currentTimeMillis(),
)
// 时间挂在哪一块:判据全在纯函数里([messageTimeCarrier],可单测)。原来是「最后一个有正文的
// 气泡」(而不是「列表最后一项」——解析出的末段偶尔只有旁白,那一项不画正文框,挂上去时间就
// 没有落脚点),于是**纯附件消息**(有 documents 或 image、`content` 为空 ⇒ 一个正文气泡都没有)
// 那一行根本没被画出来。
val timeCarrier = messageTimeCarrier(message, bubbles)
// —— 朗读(W75)——
// 「这条消息有没有可念的正文」:判据与 VM 发请求用的是**同一个纯函数**([ttsTextOf]),
// 所以「画了入口」与「发得出去」永远不会分叉。这里喂的是**已经解析好的** `bubbles`:
// 它们就是屏幕上这些气泡,比让纯函数再解析一遍更贴近「用户看到的那段字」。
// 只给她的消息(`assistant`)——主人的话不该用她的声音念回来,VM 里还有第二道闸。
val speakable = !isUser && remember(bubbles) { ttsTextOf(bubbles) != null }
// 本条自己的那份朗读状态:`messageId` 对不上的(用户点了别的一条)一律当没点过。
val ownClip = ttsClip?.takeIf { it.messageId == message.id }
val ownClipId = remember(message.id) { ttsClipIdOf(message.id) }
val speakActive = ttsPlayback.isActiveFor(ownClipId)
// 「正在准备」有两个来源:VM 那边请求还在飞(网络 / 合成,绝大多数时间在这里),以及播放通道
// 正在取字节(本端落好的文件是直接给的,这一档只是一闪而过)。
val speakPending = ownClip?.pending == true || ttsPlayback.isLoadingFor(ownClipId)
MessageGroup(
isUser = isUser,
footer = bubbleFooter(
@@ -177,19 +290,80 @@ fun MessageBubble(
shared = isSharedMessage(message),
),
onOpenMenu = openMenu,
onSpeak = if (speakable) { { onSpeak(message) } } else null,
speakActive = speakActive,
speakPending = speakPending,
ttsFailure = ownClip?.failure,
) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
DocumentChipRow(
chips = message.documents.map(DocumentChips::of),
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
// 气泡都没有时**,它们中的一个就是时间的落脚点。不是那一个时 `time = null`,
// [BubbleWithTime] 连 `Box` 都不建 ⇒ 与 W69 之前逐像素相同。
if (message.documents.isNotEmpty()) {
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Attachments },
isUser = isUser,
) { side ->
DocumentChipRow(
chips = message.documents.map(DocumentChips::of),
modifier = side,
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
}
}
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
?.let { ChatImage(it, onImageClick) }
?.let { image ->
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Image },
isUser = isUser,
) { side ->
ChatImage(model = image, onClick = onImageClick, modifier = side)
}
}
message.voice?.let { voice ->
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
if (isVoiceMessage(message)) {
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
isUser = isUser,
) { side ->
VoiceMessageRow(
voice = voice,
messageId = message.id,
playback = voicePlayback,
note = voiceNote(voice.status, message.content),
alignEnd = isUser,
onToggle = { onVoiceToggle(message) },
modifier = side,
)
}
} else {
// status == "failed":这条**没有可播的声音**(入站转写失败 / 出站合成降级)。
// 正文照常按文字渲染(下面那条路),这里只多画一行灰色小标 —— **不**画语音形态:
// 画出来就是一个必然播不了的控件(见 isVoiceMessage 的 KDoc)。
// 没有这一行,这一条与普通文字消息逐像素相同,看的人无从知道它本来是一段声音。
voiceFallbackNote(voice)?.let { note ->
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
isUser = isUser,
) { side ->
VoiceFallbackNote(text = note, alignEnd = isUser, modifier = side)
}
}
}
}
if (isContentlessUserMessage(message)) {
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
ContentlessNote(onLongPress = openMenu)
BubbleWithTime(time = outsideTime, isUser = isUser) { side ->
ContentlessNote(onLongPress = openMenu, modifier = side)
}
} else {
// 时间挂在**最后一个有正文的气泡**外侧。`null` = 这条的落点在附件块上(或这条一个字都不画),
// 此时正文气泡一个都不带时间——时间挂在这条消息上一次,不跟着每个气泡重复。
val carrierIndex = (timeCarrier as? MessageTimeCarrier.TextBubble)?.index
bubbles.forEachIndexed { index, bubble ->
Bubble(
bubble = bubble,
@@ -198,6 +372,7 @@ fun MessageBubble(
showCursor = gray,
onLongPress = openMenu,
gray = gray,
sideTime = outsideTime.takeIf { index == carrierIndex },
)
}
}
@@ -300,6 +475,147 @@ fun bubbleFooter(
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
/**
* 一条消息**外侧**那枚灰色时间的文案(纯函数,可单测);`null` = 这条一个字都不画。
*
* 四档,全部按**本地日历日**分(不是「相差不到 24 小时」那种算法:`23:59` 与次日 `00:01` 只差
* 两分钟,却已经跨天,这一档必须是「昨天」):
* - **今天**(与 `nowMillis` 落在同一个本地日期)→ `14:30`;
* - **昨天** → `昨天 09:05`;
* - **今年内更早** → `3月7日 09:05`(月日不补零,时分补零);
* - **跨年** → `2025年12月31日`。**刻意不带时分**:一年前的消息精确到分没有意义,年月日已经
* 把「多久远」说清了,多四个字符反而更难扫。
*
* ## 未来时间:按「今天」那一档显示(不造「明天」这个词)
* 服务端时钟略快 / 本机时钟略慢时会出现几秒到几分钟「之后」的时间戳,它没有任何要表达的语义
* (这条就是刚刚到的),所以只显示 `HH:mm`——那正是这条消息发生的那一刻。判据因此写成
* `!day.isBefore(today)` 而不是 `day == today`:顺手也把「服务端时钟整整偏了一天 / 一年」这种
* 坏时钟吃掉——宁可只显示一个时刻,也不在用户眼皮底下编出一个他从没见过的「明天」或假日期。
*
* ## 非法 / 缺失(`<= 0`):整条不画
* 这个字段是**毫秒 Unix 时间戳**;`0` / 负数只可能来自「这个键根本没传过来」或解析事故,不是
* 一个真实时刻。画成 `1970年1月1日` 或 `--:--` 都是在替服务端编一个它没说过的答案;而一条没有
* 时间的消息并不缺什么(正文、来源角标、动作入口都还在,见 [MessageBubble])。
*
* ## 为什么在 UI 层算,而不是让 VM 把文案算好塞进 item
* 它只依赖(这条的时间戳,现在,时区)——前两个 UI 层本地就有。为了一个「现在」去动 item 模型与
* 列表的 key 不划算,而且那样还会把文案**冻住**:同一秒里算出来的字符串被 diff 当成同一个 item,
* 跨天之后那一屏就永远停在旧口径。
*
* `zone` 可注入(默认本机时区),`nowMillis` 由调用方喂:这条映射自己不读表,所以可单测、可复现
* (与 [searchHitTime] 同一条写法)。
*/
fun messageTimeLabel(
createdAt: Long,
nowMillis: Long,
zone: ZoneId = ZoneId.systemDefault(),
): String? {
if (createdAt <= 0L) return null
val at = Instant.ofEpochMilli(createdAt).atZone(zone)
val day = at.toLocalDate()
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
return when {
// 今天**与未来**同一档(见 KDoc:未来只可能是时钟漂移,不造「明天」这个词)。
!day.isBefore(today) -> at.format(MessageClockFormat)
day == today.minusDays(1) -> "$YESTERDAY_PREFIX ${at.format(MessageClockFormat)}"
day.year == today.year -> at.format(MessageDayFormat)
else -> at.format(MessageDateOnlyFormat)
}
}
/**
* 「今天」那一档,同时也是其余各档拼时分的零件:24 小时制、补零。
*
* `Locale.ROOT`:刻意不跟随设备 `locale`——这几档文案的形状是固定的(「3月7日」不会因为系统语言
* 变成别的写法),不给本地化留口子。
*/
private val MessageClockFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
/**
* 「今年内更早」那一档:`3月7日 09:05`(月日**不**补零,与 [messageTimeLabel] 的例一致)。
*
* 两处模式字母都要盯住:**`d`** 才是「月里的第几天」,大写 **`D`** 是「一年里的第几天」
* (`M'月'D'日'` 会把 8 月 14 日排成「8月226日」——W69 的单测就是在这一步拦下的)。
*/
private val MessageDayFormat = DateTimeFormatter.ofPattern("M'月'd'日' HH:mm", Locale.ROOT)
/** 「跨年」那一档:`2025年12月31日`(刻意不带时分)。 */
private val MessageDateOnlyFormat = DateTimeFormatter.ofPattern("yyyy'年'M'月'd'日'", Locale.ROOT)
/** 「昨天」那两个字:只与时分连用(`昨天 09:05`),不会单独成句。 */
private const val YESTERDAY_PREFIX = "昨天"
/**
* 这条消息的时间**落在哪一块**上([messageTimeCarrier] 的返回值)。
*
* 之所以要给它一个名字,是因为「时间挂在正文气泡上」与「时间挂在附件块上」在 [MessageBubble] 里
* 是**两处不同的调用点**(前者由 [Bubble] 经 `sideTime` 代画,后者各自包一层 [BubbleWithTime]),
* 而某条消息只能落一个地方。判据本身是纯函数,所以「纯附件消息也有时间」这件事能被单测直接钉住,
* 不必去跑一个 Compose 测试。
*/
sealed interface MessageTimeCarrier {
/** 正文气泡:`bubbles` 里的下标([MessageBubble] 解析出来的第几个气泡)。 */
data class TextBubble(val index: Int) : MessageTimeCarrier
/** 附件芯片排(`documents` 非空才会画)。 */
data object Attachments : MessageTimeCarrier
/** 图片(`image` 非空才会画)。 */
data object Image : MessageTimeCarrier
/** 语音那一块(`voice` 键在才会画:语音形态,或 `failed` 行的降级小标)。 */
data object Voice : MessageTimeCarrier
}
/**
* 这条消息外侧那枚时间该挂在哪一块上(纯函数,可单测);`null` = 什么都不挂。
*
* ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西
*
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音那一块 → 正文气泡。
* 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。
*
* 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音那一块 → 图片 → 附件芯片排。这一档就是
* **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡
* `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来**
* (主人 2026-10-07 验收时发现的缺口)。
* 「语音那一块」按 `voice` 键判([hasVoice])而**不**按 [isVoiceMessage]:`failed` 行不画语音
* 形态,但会画那行降级小标([VoiceFallbackNote])——它也占着一块,也照样能挂时间。
* 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。
*
* ## 谁**不**走这一档
* - **真的空气泡**([isContentlessUserMessage] 成立):上面三块按判据一定都不在,所以这里返回
* `null`;但那种消息**仍然有时间**——[MessageBubble] 把 [ContentlessNote] 自己包了一层
* [BubbleWithTime]。两处刻意分开:那块的落脚点由 `ContentlessNote` 那条路自己交代,不必让这个
* 函数认识它(它只答「附件块和正文气泡里挂哪个」)。
* - **思维链卡片**:它永远画在最上面,不可能是「最后一块」(有别的块时被别的块盖过,没别的块时
* 这条消息的真问题也不是时间)。不把它算进来 ⇒ 只有思维链的助手消息与 W69 之前逐字相同。
* - **流式 / 待发气泡**:它们根本不调这个函数(见 [BubbleWithTime] 的 KDoc)。
*
* 与角色无关:她侧(`assistant`)也会带附件与图,同一条规则照用。贴哪一侧由 [BubbleWithTime]
* 按 `isUser` 定,不在这里判——这一层只回答「挂在哪一块」,不回答「挂在它的哪一边」。
*
* @param bubbles [MessageBubble] 已经解析好的气泡列表(本函数不去 `parse` 一遍:那一份判据在
* `isChat` 上,重复算一次会多出「两份解析结果不一致」的可能)。
*/
fun messageTimeCarrier(
message: HistoryMessageDto,
bubbles: List<ParsedBubble>,
): MessageTimeCarrier? {
val lastBody = bubbles.indexOfLast { it.body.isNotEmpty() }
if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody)
// 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。
return when {
hasVoice(message) -> MessageTimeCarrier.Voice
!message.image.isNullOrBlank() -> MessageTimeCarrier.Image
message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments
else -> null
}
}
/**
* 乐观发送中的用户消息(还没落库)。
*
@@ -334,6 +650,9 @@ fun PendingBubble(
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
)
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
// 语音:正文是空的(本端不转写,用户说的话要等服务端 STT 之后才落库),
// 所以这一条消息至少要有「这是语音、多长」这一枚可见凭据——与接收侧的语音形态同一个外观。
item.voiceDurationMs?.let { durationMs -> VoiceClipLabel(durationMs) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
@@ -497,6 +816,10 @@ private fun MessageGroup(
isUser: Boolean,
footer: String? = null,
onOpenMenu: (() -> Unit)? = null,
onSpeak: (() -> Unit)? = null,
speakActive: Boolean = false,
speakPending: Boolean = false,
ttsFailure: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
@@ -509,7 +832,7 @@ private fun MessageGroup(
content()
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
if (footer != null || onOpenMenu != null) {
if (footer != null || onOpenMenu != null || onSpeak != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
@@ -522,6 +845,31 @@ private fun MessageGroup(
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
// 「朗读」紧挨「⋯」:两枚都是这条消息的动作,分开放会让人以为是两个话题。
// 与「⋯」同一个尺寸、同一条 `Icon + clickable` 纪律(理由见下面那段注释)。
if (onSpeak != null) {
Icon(
imageVector = if (speakActive) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = when {
speakActive -> TTS_STOP_DESCRIPTION
speakPending -> TTS_LOADING_DESCRIPTION
else -> TTS_SPEAK_DESCRIPTION
},
// 正在响 ⇒ 主色(与语音形态那枚图标同一口径);其余是角标行那档灰。
tint = if (speakActive) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
// 正在准备时**点不动**:这时候再发一次请求没有任何意义,用户想要的是
// 「别再等了」——而那条路(取消在飞的合成)本端没有做,所以不给一个
// 点了没反应的入口(见报告「未做」)。
modifier = Modifier
.size(MESSAGE_ACTION_ICON_SIZE)
.clip(CircleShape)
.clickable(enabled = !speakPending, onClick = onSpeak),
)
}
if (onOpenMenu != null) {
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
@@ -538,9 +886,104 @@ 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),
)
}
}
}
/**
* 气泡本体 + 它**外侧**那一枚灰色时间(主人 W69):**用户**的气泡靠右 ⇒ 时间贴气泡**左边**;
* **她**的气泡靠左 ⇒ 时间贴气泡**右边**。
*
* ## 时间那一条的宽度是「量」出来的,不参与宽度分配
* 气泡可用宽度有限([BubbleBody] 自己封了 `300.dp` 的上限),而时间与气泡**同一行**,必然分掉
* 一点横向空间。这里**不让两者去抢 weight**,而是先用 [rememberTextMeasurer] 把这一行字按
* **它将要用的那个 [MiuixTheme.textStyles.footnote2]** 量一遍,拿到它排出来的**精确宽度**,
* 再用 `padding` 把这条宽度从气泡的可用宽度里**先扣掉**:
* - 时间量得准(同一份 `TextStyle`、同一份字体缩放与字体回退,量出来的就是排出来的那一串字——
* `Text` 内部只是在它自己那份 `style` 上 merge 显式参数,不参数字段一律不动),所以它**既不会
* 溢出屏幕、也不会被压成两行**;
* - 气泡吃**剩下的全部**宽度(`padding` 是在约束里扣的,气泡自己的 `widthIn(max = 300.dp)` 照旧
* 生效 ⇒ 实际可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`),所以长消息照旧用满能用的宽度。
* 代价写在明处:**有时间的消息,气泡比加这一块之前最多窄一条时间的宽度**(这是「时间与气泡同行」
* 的必然结果,也是主人要的那件事)。只有「极窄屏 + 超大字号」这一条极端组合下气泡才会明显变窄
* ——那时我们选择**如实把日期显示完整**:截断「2025年12月31日」比窄一点的气泡更亏。
*
* 为什么不用「把时间排在子节点前面、让它先占住宽度」这种零测量的写法:`Row` 给非 weight 子节点的
* 宽度是**按子节点顺序**递减的,所以「气泡在前」的那一侧(她那一侧)必然先把宽度吃光、把时间挤成
* 一条竖线;而 Compose 的 `Row` 只有「顺序摆放」一种排布,做不到「先量、后摆」。自写 `Layout` 当然
* 可以,但要自己接管窄屏下的钳制,而且那份宽度就是同一笔账——量一次是这里最小的一步。
*
* ## 摆在哪(三条都是「不许动气泡」的具体落实)
* - 时间在**气泡外边**:`padding` 在 `clip` / `background` 之外,所以它不是气泡的一部分——底色、
* 圆角、[SelectionContainer] 的选字范围、长按的点击区全都与加它之前逐像素 / 逐行为相同;
* - 时间与气泡**底对齐**:贴着气泡最后一行(气泡很高时它也不会跑到中间去);
* - 与气泡之间有 [MESSAGE_TIME_GAP] 的空隙,免得那串灰字和气泡贴成一体。
*
* 整块(时间 + 气泡)仍由 [MessageGroup] 那一列按角色对齐(主人靠右、她靠左),所以**气泡自己那
* 一侧**离屏幕的边距与没有时间时**逐像素相同**——时间填的正是原本空着的那条区域。
*
* @param time `null` = 这条消息不显示时间(时间戳缺失 / 非法,见 [messageTimeLabel]):此时**连
* `Box` 都不建**,整个气泡与加这一块之前逐像素相同——流式气泡与待发气泡走的正是这一档
* (后者本端还没有任何时间戳)。
*/
@Composable
private fun BubbleWithTime(
time: String?,
isUser: Boolean,
bubble: @Composable (Modifier) -> Unit,
) {
if (time == null) {
bubble(Modifier)
return
}
val measurer = rememberTextMeasurer()
val style = MiuixTheme.textStyles.footnote2
val density = LocalDensity.current
val strip = remember(measurer, density, time, style) {
with(density) {
measurer.measure(text = time, style = style, maxLines = 1, softWrap = false).size.width.toDp()
} + MESSAGE_TIME_GAP
}
Box {
bubble(
Modifier
.then(if (isUser) Modifier.padding(start = strip) else Modifier.padding(end = strip))
// 气泡贴回它自己那一侧:`strip` 已经吃掉了可用的余量,这里的对齐在正常宽度下是
// 恒等的(气泡就是最宽的那个孩子),留着是为了「万一条比气泡还宽」时也仍在原位。
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart),
)
Text(
text = time,
style = style,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
// 宽度已经量过、条也留够了,正常情况下一行必放得下;这两个参数是防「量完到排完之间
// 密度变了」这类不该发生的事——真发生也只是这串字自己被截,不会去挤气泡。
maxLines = 1,
softWrap = false,
modifier = Modifier.align(if (isUser) Alignment.BottomStart else Alignment.BottomEnd),
)
}
}
/**
* 一条消息在气泡层渲染成一个 / 多个气泡。
*
* [sideTime] 只由**携带时间的那个气泡**收到([MessageTimeCarrier.TextBubble] 指的那一个),
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。`body` 为空的气泡
* (比如纯附件消息解析出来的那个占位气泡)一个像素都不画,**连 [BubbleWithTime] 都不进**:
* 这也是纯附件消息会整条没有时间的原因(判据见 [messageTimeCarrier])。
*/
@Composable
private fun Bubble(
bubble: ParsedBubble,
@@ -549,10 +992,24 @@ private fun Bubble(
showCursor: Boolean = false,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
sideTime: String? = null,
) {
AnnotationLine(bubble.head)
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray)
val withCursor = showCursor && isLast && bubble.body.isNotEmpty()
val body = if (withCursor) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) {
BubbleWithTime(time = sideTime, isUser = isUser) { side ->
BubbleBody(
text = body,
runs = bubble.runs,
isUser = isUser,
onLongPress = onLongPress,
gray = gray,
trailingCursor = withCursor,
modifier = side,
)
}
}
AnnotationLine(bubble.tail)
}
@@ -582,7 +1039,14 @@ private fun LegBadge(text: String, onClick: (() -> Unit)?) {
}
}
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
/**
* 旁白/动作:灰斜体小字,依附在气泡上/下。
*
* 这一行**不做行内解析**(正文那四类标签在这里照原样显示)。理由是它读的是 `parts` 里的原文,
* 而分享预览那行读的是同一批原文(`ShareConfirmPanel.ShareConfirmAnnotation`)——那边是红线、
* 本次一个字都不能动。只在这边渲染,就等于让「预览里看到的 == 发出去之后看到的」在旁白上又裂一次,
* 而 W65 刚把这道缝补上。要连带做,得两处一起改并单独拍板(实测 `<action>` 里带 `<b>` 的 0 命中)。
*/
@Composable
private fun AnnotationLine(parts: List<ContentPart>) {
if (!LocalAppPrefs.current.showNarration) return
@@ -610,11 +1074,14 @@ private fun AnnotationLine(parts: List<ContentPart>) {
* ## 长按仍然开动作菜单
* 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。
* 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`:
* 和 [BubbleBody] 一样,它是**气泡外边**的留白,不参与这张灰底方块的绘制与点击区。
*/
@Composable
private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
private fun ContentlessNote(onLongPress: (() -> Unit)? = null, modifier: Modifier = Modifier) {
Box(
modifier = Modifier
modifier = modifier
.widthIn(max = 300.dp)
.clip(BubbleShape)
.background(MiuixTheme.colorScheme.surfaceContainer)
@@ -648,16 +1115,35 @@ private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
* 这一态的语义是「这条已经在被处理,但还没有答案」,它既不是用户说的话(不该是主人的青色),
* 也不是她的回答(不该是助手的正常底色)。正文一个字都不改,只是**褪色**,让人一眼看出
* 「没丢,只是在等」。
*
* [runs] 非空时**只画 [runs]**(拼起来就是 `text` 去掉末尾光标那一截,见 [inlineBody]):
* 同一段正文不在这里再拼一遍,免得拼出两个版本的字。[runs] 为空 = 这段正文没有可渲染的行内标签,
* 走原来的 `Text(text)` 那条路(**绝大多数消息都是这一档,一个像素都不变**)。
*
* [trailingCursor] 说的是「`text` 末尾那枚光标」。光标必须**在片段之外**补上去:它不是正文,
* 不该继承她最后那一段的粗体/等宽底色。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
* 它排在这条链的**最前面**,所以那一层留白在 `clip` / `background` / `combinedClickable` 之外:
* 气泡自己的样子、选字范围、长按的点击区都还是原来的那一块。
*/
@Composable
private fun BubbleBody(
text: String,
runs: List<InlineRun>,
isUser: Boolean,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
trailingCursor: Boolean = false,
modifier: Modifier = Modifier,
) {
val contentColor = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
isUser -> MiuixTheme.colorScheme.onPrimary
else -> MiuixTheme.colorScheme.onSurfaceContainer
}
Box(
modifier = Modifier
modifier = modifier
.widthIn(max = 300.dp)
// clip + background 与原来那句 `background(color, shape)` 画出来完全一样,
// 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。
@@ -685,19 +1171,74 @@ private fun BubbleBody(
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
isUser -> MiuixTheme.colorScheme.onPrimary
else -> MiuixTheme.colorScheme.onSurfaceContainer
},
)
if (runs.isEmpty()) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = contentColor,
)
} else {
Text(
text = inlineBody(
runs = runs,
codeBackground = if (isUser) {
MiuixTheme.colorScheme.onPrimary.copy(alpha = INLINE_CODE_ON_USER_BACKGROUND)
} else {
MiuixTheme.colorScheme.surfaceContainerHigh
},
trailingCursor = trailingCursor,
),
style = MiuixTheme.textStyles.main,
color = contentColor,
)
}
}
}
}
/**
* `<code>` 那点底色在**主人那侧**(青色气泡)的透明度:`onPrimary` 压一层浅底。
* 昔涟那侧直接用 `surfaceContainerHigh`——它本来就是比气泡底(`surfaceContainer`)高一档的填充色,
* 同一套配色里现成的一档,不另造颜色。
*/
private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f
/**
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 下划线 / 等宽底色。
*
* **为什么不拼一串 `Text` 节点**:一条气泡本来就是**一整段可选中的文本**([SelectionContainer]
* 的行为一个字都不用改),折行位置也由同一个排版器一次定完——拆成多个节点会让可选性与折行
* 各自为政。`<br>` 已经不在这一层(它是气泡分隔符,切在解析层),所以这里不再有「插一个换行」
* 这种活。
*
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体/装饰),所以非 `<code>` 的片段与原来逐像素相同。
*
* `u` 落到 `TextDecoration.Underline`(六项白名单里的那一项,见 `InlineMarkup.kt` 的文件头);
* `s` 不在白名单里,所以这里**没有**删除线分支——写 `<s>甲</s>` 时那四个字符是按字面显示的正文,
* 走的是 `runs` 为空的那条路(`BubbleBody` 里的裸 `Text`)。
*/
private fun inlineBody(
runs: List<InlineRun>,
codeBackground: Color,
trailingCursor: Boolean,
): AnnotatedString = buildAnnotatedString {
for (run in runs) {
val code = InlineStyle.Code in run.styles
withStyle(
SpanStyle(
fontWeight = if (InlineStyle.Bold in run.styles) FontWeight.Bold else null,
fontStyle = if (InlineStyle.Italic in run.styles) FontStyle.Italic else null,
textDecoration = if (InlineStyle.Underline in run.styles) TextDecoration.Underline else null,
fontFamily = if (code) FontFamily.Monospace else null,
background = if (code) codeBackground else Color.Unspecified,
),
) {
append(run.text)
}
}
if (trailingCursor) append(CURSOR)
}
/**
* 气泡里的图片(缩略)。
*
@@ -707,13 +1248,17 @@ private fun BubbleBody(
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
*
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
* 它排在这条链的**最前面**:那一层留白在 `widthIn` / `clip` / `clickable` 之外,所以图片自己的
* 尺寸上限、圆角与点击区都与加它之前逐像素 / 逐行为相同(与 [BubbleBody] 同一条纪律)。
*/
@Composable
private fun ChatImage(model: String, onClick: (String) -> Unit) {
private fun ChatImage(model: String, onClick: (String) -> Unit, modifier: Modifier = Modifier) {
AsyncImage(
model = model,
contentDescription = "查看图片",
modifier = Modifier
modifier = modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp))
.clickable { onClick(model) },
@@ -0,0 +1,74 @@
package top.yeij.cyrene.ui.chat
import android.graphics.BitmapFactory
import android.media.ExifInterface
import java.io.ByteArrayInputStream
/**
* 从本端持有的那份字节里**现读**出来的图片事实:像素与拍摄日期。读不到的就是 `null`。
*
* ## 为什么这一层不在 `domain/` 里
* [PreviewMetadata][top.yeij.cyrene.domain.chat.PreviewMetadata] 是纯函数(JVM 单测里逐档穷举),
* 而这里调的两个都是 Android 平台类(`android.graphics.BitmapFactory` / `android.media.ExifInterface`),
* 在 JVM 单测里是**抛异常的 stub**。所以切口落在这里:读平台 → 得到两个可能为 `null` 的数 →
* 交给纯函数组装成那一行。判断「显示什么」的逻辑一个字都不在平台类里,测试因此能覆盖全部档位。
*
* ## 分辨率为什么用 `inJustDecodeBounds`
* 为了一行小字把 20 MiB 的大图解成位图是不可接受的(内存 + 主线程耗时)。`inJustDecodeBounds`
* 只读文件头,`outWidth` / `outHeight` 就是答案,`decodeByteArray` 返回 `null` 是有意的。
*
* ## EXIF 用的是**系统 API**,不引新依赖
* `android.media.ExifInterface`(`InputStream` 构造自 API 24 起可用,本工程 `minSdk = 33`)。
* 它认不出的容器(PNG / GIF / WebP / BMP 这些没有标准 EXIF 的格式)只会让 `getAttribute` 回
* `null`——**那正是「拿不到就整项不缺席」要的答案**,不是错误。⚠️ 它**只读 JPEG 与 RAW**,
* 所以「所有图片都有拍摄日期」是错的预期,能报出来的基本只有相机/相册里的 JPEG。
*
* ## 日期为什么不在这里格式化
* EXIF 的 `DateTimeOriginal` 是**没有时区**的墙钟字符串(`yyyy:MM:dd HH:mm:ss`),本层**原样**
* 交给 [PreviewMetadata.formatExifDateTime][top.yeij.cyrene.domain.chat.PreviewMetadata.formatExifDateTime]
* 去严格校验与格式化——那一步是纯函数,能逐档单测,也能把「不做时区换算」这条取舍写在明面上。
*/
data class PreviewImageFacts(
val width: Int? = null,
val height: Int? = null,
/** EXIF `DateTimeOriginal` 的原始字符串;缺席即 `null`。 */
val capturedAt: String? = null,
)
/**
* 读出这两个事实。**任何一步失败都只是少一项**(`runCatching`):这一行是锦上添花,
* 它自己绝不许成为「图片打不开」的原因。
*
* 只从**已经在内存里**的那份字节读([top.yeij.cyrene.data.repository.CachedDocument.bytes],
* 预览与两条出口用的都是它),不再碰盘、不再联网。
*/
fun readPreviewImageFacts(bytes: ByteArray): PreviewImageFacts {
val bounds = readBounds(bytes)
return PreviewImageFacts(
width = bounds?.first,
height = bounds?.second,
capturedAt = readCapturedAt(bytes),
)
}
/**
* 文件头里的像素:任一侧 ≤ 0(解不出来 / 不是图片)就是 `null`。
*
* 与 `ZoomableImage` 里 Coil 报上来的那个尺寸是两回事:那个是**解码后**的位图尺寸,要等图片
* 真的解出来才有;这里是**打开弹层时立刻**就有(只看文件头)。两者一致是常态,不一致时以这里
* 为准——它描述的是文件本身。
*/
private fun readBounds(bytes: ByteArray): Pair<Int, Int>? = runCatching {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
val width = options.outWidth
val height = options.outHeight
if (width > 0 && height > 0) width to height else null
}.getOrNull()
/** EXIF 的 `DateTimeOriginal`;没有这个标签 / 不是 EXIF / 读不动,一律 `null`。 */
private fun readCapturedAt(bytes: ByteArray): String? = runCatching {
ByteArrayInputStream(bytes).use { stream ->
ExifInterface(stream).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
}
}.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
@@ -0,0 +1,134 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.allClosed
/**
* **连接重建**(不是第一次接上)时把本端的「进行中」一次性收口。
*
* ## 它修的是什么(主人 2026-10-06 报的现场)
* 后端卡在半路(回合在跑、或工具在跑)时被**重启**:那一轮在服务端**已经不存在了**,
* 所以本端**永远不会**再收到它的任何收口帧(`turn.end` / `stream_end` / 助手落库行)。
* 而本端「进行中」的每一条闸都只认**帧**:
*
* - 灰态(④「已读,正在回答…」)的唯一闸是 [top.yeij.cyrene.domain.chat.TurnBinding.closed],
* 它只由 `turn.end` / 同 `turnId` 的助手落库行驱动(`ChatViewModel.onEvent`);
* - 「思考中…」/「执行 X…」的唯一闸是流式路自己的 `thinkingActive` / `active` / 工具卡的 `phase`
* (收口只发生在 `stream_end` / 助手落库行);
* - 底栏那句「正在思考…」的唯一闸是 `session_busy` / `session_idle` 报的 [ChatUiState.busyMode]。
*
* 三条路都断在「没有帧」上 ⇒ **没有任何一条会自己收口**,界面于是永久停在那一格:气泡一直灰着、
* 卡片一直转、底栏一直说她在思考。三条里今天唯一被兜住的是 `session_idle` / `session_interrupted`
* 那两帧(`allClosed()` 的两个既有调用点)——而**后端重启时它们永远不会到**(说这话的进程已经没了)。
*
* 还有一处**不显眼但很要命**的连带:那条永远 `active` 的流式路永远是消息列表的**末项**
* ([ChatUiState.items] 的最后一项,`reverseLayout` 下就是**屏幕最下方**),而 `ChatScreen` 的
* 「跟随底部」判的正是 `items.lastOrNull()?.key`(`ChatScreen.kt:508`)——末项不再变化 ⇒
* **此后新到的消息不再把列表滚到底**,于是「最新一条」永远停在那一张转着的卡上。用户看到的就是
* 「拉不到新消息」(消息其实进得来,是屏幕不再跟着它走)。
*
* 收口之后这张卡变成**惰性**的(不再转、不再声称在思考):它仍留在列表里(正文一个字都不删,
* 见下面「不许碰的东西」),直到它自己那一路的落库行到达、或**下一轮** `message.start` 把它当作
* 「已结束的路」丢掉(`TurnStreams.beginLeg`)——那时末项恢复推进,跟随底部也就回来了。
* 这条残留如实登记:收口**不**替这张卡删内容,所以它自己要等一个后续事件才消失。
*
* ## 触发时机:只做「连接重建」((a)),**不做静默超时**((b))
* 判据落在 [isReconnect] 上,也就是 `WsState` **非 Connected → Connected** 的那一跳。三条理由:
* 1. **确定性**:后端重启必然重建连接,而重建连接**必然**让 `WsState` 走一遍 `Reconnecting →
* Connected`(`ChatSocket.runConnectionLoop`)。这条判据是**事件驱动**的,不需要时间常量;
* 2. **可验证**:假后端 `POST /__control/ws-close`(或直接重启进程)就能复现,设备上判据是
* 「一条已在飞的回合 + 一次真踢」,不依赖「等多久」这种主观量;
* 3. **与既有纪律一致**:本项目自 W38 起就定死「状态驱动,不按时间轮」(见
* `ChatViewModel.retryFirstPage` 的 KDoc),(b) 会在同一张表上引入第二个口径。
*
* (b)(某一路 N 秒没有任何帧就认为它结束了)**刻意没做**,代价与风险如实记在这里,留主人拍板:
* - 它要多一个**时间常量**,而那个数只能拍:太小会把「正常但慢」的回合(长思维链、长工具执行、
* 服务端排队)误杀成已结束,太大则回到「卡住很久」的老样子;
* - 它能在**连接没断**时兜底(后端进程活着但编排器死锁、或帧被中间设备悄悄丢掉),而 (a) 兜不住
* 这一档;但这一档今天**没有现场证据**(主人报的是「重启后端」,那是 (a)),按「先修有证据的」
* 不动它;
* - 两者叠加不会互相破坏(都是幂等收口),真要做时**先做 (a)、再把它当第二级**即可。
*
* ## 「第一次连接」不算重建
* 冷启动的 `Idle → Connecting → Connected` 必须**一个字节都不收口**,两条路都会踩到:
* - **刚进会话的 VM**:它订阅时连接可能**已经是** Connected,于是它看到的第一次 Connected 与
* `history` / `turn_begin` / `steer_ack` 那一批首帧**几乎同时**到达(帧走 `socket.events`、
* 状态走 `socket.state`,两条流各自投递)。这时候本端手里可能**已经有**一条正当地灰着的气泡
* (另一台设备起的那一轮),把它当成重建收口就是**在用户刚打开会话的瞬间**凭空把那一轮说成已答;
* - **冷启动**:`Idle → Connecting → Connected` 是每一个会话的第一跳,与「后端重启过」毫无关系。
*
* 所以判据必须是「**曾经**到过 Connected 之后又回到 Connected」,而不是「这一次是不是 Connected」。
*/
internal fun isReconnect(previous: WsState, current: WsState, everConnected: Boolean): Boolean {
// 不是「接上了」,谈不上重建(Connecting / Reconnecting / Offline / Idle 都不收口)。
if (current != WsState.Connected) return false
// 本 VM 见过 Connected ⇒ 这一次接上必然是**接回来**。这一条是主判据,而且**不受状态流合流影响**
// (理由与「为什么是一位记忆而不是看上一跳」见 `ChatViewModel.everConnected` 的 KDoc)。
if (everConnected) return true
// 没记过账时的第二证据:上一跳是**重连退避**(`ChatSocket.runConnectionLoop` 只在断开之后写
// `Reconnecting`;首次握手走的是 `Connecting`)。这一档只在一处是真的:**本 VM 是在离线期间
// 建起来的**(进次级会话时连接就是断的)⇒ 它自己的第一次 Connected 确实发生在一次重建之后。
// 它手里那时**什么都没有**(没有 turn / 没有流式路 / 没有 busyMode),所以 [settledAfterReconnect]
// 对它是恒等变换——判在这里不会误伤任何东西,反过来说也不必为它加第二条口径。
return previous == WsState.Reconnecting
}
/**
* 连接重建时把本端「进行中」的五个面一次性收口(纯函数,VM 与测试共用)。
*
* 收口的是**服务端那一轮留下来的本端投影**,一件一件说清它为什么该收:
* - [ChatUiState.busyMode]:「正在思考…」是**服务端报的**事实(`session_busy`)。旧连接没了,
* 这个事实就**不可知**了;继续显示它等于替服务端保证一件已经问不到的事;
* - [ChatUiState.activeTurns]:同上(「并行 N 路」);归 `0` = 「不知道」,正是
* [ChatUiState.activeTurns] 文档里那个「不知道」的取值;
* - [ChatUiState.queueDepth]:「已排队 N 条」也是服务端队列的事实,与 `session_idle` / 被打断
* 那两处的处置**逐字一致**(那两处也是 `busyMode = null, queueDepth = 0` + 全部收口);
* - [ChatUiState.turns] ⇒ `allClosed()`:未收口的绑定全部标 `closed`(气泡 ④ → ⑤);
* - [ChatUiState.streams] ⇒ `allFinished()`:每一路停止生长(思维链不再「思考中」、工具卡不再
* 「执行 X…」),**正文一个字都不删**。
*
* ## 收口之后**不许**碰的东西(可降级;也是本函数的红线)
* [ChatUiState.messages] / [ChatUiState.pending] / [ChatUiState.queuedKeys] /
* [ChatUiState.queuedForConnection] / 草稿与附件 / [ChatUiState.error] / [ChatUiState.sessionGone]
* **一律原样**:收口只改「进行中的外观」,**不删消息、不整表重拉、不给用户多加一条横幅**。
* 绑定表本身也**不删**(只标 `closed`)——并行路角标(`legBadgeForMessage`)靠它,删了等于事后
* 篡改历史。于是写死一条纪律:**收口是可降级的**,最坏的结果只是「少灰一会儿」。
*
* ## 「闪动」:收口会不会被随后到达的帧翻回进行中(如实回答)
* - 灰态**不会**:[top.yeij.cyrene.domain.chat.TurnBinding.closed] 是**单调**的既成事实
* (`withTurnBegin` 合并时也**保持原值**,见那里第 2 条),所以「已答」之后不会退回「回答中」。
* - 流式卡片**会**:那一帧正文/思维链真的又来了,`withLeg` 就会把它重新变成 `active` /
* `thinkingActive` —— 这是**如实**的(它确实又在生成了),而且是**自愈**的:正文不会因为
* 本端收过一次口而丢字(收口不删内容,后续 delta 照旧追加到同一路)。
* - `busyMode` **会**:服务端下一次 `session_busy` 一到就恢复。
*
* 也就是说:**代价落在「服务端那一轮其实还活着」(只是连接抖动过)这一档**——本端会提前一点点
* 把气泡显示成已答。这个代价是刻意选的,因为它比另一个方向小得多:今天的行为(永远灰着、
* 卡片永远转、底栏永远说她在思考、跟随底部永远失效)是**永久坏掉**,且用户唯一的出路是退出重进。
*
* ## 一条如实登记的行为变化
* 清掉 [ChatUiState.busyMode] 会**连带**改变下一次发送的声明:`ChatViewModel.emitAndDeliver` 用
* `busyMode != null` 算 `interruptScopeForSend` / `parallelForSend`(`ChatViewModel.kt:1793`)。
* 于是「她忙时发消息」偏好选「打断」的那一档,在重建后的**第一次**发送不再先发 `interrupt`。
* 如实说:连接重建之后本端**确实不知道**那一轮还在不在,而不打断的后果(这一条被服务端排在后面
* 或并行处理)比对着一个已经不存在的回合发 `interrupt` 小得多。服务端下一次报 `session_busy`
* 就恢复原样。
*/
internal fun ChatUiState.settledAfterReconnect(): ChatUiState {
val closedTurns = turns.allClosed()
val finishedStreams = streams.allFinished()
// 没有任何「进行中」可收时返回**同一个实例**:每次「接回来」都会调它一次,而无变化时制造新
// 实例会让 StateFlow 白白发一轮(也让测试里的 `assertSame` 失去意义)。
if (closedTurns === turns && finishedStreams === streams &&
busyMode == null && activeTurns == 0 && queueDepth == 0
) {
return this
}
return copy(
busyMode = null,
activeTurns = 0,
queueDepth = 0,
turns = closedTurns,
streams = finishedStreams,
)
}
@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -23,12 +24,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
import top.yukonga.miuix.kmp.basic.Text
@@ -253,6 +260,17 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
* 输入框与按钮原地不动。上限按状态取([sharePreviewMaxHeight]):收起态
* [SHARE_PREVIEW_MAX_HEIGHT]、用户展开后 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
*
* ## 正文走**与气泡同一套解析**(W65),所以这一窗里排的是「气泡块」而不是一段原样的字
* 正文不是原样铺进去的:它先过 [top.yeij.cyrene.domain.parse.parse]——与 `MessageBubble` 里那条
* 完全同一个函数、同一个口径([sharePreviewBlocks] 只是给结果加了一道兜底)。于是在这里看到的就是
* 发出去之后气泡里的样子:每个 `<p>…</p>`(无标签时按空行分段)是**一个独立的块**,
* `<action>` / `<narration>`(以及星号那几种写法)不当正文、而是**灰斜体小字**的旁白块,标签本身
* 一个字符都不显示。
*
* 这一层修掉的是 W61 审查发现的一处「所见非所发」:预览此前把正文 `trim` 之后原样显示、**不过解析
* 器**,而发送后的气泡过解析器——分享一段 `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩
* 「正文」**。用户按发送之前看到的不是他将要发出的东西。
*
* ## 正文给三行,但三行之外是「滚」不是「省略号」
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
* 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短
@@ -265,10 +283,10 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
*
* ## 三行读不下时,再给一次「展开」
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行**
* (W56 拍板:滚动与展开**两个都要**)。所以正文排下来超过三行时(判据是
* [sharePreviewTextOverflowsWindow],正好三行不给)卡片右上一枚「展开」把这一窗放到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。没超过三行时这一枚**不出现**——
* 短正文上摆一枚点了没反应的「展开」比没有更糟。
* (W56 拍板:滚动与展开**两个都要**)。所以这一窗**装不下**里面那几块时(判据是
* [sharePreviewTextOverflowsWindow]:量出来的内容自然高度比窗口高,正好放满不给)卡片右上一枚
* 「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。装得下时这一枚
* **不出现**——短正文上摆一枚点了没反应的「展开」比没有更糟。
*
* 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限,
* 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。
@@ -316,8 +334,8 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
}
if (preview.hasText) {
// 「超没超过三行」只有排完才知道(折行位置由字号与系统的大字体缩放决定),所以它由正文
// 本体量出来之后回调上来,标签这一行凭它决定要不要给出展开控件。
// 「装不装得下」只有排完才知道(折行位置由字号与系统的大字体缩放决定,块数与旁白行数更是
// 解析出来的),所以它由正文那一窗量出来之后回调上来,标签这一行凭它决定要不要给出控件。
var bodyOverflows by remember(preview.text) { mutableStateOf(false) }
Column(
modifier = Modifier
@@ -385,20 +403,70 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
)
}
/** 相邻两个气泡块之间的间距(见 [ShareConfirmBody])。块与块靠这一层间距分开,不靠气泡底色。 */
private val SHARE_PREVIEW_BLOCK_GAP = 6.dp
/** 一个块里旁白行与正文行之间的间距(气泡里那两行也挨得很近,与 `AnnotationLine` 的 2dp 同源)。 */
private val SHARE_PREVIEW_LINE_GAP = 2.dp
/**
* 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
* 用户点了「展开」之后这一窗撤掉自己的上限,整段正文交给外层预览区那一层滚(W56)。
* 正文那一窗:**高度恒为三行、再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
* 用户点了「展开」之后这一窗撤掉自己的上限,全部内容交给外层预览区那一层滚(W56)。
*
* 里面排的**不是一段原样的字**,而是 [sharePreviewBlocks] 给出的那几个「气泡块」(W65 起走与
* `MessageBubble` 同一个 [parse])。每个块自己排旁白行与正文行,块与块之间留
* [SHARE_PREVIEW_BLOCK_GAP]。
*
* ## 为什么量行高,而不是写一个 dp 常量
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
* 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。
* 所以这里改为**量**:任一排版结果都能给出「一行多高」(`getLineBottom(0) - getLineTop(0)`),
* 窗口高度 = 它 × [SHARE_PREVIEW_TEXT_LINES]([sharePreviewWindowHeight])。
*
* 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
* 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,高度不受限,两种状态下排出来的行位置一样)。
* **为什么不再取「第三行的底边」**(那是 W65 之前的量法):那时这一窗里只有一个 `Text`,
* `getLineBottom(2)` 就是那一个文本的三行底。现在里面是**多个块**,每块还有自己的旁白行与间距——
* 在任何一个块上取「第三行」都不再是这个窗口的第三行。行高则是唯一一个跨块也成立、且与块数无关的
* 量:窗口恒为「三行字高」,块与旁白占掉多少就从这三行里吃掉多少(这正是它的语义)。
*
* ## 「装不下」是**量**出来的,不是**数**出来的(W65 换掉的判据)
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > 3`)。多块之后行数不再是一个数得出来的量:
* 旁白行与块间距都占高度,却不属于任何一块的「行数」;字号缩放与折行位置也全落在行数之外。按行数算,
* 两块各三行加起来是 6、窗口只装得下 3 行——这条能对,但「一块三行 + 一条旁白 + 一层间距」这种更常见
* 的形状就数不准了,而这恰是解析之后最常见的形状。
*
* 所以现在比的是**高度**:内容排下来的自然高度 vs 这一窗的高度([sharePreviewTextOverflowsWindow])。
* 两个量都是排完才知道的:
* - 窗口高由上面的行高乘三给出;
* - 自然高由 [androidx.compose.ui.layout.onSizeChanged] 读到——它挂在 `verticalScroll` **里面**,
* 而 `verticalScroll` 给子项的是无界高度,所以读到的是内容的**自然高度**(不受这一窗上限影响的那
* 一个,收起态与展开态读到的是同一个数)。
*
* 「自然高度 > 窗口高度」就是「窗里还有东西没露出来」,正是那枚「展开」承诺的事;旁白、块间距、折行、
* 系统大字体缩放全被算在同一把尺子上——它可自证的地方在于:两边都是这一帧真实排出来的像素,没有一处
* 是我们估的行数或猜的 dp。
*
* ⚠️ 这个比较在**组合期**算完、由 [SideEffect] 报上去,**不**在排版回调里算:两个量都来自排版回调,
* 而在回调里读到的是**上一帧**组合里的值(`contentHeight` 与 `cap` 都不在本帧的组合里),在那里直接比
* 会永远差一帧——首帧报一次「不超窗」之后就再也不会报,长正文上那枚「展开」永远不出现。组合期读到的
* 一定是最新的一帧,代价只是一次幂等的重复上报([SideEffect] 每次组合都跑,值没变就是空写)。
*
* ## ⚠️ 这一层绝不能拿到 [Dp.Infinity](W58 修的崩溃)
* `heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` 一旦在**无界高度**下被测量,
* Compose 的 `checkScrollableContainerConstraints` 会**直接抛 `IllegalStateException` 把 App 打崩**
* (真机复现:分享进来、面板刚打开就闪退,栈是 `ScrollNode.measure` ← `SizeNode.measure` ← `DialogLayout`)。
*
* 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它给出的**恒是一个有限值**;
* 而「这一层干脆不设上限」只发生在展开态——那时它连 `verticalScroll` 一起去掉(见下一节),
* 于是「无界高度 + 滚动」这个组合在两种状态下都不成立:
* - **首帧**(还没量到)用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 有限,且正好是收起态外层预览区的上限;
* - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层
* 有限的约束在管事(不是「没有约束」)。
*
* 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,块的内容、宽度
* 与样式都没变,行高与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行高一样)。
*
* ## 展开态为什么干脆不留这一层上限
* 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
@@ -407,16 +475,19 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。
* 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。
*
* ## 首帧的那次回流:留着,且确认它看不见(W56 复核)
* 上限是**首帧之后**才量到的,所以长正文会先按没有上限排一次,下一帧才收到三行。上一批留了个悬念:
* 真机上会不会看见一次跳动。复核的结论是**看不见**,两条理由:
* ## 首帧的那次回流:留着,且确认它看不见
* 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次,
* 下一帧才收到三行。真机上**看不见**跳动,三条理由:
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
* 2. 加展开态**不会把它变明显**:展开态本来就没有这一层上限(见上),也就没有「量完再回落」这一步;
* 而展开 / 收起是用户主动的一次布局变化,那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的
* 是展开控件本身——它要到第二帧才出现(行数是排完才知道的),但这一枚也顶不动东西:标签行里那
* 两句话用的是同一个 `footnote2`,行高不变。
* 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化,
* 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现
* (自然高度是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,
* 行高不变。
* 3. 首帧那一次比出来的是「自然高度 vs 150dp 兜底」而不是「vs 三行」:那一帧的结论**可能是错的**
* (一段四五行的正文在 150dp 下看着「装得下」),但它在下一帧就被纠正,而这一枚控件本来就要到
* 第二帧才可能摆上——所以这条错判永远看不见。
*
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
@@ -425,8 +496,13 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
* ## 两层滚动怎么分工
* 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
* 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
* 就走嵌套滚动,不用额外接线。展开态下这一窗没有可滚的余量(`heightIn` 给了 [Dp.Infinity]),
* 手势整个交给外层。
* 就走嵌套滚动,不用额外接线。
*
* ⚠️ **展开态必须把这一层整个去掉(上限 + 滚动一起),只留外层那一层滚。** 只去掉上限是不够的 ——
* 外层 [ShareConfirmPreviews] 自己就是一个 `verticalScroll`,它给内容的是**无界高度**;这一层若
* 留着 `verticalScroll`,那个 scroll 就会在无界高度下被测量 ⇒ 抛同一个
* `IllegalStateException`(W58 真机在「点展开」的那一下崩了第二次,就是这么来的)。
* 展开后读不读得到尾,只取决于外层那一层。
*/
@Composable
private fun ShareConfirmBody(
@@ -435,33 +511,124 @@ private fun ShareConfirmBody(
onOverflowChange: (Boolean) -> Unit,
) {
val density = LocalDensity.current
var cap by remember(text) { mutableStateOf(Dp.Infinity) }
val blocks = remember(text) { sharePreviewBlocks(text) }
// `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。
var cap by remember(text) { mutableStateOf<Dp?>(null) }
// 内容排下来的**自然高度**(见 KDoc:量在 `verticalScroll` 里面,拿到的是不受上限影响的那一个)。
var contentHeight by remember(text) { mutableStateOf(0) }
// 无条件 `remember`,避免在分支里建状态;滚动位置由这一窗自己的生命周期管。
val scroll = rememberScrollState()
val window = if (expanded) {
// 展开态:上限与滚动**一起**去掉(见 KDoc)。内容自然撑开,交给外层预览区那一层滚。
Modifier
} else {
Modifier
.heightIn(max = shareConfirmWindowCap(cap))
.verticalScroll(scroll)
}
// 一行有多高:正文行与旁白行同为 `footnote1`,所以第一个排完的文本就能给(不限是哪一块的哪一行)。
// 量到一次就定死——`cap` 只在这一个分支里被写、且只从 `null` 变成一个值,不会来回跳。
val measureLine: (TextLayoutResult) -> Unit = { layout ->
if (cap == null && layout.lineCount > 0) {
val line = layout.getLineBottom(0) - layout.getLineTop(0)
// 0 或负数(还没排稳)不记:150dp 的兜底比一扇零高的窗好(同 [shareConfirmWindowCap] 的取向)。
if (line > 0) cap = sharePreviewWindowHeight(with(density) { line.toDp() })
}
}
Column(
modifier = Modifier
.fillMaxWidth()
// 展开态给 [Dp.Infinity] = 这一层没有上限;这正是**首帧那个状态**(`cap` 一开始就是它),
// 所以展开态不多一条代码路径,也不会比首帧更糟。
.heightIn(max = if (expanded) Dp.Infinity else cap)
.verticalScroll(rememberScrollState()),
.then(window)
.onSizeChanged { contentHeight = it.height },
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_BLOCK_GAP),
) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
onTextLayout = { layout ->
if (cap == Dp.Infinity && layout.lineCount > 0) {
// 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
}
// 行数与上限无关(这一窗没有 `maxLines`、测量高度不受限,排一次行数就定了),所以
// 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。
onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount))
},
)
blocks.forEach { block -> ShareConfirmBlock(block, measureLine) }
}
// 见 KDoc:在组合期算完、由 SideEffect 报上去(两个量在排版回调里读到的是上一帧的值)。
val overflows = sharePreviewTextOverflowsWindow(
contentHeightPx = contentHeight,
windowHeightPx = with(density) { shareConfirmWindowCap(cap).roundToPx() },
)
SideEffect { onOverflowChange(overflows) }
}
/**
* 预览里的**一个气泡块**:上依附的旁白 + 正文 + 下依附的旁白。
*
* 这个顺序就是 `MessageBubble` 里那个 `Bubble` 的顺序(`head` → 正文 → `tail`),用的也是同一个
* [parse] 给出的同一批块——「预览里长这样」与「发出去之后长那样」因此是**同一套判据**的两次渲染。
*
* 块与块之间只留一层间距(外层 [ShareConfirmBody] 的 `spacedBy`),**不画气泡底色**:这一栏是面板里
* 的一张卡片(`surfaceContainer` 底),而气泡底色在聊天里表示「谁说的」——用户那条是 `primary` 色、
* 她那侧是灰。在确认面板里复刻一层假气泡底色会让人以为这里已经是聊天记录;而且那层内边距要从三行窗
* 里再吃掉近一行,正是 W54D 之后最不该动的那点可见正文。
*/
@Composable
private fun ShareConfirmBlock(block: ParsedBubble, onTextLayout: (TextLayoutResult) -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_LINE_GAP),
) {
ShareConfirmAnnotation(block.head, onTextLayout)
if (block.body.isNotEmpty()) {
Text(
text = block.body,
style = MiuixTheme.textStyles.footnote1,
onTextLayout = onTextLayout,
)
}
ShareConfirmAnnotation(block.tail, onTextLayout)
}
}
/**
* 预览里的旁白行:**灰斜体小字**——与气泡里那行(`MessageBubble` 的 `AnnotationLine`)同一个形态、
* 同一种拼法(多个片段用一个空格连起来),字全是 [parse] 给的那几个 `ContentPart` 的原文。
*
* ⚠️ 这里**不**读 `LocalAppPrefs.showNarration`(气泡那行会读)。那个偏好管的是**聊天记录里**要不要
* 显示旁白,而这块面板回答的是「她将收到的是什么」:按那个偏好把旁白藏起来,就等于让用户在按下发送
* 之前看不到这次要发出去的一部分内容——而 W61 审查发现的那处「预览里是标签、气泡里只剩正文」,错的
* 正是同一件事(看到的不是要发的)。要改成与气泡一样随时可以,但那是主人拍板的事,不在这里顺手做。
*/
@Composable
private fun ShareConfirmAnnotation(parts: List<ContentPart>, onTextLayout: (TextLayoutResult) -> Unit) {
val text = parts.joinToString(" ") { it.text }.trim()
if (text.isEmpty()) return
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
fontStyle = FontStyle.Italic,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
onTextLayout = onTextLayout,
)
}
/**
* **收起态**那一窗的高度上限(**纯函数**,可单测)。
*
* 量到过就用量的,还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 兜底必须是**有限值**,因为
* `heightIn` 与 `verticalScroll` 就在同一串 modifier 上。
*
* 展开态**不走这里**:那一态连 `verticalScroll` 一起去掉(只留外层预览区那一层滚),没有「上限」
* 这个维度可给 —— 见 [ShareConfirmBody] 的 KDoc。
*
* ⚠️ 返回值**永远是一个有限值** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而
* `verticalScroll` 在无界高度下被测量时 Compose 会直接抛 `IllegalStateException` 把 App 打崩
* (W58 真机崩过两次:一次是初值用了 `Dp.Infinity`,一次是展开态只去掉了上限)。这条不变量有单测钉着。
*
* 所以非有限的那几个取值([Dp.Infinity] 与 [Dp.Unspecified],后者是 `NaN`)**与「还没量到」同等对待**,
* 一律回落 [SHARE_PREVIEW_MAX_HEIGHT]:这条 KDoc 写的是一条**判据**,而不该只是对调用方的请求——本函数
* 是这一层唯一的出口,它自己要能保证「给出去的上限总是有限」。今天唯一的调用方传的是 `null` 或
* [sharePreviewWindowHeight] 的结果(正数 × 三,恒有限),所以这一道守卫不改变任何现状;它挡的是
* **下一次**有人顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。
*/
internal fun shareConfirmWindowCap(measured: Dp?): Dp =
measured?.takeIf { it.value.isFinite() } ?: SHARE_PREVIEW_MAX_HEIGHT
/** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
@@ -486,8 +653,7 @@ internal val SHARE_PREVIEW_EXPANDED_MAX_HEIGHT = 240.dp
/**
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
*
* 它同时是「要不要给出展开控件」的那条线([sharePreviewTextOverflowsWindow]):正好这么多行就没有
* 东西可展开。
* 它同时是窗口高度那笔账里唯一的数:窗口高 = **量到的行高** × 这个数([sharePreviewWindowHeight])。
*
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
@@ -504,18 +670,67 @@ internal const val SHARE_PREVIEW_EXPAND_LABEL = "展开"
internal const val SHARE_PREVIEW_COLLAPSE_LABEL = "收起"
/**
* 正文**排下来**超过三行窗口了吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
* 三行窗口有多高(**纯函数**,可单测):量到的一行高度 × [SHARE_PREVIEW_TEXT_LINES]。
*
* 判据是**行数**,不是 `hasVisualOverflow`:正文这一窗的裁剪落在它外面那层 `Column` 的
* `heightIn(max = cap)` 上,而这一窗自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的
* 就是无上限约束),所以 `Text` 自己**永远**报「没有溢出」——拿它当判据会在最长的那种正文上漏掉
* 控件。行数则与上限无关:只要排下来比窗口多一行,第四行就一定在窗口之外,展开就有东西可看。
*
* 边界是**严格大于**:正好三行时整段都在窗里(`getLineBottom(2)` 就是这一窗的底),此时给一枚
* 「展开」是点了没有反应的假控件。
* 行高只能是**量**出来的(见 [ShareConfirmBody] 的「为什么量行高,而不是写一个 dp 常量」),所以这里
* 只做乘法、不掺任何 dp 常量:大字体档下量到的行高更大,窗口就跟着更高,「三行」始终是三行。把这个数
* 留在唯一一处,改它只改 [SHARE_PREVIEW_TEXT_LINES]。
*/
internal fun sharePreviewTextOverflowsWindow(lineCount: Int): Boolean =
lineCount > SHARE_PREVIEW_TEXT_LINES
internal fun sharePreviewWindowHeight(lineHeight: Dp): Dp =
lineHeight * SHARE_PREVIEW_TEXT_LINES
/**
* 预览正文区该渲染成哪几块(**纯函数**,可单测)——**走的就是气泡那一套解析**(W65 起)。
*
* 用的是 `MessageBubble` 里那同**一个** [parse],连兜底都是同一条:解析结果为空(或全是被丢掉的空块)
* 时回落到「整段原样显示」的那一块,与那边 `parsed.ifEmpty { listOf(plainBubble(content)) }` 逐字同义。
* 所以「预览里看不到那一串标签」不是本函数自己剥出来的,而是 [parse] 的既有语义(落单标签一律剥掉、
* 完整块切气泡、旁白另成一类)——这里一个字符都不改写、也不另写一套「去标签」。
*
* **兜底不是可选项**:`parse` 对某些输入会回空列表(纯空白是既有的一档,落单标签被剥光是另一档,
* 见 `ShareTextPipelineTest` 里的 `<p><p>` 那几条),而面板的 [ShareConfirmPreview.hasText] 只管
* 「正文非空白」,管不到解析结果。少了这条兜底,用户就会在一块**空白**前面按发送——而正文这一栏是他按
* 发送之前唯一能核对的东西。
*
* 正文在这里**不再 trim**:两侧空白早在 [shareConfirmPreview] 那一站去过一次(`share.text.trim()`,
* 与 `sendShared` 开头同一个口径),这里再来一次只会多一个说法不一的出处。
*/
internal fun sharePreviewBlocks(text: String): List<ParsedBubble> =
parse(text).filterNot { it.isEmpty }.ifEmpty {
listOf(ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList()))
}
/**
* 这一窗**装不下**里面的内容吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
*
* ## 判据是**高度**,不是行数(W65 换掉的)
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > SHARE_PREVIEW_TEXT_LINES`)。正文改走解析
* 之后这一窗里是**多个块**,每块还各自带旁白行与块间距,于是行数不再是一个覆盖得住窗口的量:
*
* - 旁白行与块间距都占高度,却谁也不属于哪一块的「行数」;
* - 折行位置、字号与系统大字体缩放都落在行数之外:「两个块各占两行、中间一层间距」这种最常见的形状,
* 按行数只能说「4 > 3」,说不出它到底差多少,也说不出一块内部的折行吃掉了第二块的位置;
* - 反过来也漏:「一块两行 + 一条两行的旁白」数出来是 4 行,但它真正超出的高度取决于那两层间距与
* 旁白自己的行高——行数这个量从头到尾没有把它们算进去。
*
* 所以比的是**两边真实排出来的像素**:内容自然高度(由挂在 `verticalScroll` 里面的
* [androidx.compose.ui.layout.onSizeChanged] 读到,见 [ShareConfirmBody])vs 这一窗的高度
* ([sharePreviewWindowHeight] 那个口径:量到的行高 × 三)。「自然高度 > 窗口高度」**就是**「窗里还有
* 东西没露出来」,正是那枚「展开」承诺的事。它可自证的地方在于:两个量都是这一帧真实排出来的像素,
* 没有任何一处是我们估的行数或猜的 dp。
*
* 也不是 `hasVisualOverflow`:裁剪落在这一窗外面那层 `Column` 的 `heightIn(max = cap)` 上,而每个
* `Text` 自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的就是无上限约束),所以它们永远报
* 「没有溢出」——拿它当判据会把控件在最长的那种正文上漏掉。
*
* ## 边界与首帧
* 严格大于:**正好一样高**就是整段都在窗里(窗口底下一条不多),此时给一枚「展开」是点了没有反应的
* 假控件。
*
* 任何一边还没量到(`0`)都回 `false`:首帧摆上一枚点了没有反应的「展开」比晚一帧出现更糟。
*/
internal fun sharePreviewTextOverflowsWindow(contentHeightPx: Int, windowHeightPx: Int): Boolean =
contentHeightPx > 0 && windowHeightPx > 0 && contentHeightPx > windowHeightPx
/**
* 预览区当前该给多高(**纯函数**,可单测):收起态 [SHARE_PREVIEW_MAX_HEIGHT]、展开态
@@ -0,0 +1,43 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.domain.share.ShareTextSource
import top.yeij.cyrene.domain.share.SharedContent
/**
* 「这段正文是从对方给的**分享标题**回落来的」——面板上那句如实的小字(**纯函数**,可单测)。
*
* ## 它补的是哪一处
* 取正文的回落链([top.yeij.cyrene.domain.share.ShareRules.extract]:`EXTRA_TEXT` →
* `EXTRA_SUBJECT` → `EXTRA_TITLE`)让「对方只给了标题」的那次分享**不再静默**——用户可以确认、可以
* 取消。但回落来的那一段与真正文**长得一模一样**:预览里只有一句话时,用户会以为对方发来的就是这句
* 正文,其实本端只拿到了标题。所以这里给出一句**只出现在界面上**的说明。
*
* ## 红线:它绝不进正文
* 这句话是**本端渲染层**的信息,一个字都不许拼进将要发送的那段正文(`shareConfirmBody` 拼的是分享
* 正文 + 用户描述,与这里无关):把来源标记拼进正文 = 让用户正文冒充系统指令,是项目红线
* (见 `ShareRules` 的「为什么正文一个字都不加」)。所以这条消息的内容**永远只是正文本身**,
* 预览里那段字与发出去的那条消息逐字相同。
*
* ## 为什么 `Subject` 与 `Title` 共用一句
* 两档在**数据**上分得很清([ShareTextSource],排查时要看得见对方把内容塞进了哪个字段),但对用户说
* 的是同一件事:标题与主题在他眼里没有区别。各写一句只会多出一处迟早会走样的措辞。
*
* ## 不说「正文」的那一档为什么回 `null`
* [ShareTextSource.Text] 是规范路径(正文本来就是正文):一切照旧,**一个字都不说**——正常落地时
* 说话才是噪声(与 `shareNoticeFor` 的静默边界同一条纪律)。
*
* @return 该说的那句话;没有要说的(正文来自 `EXTRA_TEXT`)回 `null`。
*/
fun shareTextOriginNotice(share: SharedContent): String? = when (share.textSource) {
ShareTextSource.Text -> null
ShareTextSource.Subject, ShareTextSource.Title -> SHARE_TEXT_FROM_TITLE_NOTICE
}
/**
* 那句话说出口的样子。
*
* 两件事要在同一句里说清:**它是什么**(对方给的分享标题)与**缺了什么**(对方没有提供正文)——
* 只说前一半,用户仍会以为这句话就是对方发来的正文;只说后一半,他不知道预览里那行字是从哪冒出来的。
* 刻意**不说**「失败」「错误」:这一次分享的内容一个字节都没丢,只是它本来就只有标题。
*/
const val SHARE_TEXT_FROM_TITLE_NOTICE = "这段文字来自对方的分享标题(对方没有提供正文)"
@@ -88,10 +88,13 @@ fun ToolCard(
FoldCardLabel("参数")
FoldCardBody(tool.arguments)
}
if (tool.output.isNotBlank()) {
// 结果区是否出现**完全**由 [toolResultTextOf] 决定:`null` 就整块不画(连标签和
// 上方那道 Spacer 都不画),于是「摘要缺席」时这一屏与后端补这个键之前**逐字节相同**。
val result = toolResultTextOf(tool)
if (result != null) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("结果")
FoldCardBody(tool.output)
FoldCardBody(result)
}
}
}
@@ -108,6 +108,49 @@ fun List<StreamingLeg>.finishingLeg(
fun List<StreamingLeg>.withoutLeg(bucket: TurnBucket): List<StreamingLeg> =
if (none { it.bucket == bucket }) this else filterNot { it.bucket == bucket }
/**
* **连接重建时收口每一路**(纯函数,[top.yeij.cyrene.ui.chat.settledAfterReconnect] 用它)。
*
* 与 [finishingLeg] 的关系:同一件事(这一路不再生长、思维链与工具卡不再声称自己在跑),
* 区别只在**触发点**——[finishingLeg] 是「收到了这一路的 `stream_end`」,这里没有那一帧,
* 是「连接重建了,而服务端那一轮在旧连接上,永远不会再有后续帧」(判据见
* [top.yeij.cyrene.ui.chat.isReconnect],理由见它那一份 KDoc)。
*
* 两条纪律:
* 1. **正文与思维链一个字都不删**——用户可能正读到一半,而这已经不是服务端能再给一次的东西;
* 2. **没有任何可显示内容的那一路整条丢掉**:`active = true` 而什么都不画时,[StreamingBubble]
* 会画一个只有光标的空气泡,收口它等于把它变成一个**一个像素都不画、却仍占一个列表项**
* (也因此占着「最新一项」这个位置)的幽灵——那种路丢掉是本函数的**唯一**删除动作,
* 而它删的是「本来就没有的东西」。
*
* 无变化时返回**同一个实例**(与 [finishingLeg] / `withoutLeg` 同一条纪律)。
*/
fun List<StreamingLeg>.allFinished(): List<StreamingLeg> {
val next = mapNotNull { it.finishedLeg() }
return if (next == this) this else next
}
/**
* 这一路**收口后**的样子;收口之后**什么都画不出来**时返回 `null`(整路丢掉,见 [allFinished])。
*
* 三件事与 [finishingLeg] 逐字同义:不再 `active`(光标停、兜底路径不再当它是新回合)、
* 思维链不再 `thinkingActive`(标题从「思考中…」变回「思维链」),外加工具卡也一起收口
* ([settledAfterReconnect],标题从「执行 X…」变回「工具调用 · X」)。
*
* ⚠️ `bubbles` / `thinking` / 工具卡的参数与输出**一个都没动**:这是「不再声称在跑」,
* 不是「清空这一路」。
*/
private fun StreamingLeg.finishedLeg(): StreamingLeg? {
val settled = state.copy(
active = false,
thinkingActive = false,
tools = state.tools.settledAfterReconnect(),
)
val anythingToShow =
settled.bubbles.isNotEmpty() || settled.thinking.isNotBlank() || settled.tools.isNotEmpty()
return if (anythingToShow) copy(state = settled) else null
}
/**
* 这条**已落库**消息处在哪一态(六态判定,规范 §1.1)。
*
@@ -0,0 +1,219 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.VoiceDto
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failureOf
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.voiceAudio
import top.yeij.cyrene.domain.chat.voiceDurationLabel
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = 这条画的是语音形态,
* 见 `isVoiceMessage`),不是内容。
*/
const val VOICE_LABEL: String = "语音"
/** 加载中那一句(与 [VOICE_LABEL] 同一行,替换掉时长那一格)。 */
private const val VOICE_LOADING_LABEL = "加载中…"
/** 播放控件那枚图标的读屏名字 —— 同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
const val VOICE_PLAY_DESCRIPTION: String = "播放语音"
/** 正在放 / 加载中时那枚图标的读屏名字(点它 = 停)。 */
const val VOICE_STOP_DESCRIPTION: String = "停止播放语音"
/** 图标尺寸:与消息角标那枚「⋯」同一档,整条语音形态因此不比一条角标高多少。 */
private val VOICE_ICON_SIZE = 18.dp
/**
* 一条语音消息在**转写文本之外**的那个「语音」形态:`[播放/暂停] 语音 · 时长`。
*
* ## 画什么由三条契约判据决定(都在这一个函数里,不散落)
* - **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**:本组件只有在这条消息要画语音形态时
* 才会被调用(调用点在 [MessageBubble],判据是 `isVoiceMessage`),所以这里既不认识正文里有
* 没有 `[语音]` 字样、也不认识 `status`——`failed` 那一态(没有可播的声音)根本不走这条路,
* 它由 `VoiceFallbackNote` 单独画一行灰色小标;
* - **无副本 ⇒ 不画播放控件**([voiceAudio] 回 `null`):四个键(`name`/`mime`/`size`/`url`)
* 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示
* (时长与副本**无关**);
* - **缺席就不显示时长**([voiceDurationLabel] 回 `null`):绝不显示 `0:00` 冒充,也不拿
* `size` 反算。
*
* ## 三处文案各自归谁
* - **正文**(转写 / 服务端的诚实提示)由 [MessageBubble] 照常渲染,本组件一个字符都不改、
* 也不隐藏它;
* - [note](`status = empty` 且正文为空时那句「没有识别出文字」)由调用方用纯函数
* `voiceNote` 算好传进来 —— 本组件不判 `status`;
* - [playback] 里这条消息的失败提示([failureOf])**只在失败时出现一行**,颜色用
* `colorScheme.error`:可见但不夸张(不弹窗、不占整行、不挡住正文)。
*
* ## 图标从哪来(查过 AAR,不是猜的)
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚(`Close` / `Search` / `SearchCleanup` /
* `Sidebar` / `Check` / `ArrowRight` / `ArrowUpDown`),**没有播放 / 暂停字形**;但 **extended**
* 包里有真的 `Play` / `Pause`(`javap` 确认过 `PlayKt.getPlay(MiuixIcons)`),而本模块本来就在用
* extended(`MessageBubble.kt` 的 `MiuixIcons.More`)。所以这里用真的字形,**不**自绘几何图形:
* 自绘一个三角形在扫读上并不比官方图标便宜,还要自己承担「像不像播放键」的风险。
* 官方文档写 `MiuixIcons.Xxx` 的用法对人不对包,以 AAR 为准 —— 这两枚就是 extended 包里的。
*
* ## 点哪儿
* **整枚可点**(不只是那 18dp 的图标):读屏与手指都不必瞄准;图标只是「这里能按」的可见凭据
* (与 [LegBadge] 「看得出可点」同一条思路)。
*
* @param note 正文之外要多说的一句(没有就传 `null`)。
* @param playback 这条通道的当前状态(由调用方用 [rememberVoicePlayer] 那一个实例驱动)。
* @param onToggle 用户点了这枚语音:交给上层真正去取字节 / 放 / 停(本组件不发请求)。
*/
@Composable
fun VoiceMessageRow(
voice: VoiceDto,
messageId: String,
playback: VoicePlaybackState,
note: String? = null,
alignEnd: Boolean = false,
onToggle: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val audio = voiceAudio(voice)
val active = playback.isActiveFor(messageId)
val loading = playback.isLoadingFor(messageId)
val failure = playback.failureOf(messageId)
val duration = voiceDurationLabel(voice.durationMs)
Column(
modifier = modifier,
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.then(
if (audio == null) {
Modifier
} else {
Modifier
.clickable(onClick = onToggle)
// 可点的那一层(整枚)自己报「点它会发生什么」;里面的图标因此只是
// 一笔装饰(`contentDescription = null`),读屏不会读到第二个焦点。
.semantics {
contentDescription = if (active) VOICE_STOP_DESCRIPTION else VOICE_PLAY_DESCRIPTION
}
},
)
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (audio != null) {
Icon(
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier
.size(VOICE_ICON_SIZE)
.clip(CircleShape),
)
}
Text(
text = VOICE_LABEL,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
when {
// 「现在在干什么」与「点它会怎样」分开说:图标回答后者,这一格回答前者。
// ⚠️ 只有**真的在取字节**时才说「加载中…」:`active` 还包含「正在响」,
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」。
loading -> Text(
text = VOICE_LOADING_LABEL,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
duration != null -> Text(
text = duration,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
note?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
failure?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
)
}
}
}
/**
* `status == "failed"` 那条消息**没有语音形态**,但要多说的一句话(`voice` 键在、没有可播的声音)。
*
* ## 为什么这一行必须存在
* 这一态的消息正文照常按文字渲染(入站是服务端那行诚实提示,出站是合成降级后的原文本)。少了这一行,
* 一条「她本想用语音、合成降级成文字」的消息与一条普通文字消息**逐像素相同**——看的人无从知道这条
* 本来是一段声音,也看不出它与别的话有什么不一样。所以这枚小标不是装饰,它是这一态**唯一**的可见
* 凭据([VoiceMessageRow] 在这里根本不画,见 `isVoiceMessage`)。
*
* ## 样式为什么抄 [VoiceMessageRow] 里 note 那一行
* `footnote2` + `onSurfaceVariantSummary`:与语音形态旁边那几句说明(「没有识别出文字」/ 加载中 /
* 时长)**同一档**。这三处说的都是「关于这段声音的一句脚注」,同一个外观才让人读成一类东西;
* 换成正文那一档字级会被误读成消息内容的一部分,换成 `error` 又会把一次**降级**说成一次**失败**
* ——降级本身是服务端做对的一件事(她的话一个字没丢,只是没变成声音)。
*
* ## 不判 `status`,也不拼文案
* [text] 由调用方用纯函数 `voiceFallbackNote` 算好传进来(固定前半句 + 服务端 `fallbackReason` 原样)。
* 本组件只负责画:判据与措辞都在 domain 层单测钉着,这里再判一次就多出「两处判据不一致」的可能
* ——与 [VoiceMessageRow] 对 `note` 的处理同一条纪律。
*
* @param text 已经算好的那一整句(本组件不改一个字符)。
* @param alignEnd 用户侧(气泡在右):整行贴右;否则贴左。
*/
@Composable
fun VoiceFallbackNote(
text: String,
alignEnd: Boolean = false,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
@@ -0,0 +1,349 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failureOf
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.voiceElapsedLabel
import top.yeij.cyrene.domain.chat.voiceReplyNote
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 输入栏那几个**语音**形态(录音中 / 待重发 / 她的语音回复),以及乐观气泡里那一枚「语音」。
*
* ## 为什么单独一个文件
* 它们四个共用同一套尺寸与配色,而这个文件之外没人需要知道这些细节:`ChatScreen` 只负责接线,
* `MessageBubble` 只调用最后一枚。放在一起,改一眼就能看全。
*
* ## 图标从哪来(查过 AAR,不是猜的)
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚,**没有麦克风**;**extended** 包里有真的
* `Mic` / `MicSlash`(`javap` 确认过 `MicKt.getMic(MiuixIcons)`),而本模块本来就在用 extended
* (`VoiceBubble.kt` 的 `Play` / `Pause`、`ChatScreen.kt` 的 `File` / `Image`)。
*
* ## 读屏名字即设备验收的 `content-desc`
* 四枚可点控件的 `contentDescription` 都抽成了常量([VOICE_RECORD_DESCRIPTION] 等):
* `uiautomator dump` 靠它们定位,改文案等于改验收口径,两处一起改。
*/
/** 「开始录音」那枚麦克风的读屏名字。 */
const val VOICE_RECORD_DESCRIPTION: String = "按住说话(点按开始录音)"
/** 录音中那枚麦克风(点它 = 结束并发送)。 */
const val VOICE_RECORD_STOP_DESCRIPTION: String = "结束录音并发送"
/** 录音中的「取消」。 */
const val VOICE_RECORD_CANCEL_DESCRIPTION: String = "取消这段录音"
/** 待重发那一段的「重发」。 */
const val VOICE_DRAFT_RETRY_DESCRIPTION: String = "重发这段语音"
/** 待重发那一段的「丢弃」。 */
const val VOICE_DRAFT_DISCARD_DESCRIPTION: String = "丢弃这段语音"
/** 她的语音回复的播放 / 停止。 */
const val VOICE_REPLY_PLAY_DESCRIPTION: String = "播放她的语音回复"
const val VOICE_REPLY_STOP_DESCRIPTION: String = "停止播放她的语音回复"
/** 关掉她的语音回复那一张卡片。 */
const val VOICE_REPLY_DISMISS_DESCRIPTION: String = "关闭这条语音回复"
/** 乐观气泡里那枚「语音」的字样(与接收侧的 [VOICE_LABEL] 同一个词,不另造)。 */
const val VOICE_CLIP_LABEL: String = VOICE_LABEL
/**
* 麦克风**没授权**时的如实告知。
*
* 说「到系统设置里允许」而不是再申请一次:本端只在第一次点麦克风时申请(平台规范),
* 被拒之后重复申请只会被系统静默挡回(Android 不再弹框),那样用户点十次都毫无反应。
*/
const val VOICE_PERMISSION_DENIED_MESSAGE: String =
"没有麦克风权限,到系统设置里允许后再试"
/**
* 有权限、但麦克风**现在起不来**(被别的应用占用 / 设备不支持这组采样参数)。
*
* 与 [VOICE_PERMISSION_DENIED_MESSAGE] 分开:那一档用户要去设置页,这一档要等一会或换个应用;
* 说成同一句就是把人引向一个解决不了问题的动作。
*/
const val VOICE_UNAVAILABLE_MESSAGE: String = "麦克风现在打不开,可能被别的应用占着"
/** 图标尺寸:与接收侧语音形态同一档。 */
private val VOICE_ICON_SIZE = 18.dp
/** 卡片底色与圆角:与 `VoiceMessageRow` 那一枚同形(同一个产品里的语音长一个样)。 */
private val VOICE_CARD_SHAPE = RoundedCornerShape(12.dp)
/**
* 录音中那一行:`🎤 录音中 · 0:07 [取消] [发送]`。
*
* 时长由**已收到的字节数**反算(见 `VoiceRecorder`),所以屏幕上这个数字与发出去的音频长度
* 严格一致。到 60 秒时 `VoiceRecorder` 会置「满了」标记、由调用方直接收尾——这里不做倒计时:
* 一个还在数的计时器在「已经满了」那一刻是假话。
*
* 两枚按钮分开(不是「点头像就发」那种手势):录音是一条**有去无回**的动作(这段 bytes 一旦
* 发出就落进服务端编排),取消必须和发送一样容易按到。
*/
@Composable
fun VoiceRecordingRow(
elapsedMs: Int,
onCancel: () -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.error,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "录音中 · ${voiceElapsedLabel(elapsedMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onCancel,
modifier = Modifier.semantics { contentDescription = VOICE_RECORD_CANCEL_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
Button(onClick = onSend) { Text("发送") }
}
}
/**
* 一段**录好但没发出去**的语音:`🎤 语音 · 0:07 [重发] [丢弃]`。
*
* 它只在一件事之后出现:`voice_input` 没能上行(连接没就绪 / 写帧失败)。此时那段字节**只在
* 内存里**,界面上必须给两条出路——重发(**复用同一个 `clientMsgId`**,服务端据此判重)或丢弃。
* 不给「退回到输入栏」那种出路:语音没有可以退回到输入框的形态。
*/
@Composable
fun VoiceDraftRow(
draft: VoiceDraftInfo,
onRetry: () -> Unit,
onDiscard: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(draft.durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
TextButton(
text = "重发",
onClick = onRetry,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_RETRY_DESCRIPTION },
)
TextButton(
text = "丢弃",
onClick = onDiscard,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_DISCARD_DESCRIPTION },
)
}
}
/**
* 她的语音回复:`[▶] 她的语音回复 · 0:03 [×]` + 她说的话([VoiceReply.reply])。
*
* ## 只有文字那一档
* `provider == "silent"`(或这一轮根本没有 audio)时**不画播放控件**,只画文字 + 一句
* [voiceSilentNote](「这一轮她只回了文字,没有语音」)。契约的硬要求是「UI 别把它画成播放器」;
* 补那句解释是为了让「她选择只用文字」与「播放器坏了」在屏幕上分得开。
*
* ## 可播放但播不了
* 音频在、本机解码 / 落盘失败时**照样画**播放控件:点了会走到 [failureOf] 那一行如实说
* 「播不了」——这比一个看起来什么都没有的控件诚实。
*/
@Composable
fun VoiceReplyRow(
reply: VoiceReply,
playback: VoicePlaybackState,
onTogglePlay: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val active = playback.isActiveFor(reply.id)
val loading = playback.isLoadingFor(reply.id)
val failure = playback.failureOf(reply.id)
val text = reply.reply
Column(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (reply.playable) {
// 整枚可点(不只是 18dp 的图标),读屏名字说清「点它会怎样」。
Row(
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onTogglePlay)
.semantics {
contentDescription = if (active) {
VOICE_REPLY_STOP_DESCRIPTION
} else {
VOICE_REPLY_PLAY_DESCRIPTION
}
}
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
} else {
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
// 「现在在干什么」与「点它会怎样」分开说(与接收侧语音形态同一条纪律):
// 只有真的在取字节时才说「准备中…」,正在响时不冒充加载。
if (loading) {
Text(
text = "准备中…",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = onDismiss,
modifier = Modifier.semantics { contentDescription = VOICE_REPLY_DISMISS_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
}
}
text?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.padding(start = 2.dp, end = 8.dp),
)
}
// 「只有文字」与「本机没准备好」是两件事,各说各的那句(判据是纯函数)。
voiceReplyNote(silent = reply.silent, hasAudioFile = reply.file != null)?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
failure?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
)
}
}
}
/**
* 乐观气泡里那一枚「语音」:`🎤 语音 · 0:07`。
*
* 用户按住说话之后,气泡**立刻**出现(与文本消息的乐观路径同一个 `pending` 列表、同一个回滚),
* 只是正文还没到——转写要等服务端。所以这里只画媒体类型与时长,**不画播放控件**:
* 本地已经发出去的那段音频不在播放通道里(它已经在服务端了,回放应当走历史消息那条路)。
*/
@Composable
fun VoiceClipLabel(durationMs: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
}
@@ -0,0 +1,230 @@
package top.yeij.cyrene.ui.chat
import android.media.AudioAttributes
import android.media.MediaPlayer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.chat.VOICE_UNPLAYABLE_MESSAGE
import top.yeij.cyrene.domain.chat.VoiceAudio
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failed
import top.yeij.cyrene.domain.chat.finished
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.prepared
import top.yeij.cyrene.domain.chat.pressed
import top.yeij.cyrene.domain.chat.silenced
import top.yeij.cyrene.domain.chat.voiceAudio
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
import java.io.File
/**
* 「同一时刻只有一条语音在响」的那条**播放通道**(接收侧:播放别人发来的语音)。
*
* ## 用什么放(不引任何新依赖)
* 系统 **`android.media.MediaPlayer`**(平台 API,API 1 起就有)。选它而不是 ExoPlayer / Media3:
* 项目纪律是加依赖要先问,而这里要的只是「放一段 ogg / wav / mp3 的本地文件」,平台的解码器够用。
* 音源是**本地文件**([load] 的产物):字节由 `VoiceStore` 取一次、落在 `cacheDir/media/`,
* 于是播放本身不联网、不随时间抖动,也**不必**把 Bearer 头塞进播放器(`MediaPlayer` 的头部参数
* 不会走 401 续期那条路,那才是真的会出事的地方)。
*
* ## 生命周期:三处必须停下来并释放(`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路)
* 1. **开始播另一条**:[toggle] 在任何新播放之前先 [stop](旧的那条停下并释放,含正在下载的
* 那一次)——所以「一条没放完又点另一条」不会出现两条叠着响;
* 2. **离开会话 / 换会话**:调用方用 [rememberVoicePlayer](它按 `sessionKey` 换实例并在
* `onDispose` 里 [release]),换会话 = 换实例 = 旧的那条立刻停;
* 3. **组件销毁**:同上,`onDispose` 是组合被丢弃时唯一可靠的时刻([release] 幂等,可重复调)。
*
* ⚠️ **本片没做的两件事(如实)**:
* - **不申请音频焦点**(`AudioManager` 那套):需要 `Context` 与「焦点被抢走」的处理,属于
* 「播放器该有的完整行为」,与本期「收得到、放得出来」不是一档。后果是主人自己在放音乐时
* 语音会叠在上面;
* - **App 退到后台不停播**(要接 `LocalLifecycleOwner` 需要 `lifecycle-runtime-compose`,
* 本期不加依赖)。语音都很短,影响面是「切走那一两秒还在响」。
*
* ## 失败:可见、但不夸张
* 两档失败都会进 [VoicePlaybackState.Failed](气泡据此把按钮变回「未播」并显示**一行**短提示):
* 取字节失败(网络 / 404 / 地址拼不出来,文案来自 [voicePlaybackFailureMessage])与解码起播失败
* ([VOICE_UNPLAYABLE_MESSAGE])。**绝不静默**——一条点了没反应的语音在用户眼里就是坏的。
*
* ## 不缓存字节(这一层)
* 缓存是 [VoiceStore] 的事(按存储名落 `cacheDir/media/`,命中就不发请求)。本类只负责「拿到
* 文件 → 放 → 放完/停 → 释放」,因此它不认识网络,也不认识缓存策略。
*
* @param scope 驱动的协程作用域(调用方给,通常是组合的 `rememberCoroutineScope`)。
* @param load 取这条语音的**本地文件**(未命中缓存时内部会下载并落盘)。
*/
class VoicePlayer(
private val scope: CoroutineScope,
private val load: suspend (VoiceAudio) -> Result<File>,
) {
private val _state = MutableStateFlow<VoicePlaybackState>(VoicePlaybackState.Idle)
/** 这条通道此刻的状态(气泡与按钮都看它)。状态机本身是纯函数,见 `domain.chat.VoicePlayback`。 */
val state: StateFlow<VoicePlaybackState> = _state.asStateFlow()
/** 正在取字节的那次协程;停下 / 换目标时一并取消(用户不该被一个慢下载困住)。 */
private var loading: Job? = null
/** 当前这一台 `MediaPlayer`(null = 没有)。回调里的身份比对都以它为准。 */
private var player: MediaPlayer? = null
/**
* 用户点了这条消息的播放 / 暂停。
*
* 判据全部走纯函数:拿不到 [VoiceAudio](**无副本** ⇒ 四个键没凑齐)就**什么都不做**
* ——正常路径上那时气泡根本不画播放控件,这里是第二道闸(同一个判据,不在 UI 之外再猜一次)。
*/
fun toggle(message: HistoryMessageDto) {
val audio = voiceAudio(message.voice) ?: return
val id = message.id
// 再点一下 = 停(加载中也算:那表示「别下了」)。
if (_state.value.isActiveFor(id)) {
stop()
return
}
stop()
_state.value = _state.value.pressed(id)
loading = scope.launch {
load(audio).fold(
onSuccess = { file ->
// 这一帧还该不该放:用户可能已经点了停 / 点了别的(状态已不是 Loading(id))。
if (_state.value.isActiveFor(id)) play(id, file)
},
onFailure = { error ->
_state.value = _state.value.failed(id, voicePlaybackFailureMessage(error))
},
)
}
}
/**
* 放一段**已经在手上**的音频(`voice_output` 那条路:base64 就在帧里,不需要下载)。
*
* 与 [toggle] 走**同一条通道**、同一套身份比对与释放纪律(同一台喇叭上永远只有一条语音在响),
* 差别只有两个:音频不是「按消息取字节」而是由调用方现给([load]),而且键是
* `voice_output` 那一帧自己铸的 [clipId](它不是历史消息,没有 `messageId` 可用)。
*
* 同样遵守「再点一下 = 停」:`clipId` 正在放 / 正在载入时,这一下是**停**。
*
* @param load 取这份本地文件(写缓存 / 解码都在里面,调用方应切到 IO)。
*/
fun toggleClip(clipId: String, load: suspend () -> Result<File>) {
if (_state.value.isActiveFor(clipId)) {
stop()
return
}
stop()
_state.value = _state.value.pressed(clipId)
loading = scope.launch {
load().fold(
onSuccess = { file ->
if (_state.value.isActiveFor(clipId)) play(clipId, file)
},
onFailure = { error ->
_state.value = _state.value.failed(clipId, voicePlaybackFailureMessage(error))
},
)
}
}
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
fun stop() {
loading?.cancel()
loading = null
releasePlayer()
// ⚠️ `silenced` 而不是「只清 Playing」:失败那句话也要跟着走——换了目标 / 离开页面之后,
// 屏幕上不该还挂着上一条的失败。
_state.value = _state.value.silenced
}
/** 组件销毁 / 离开会话时调(等价于 [stop],单列一个名字是为了让调用点的意图看得见)。 */
fun release() = stop()
private fun play(messageId: String, file: File) {
val mp = MediaPlayer()
player = mp
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
mp.setOnPreparedListener { prepared ->
// 身份比对:已经换掉 / 已经 release 的那个实例,回调一律不改状态。
if (player !== prepared) return@setOnPreparedListener
prepared.start()
_state.value = _state.value.prepared(messageId)
}
mp.setOnCompletionListener { done ->
if (player !== done) return@setOnCompletionListener
releasePlayer()
_state.value = _state.value.finished(messageId)
}
mp.setOnErrorListener { broken, _, _ ->
if (player !== broken) return@setOnErrorListener true
releasePlayer()
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
// true = 这次错误已经处理(不让它再走 onCompletion,也不弹系统默认处理)。
true
}
try {
mp.setDataSource(file.absolutePath)
// prepareAsync 而不是 prepare:同步那一版会占着主线程等解码器,异步的失败同样会走到
// onError(format 不支持 / 文件损坏 / 已被清空都是这一档)。
mp.prepareAsync()
} catch (_: Exception) {
// setDataSource / prepareAsync 在状态不对、文件读不动时**同步抛**:如实报「播不了」。
releasePlayer()
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
}
}
/**
* 把当前那台播放器拆干净:先摘掉全部监听(免得 `stop()` / `release()` 触发的回调再改状态),
* 再 `stop()`(未 prepare 完时它会抛 `IllegalStateException`,所以包在 `runCatching` 里),
* 最后 `release()`。
*/
private fun releasePlayer() {
val current = player ?: return
player = null
current.setOnPreparedListener(null)
current.setOnCompletionListener(null)
current.setOnErrorListener(null)
runCatching { current.stop() }
runCatching { current.release() }
}
}
/**
* 为**当前这段组合**(通常是一个会话的聊天页)记住一条播放通道,并在它被丢弃时释放。
*
* 两条纪律写在这里,因为它们是本类唯一的使用方式:
* - **[sessionKey] 变了就换一条通道**:换会话 = 换通道 = 上一条立刻停(`remember` 换实例 →
* `DisposableEffect` 的旧 effect 走 `onDispose` → [VoicePlayer.release]);
* - **[load] 变化不重建通道**:它由 `rememberUpdatedState` 顶着(每次重组拿到最新那个 lambda),
* 否则重组期间的一次实例更替会把正在放的那条停掉。
*/
@Composable
fun rememberVoicePlayer(
sessionKey: Any?,
load: suspend (VoiceAudio) -> Result<File>,
): VoicePlayer {
val scope = rememberCoroutineScope()
val currentLoad by rememberUpdatedState(load)
val player = remember(sessionKey) { VoicePlayer(scope) { audio -> currentLoad(audio) } }
DisposableEffect(player) {
onDispose { player.release() }
}
return player
}
@@ -0,0 +1,49 @@
package top.yeij.cyrene.ui.chat
import java.io.File
/**
* `voice_output` 在界面上的那一份「她的语音回复」(只管**这一条实时应答**,不进历史)。
*
* ## 为什么它不在消息列表里
* 契约 v0.96 §3.15:这一帧**只定向回发起 `voice_input` 的那条连接**、**连接断了不补投**,
* 而且回合的正文本来就会经流式 / `message_persisted` 落进消息列表。所以它不是一条消息,
* 是「这一次语音回合的音频 + 她说的话」这一次性产物:放在输入栏上方一条卡片里,
* 用户随时可以关掉([ChatViewModel.dismissVoiceReply]),它不参与排序、不占历史。
*
* ## [hasAudio] 与 [file] 是两件事
* - [hasAudio]:**服务端**这一轮给了真音频(`provider != "silent"` 且 `audio` 非空白)。
* 为假 ⇒ 界面上**一个播放控件都不画**(画了就是点下去必然失败的按钮)。
* - [file]:音频在本机的那份文件。`hasAudio` 为真而它是 `null` = 本机解码 / 落盘失败
* ——这一档**照样画**播放控件(点了会如实报「播不了」),**绝不**把本机的故障说成
* 「她这一轮只回了文字」(见 `domain.chat.voiceReplyNote`)。
*
* @property id 播放通道的键:每条 `voice_output` 现铸一个(它不是历史消息,没有 `messageId`)。
* @property reply 她说的那一句(音频念的就是它),见 `domain.chat.voiceReplyText`。
* @property transcript 转写(载荷里的 `text`):那段音频被识别成什么;只作兜底与诊断。
* @property format 服务端给的音频格式;本端**不照抄它去解码**(`MediaPlayer` 按文件头嗅探)。
*/
data class VoiceReply(
val id: String,
val reply: String?,
val transcript: String?,
val format: String?,
val provider: String?,
val hasAudio: Boolean,
val file: File?,
/**
* 这一条还该不该**自动播放**(默认 true,播过就置假,见 [ChatViewModel.consumeVoiceReplyAutoPlay])。
*
* 为什么要这一位:这一帧**只在到达时**有意义(断线不补投),而到达的那一刻用户可能正在别的
* 会话 / 别的页面里。若只按「音频落好了就放」来触发,等他切回来时会**突然响一段旧语音**
* ——那不是他刚做的事。所以自动播放只认**第一次**落好的那一下,之后靠卡片上的播放键。
*/
val autoPlay: Boolean = true,
) {
/** 能不能画「播放」那枚控件:这一轮**有**音频,而且它已经落到本机。 */
val playable: Boolean get() = hasAudio && file != null
/** 这一轮是不是「没有音频」(= `provider == "silent"` 或 `audio` 缺席)。 */
val silent: Boolean get() = !hasAudio
}
@@ -0,0 +1,103 @@
package top.yeij.cyrene.ui.chat
import java.io.File
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.ttsClipIdOf
/**
* 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。
*
* ## 为什么它住在 VM 的状态里,而不是气泡自己的 `remember`
* 一次朗读要经过一段**跨组合重建**的等待(发请求、等字节、落盘):转屏 / 滚出屏幕再滚回来
* 都不该让已经落好的那段音频消失,也不该让「正在准备」这件事凭空重置。所以请求发起、失败分类、
* 落盘结果都在 `ChatViewModel.speak` 里,气泡只是这份状态的视图。
*
* ## 一次只有一个:`ttsClip` 是**单格**
* 与 [VoiceReply] 同一取舍:同一时刻用户点的是**一条**消息,多留几条既占内存又没人看。
* 用户点了另一条 ⇒ 这一格被新的那条整个替换(旧那条的音频文件留在缓存里,下次点它命中不发请求)。
*
* ## 播放不在这一格里
* 「正在响 / 已经停了」由**唯一的播放通道**(`ui.chat.VoicePlayer` 的 `state`)说,键是
* [clipId](`tts:<messageId>`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、
* 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道
* `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事
* 本来就不是一件事。
*
* @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。
* @property clipId 播放通道上的键(`tts:<messageId>`):与语音副本的键(= 它的 `messageId`)
* 分得开,于是「念这一条」与「放这条消息的语音副本」天然互斥(点一个停另一个)。
* @property file 合成音频在本机的那份 WAV;`null` = 还没到手(请求在飞,或已经失败了)。
* @property failure 上一次没成功时给用户看的那句话(`domain.chat.TtsFailure.message`);
* `null` = 没失败过 / 还没结果。
* @property retryable 再点一次**可能**有用吗(`domain.chat.TtsFailure.retryable`)。为假时图标
* 仍然可点 —— 但那是「重来一次」而不是「重试」,文案上不提重试(见 `TtsFailure.Unconfigured`)。
* @property autoPlay 这一次结果还该不该**自动起播**(默认 true,起播过就置假,见
* `ChatViewModel.consumeTtsAutoPlay`)。
*
* 为什么要这一位:请求发出去之后用户可能已经切到别的会话 / 别的页面。若只按「文件到了就放」
* 来触发,等他切回来时会**突然响一段刚才点的朗读** —— 那不是他此刻的动作。所以自动起播只认
* **第一次**落好的那一下,之后靠那枚图标。
*/
data class TtsClip(
val messageId: String,
val clipId: String,
val file: File?,
val failure: String?,
val retryable: Boolean,
val autoPlay: Boolean = true,
) {
/** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */
val pending: Boolean get() = file == null && failure == null
}
/**
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
*
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
* 判据跨了三处状态(这一格的 [TtsClip]、那条唯一播放通道的 [VoicePlaybackState]、以及
* `ttsClipIdOf` 铸的键),而它们谁都不该在自己的类里替对方下结论。
*/
sealed interface TtsSpeakIntent {
/** 这一条**正在响**,手上也有它的音频 ⇒ 这一下是「停」。 */
data object Stop : TtsSpeakIntent
/** 其余一切(没在响、响的是别的、失败过、缓存已命中想再听一遍)⇒ 交给 VM。 */
data object Speak : TtsSpeakIntent
}
/**
* 这一下点击是「停」还是「(重新)念」。
*
* ## 为什么必须有这条判据:否则「停止朗读」是空操作
* 图标在播放中画的是暂停符、读屏名字是 `TTS_STOP_DESCRIPTION`(见 `MessageBubble`),所以
* 用户点它时**意思是停**。但播放中 `TtsClip.pending` 是假的(文件早就到手了),于是
* `ChatViewModel.beginTtsSpeak` 的防抖放行、整格被换成新的 pending、缓存命中秒回、
* 起播 effect 又因为「已经在放同一条」而跳过 `toggleClip` —— 三处各自都「没错」,合起来就是
* 点了没反应。判据落在**播放通道**这一处(它是「有没有在响」的唯一真相),才不会有第二处
* 各自解释「播放中」是什么意思。
*
* ## 为什么要 `file != null`
* 「正在响」与「音频在手上」是两件事:通道处于 [VoicePlaybackState.Loading] 时它也算 active,
* 而那一刻 [TtsClip.file] 可能还是 `null`(字节在 VM 那边取)。那时把点击解释成「停」会停掉
* 一次**还没起播**的载入,而用户点它的本意是「念」——所以只有文件确实在手、通道确实在响时才判
* [TtsSpeakIntent.Stop];其余一律 [TtsSpeakIntent.Speak],由 VM 的 pending 分支去防抖。
*
* @param playback 那条唯一播放通道此刻的状态(`VoicePlayer.state`)。
* @param messageId 被点的那条消息。
* @param clip 这一条自己的朗读格(`messageId` 对不上的要当没点过,判据里再核一次)。
*/
fun ttsSpeakIntent(
playback: VoicePlaybackState,
messageId: String,
clip: TtsClip?,
): TtsSpeakIntent {
val file = clip?.takeIf { it.messageId == messageId }?.file
return if (file != null && playback.isActiveFor(ttsClipIdOf(messageId))) {
TtsSpeakIntent.Stop
} else {
TtsSpeakIntent.Speak
}
}
@@ -1,5 +1,7 @@
package top.yeij.cyrene.ui.sessions
import top.yeij.cyrene.data.repository.MessageIncrement
/**
* 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。
*
@@ -18,11 +20,15 @@ internal enum class PersistedRoute {
/**
* **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数**
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,问服务端要那个数。
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地问服务端要。
*
* 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。
* 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处,
* 于是红点必须靠用户开一次抽屉(那次静默重拉)才补上。
* 于是红点必须靠用户开一次抽屉才补上。
*
* 「要」的是什么,W77 换过一次:原来是整表 `GET /sessions`,现在是**这条会话的消息增量**
* `GET /messages?after=`(见 [incrementCursors] / [applyIncrement]);整表退回成「增量给不出
* 可信答案」时的回落。判据本身不受影响——这一档该不该被窗口挡住,与问的是哪种口径无关。
*/
InactiveSession,
}
@@ -190,3 +196,117 @@ internal fun silentRefreshStep(
*/
internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle =
SilentRefreshThrottle(lastPullAt = nowMillis)
// ---------------------------------------------------------------------------------------------
// W77:非激活会话的静默刷新,从「整表重拉 GET /sessions」换成「前向增量 GET /messages?after=」
//
// 后端明确批了这一换(回执二十八第 3 条:`?after=` 毫秒 `createdAt` 早就在册)。换掉的理由是**量**:
// 整表一次要回**所有**会话(每条一行预览 + 未读计数),而这里只需要回**真的变了的那一个**会话的新消息。
// 下面三个纯函数就是这一换的全部判据,放在这里而不是 VM 里,理由与 [silentRefreshStep] 一样:
// 纯函数才穷举得了、才不用在 JVM 上摆一台假服务器。
// ---------------------------------------------------------------------------------------------
/**
* 某个会话现在该从哪个 `createdAt` 起问增量(纯函数);`null` = **这一行问不了**。
*
* 游标就取 [SessionItem.lastMessageAt]——最近一次权威 `GET /sessions` 给的、本端已知最新一条的
* `createdAt`——**不另立账本**:
* - 服务端的 `after` 是 `created_at > ?`(**严格**大于),而 [SessionItem.lastMessageAt] 正是
* 「已经见过的最新一条」⇒ 两边语义天然对齐,多立一个账本只会多一个会漂移的真相源;
* - 增量自己会把 [SessionItem.lastMessageAt] 推到新游标([applyIncrement]),于是账本自洽地滚动,
* 不需要第二处推进。
*
* 两种「问不了」都落到 `null`,它们其实是同一件事——**本端不知道这条会话看到哪儿了**:
* - 行不在列表里(会话在别处新建、或本端那份列表还没拉到它)⇒ 没有游标可用;
* - 行在但 `lastMessageAt` 为 `null`(本端认为它一条消息都没有,却收到了它的落库广播)⇒
* 手里那份列表已经陈旧到不可信。
*
* 两种都由调用方回落到整表(唯一权威),见 `SessionsViewModel.flushPendingIncrements`。
*/
internal fun incrementCursorFor(item: SessionItem?): Long? = item?.lastMessageAt
/**
* 一批待问增量的会话 id → 各自的 `after` 游标(纯函数)。
*
* `null` = **这批里有一个问不了**([incrementCursorFor] 给了 `null`)⇒ 调用方**整批**回落整表。
* 不混着来是刻意的:一半靠增量、一半靠整表,得到的那份列表没有任何一处能说得清「它是什么时刻的」,
* 而未读面上最不能出的就是这种「说不清」。
*
* 返回 `emptyMap` = 这批本来就是空的(没有事要做),**不是**失败。
*/
internal fun incrementCursors(
sessionIds: Collection<String>,
sessions: List<SessionItem>,
): Map<String, Long>? {
if (sessionIds.isEmpty()) return emptyMap()
val byId = sessions.associateBy { it.id }
val cursors = LinkedHashMap<String, Long>(sessionIds.size)
for (id in sessionIds) {
cursors[id] = incrementCursorFor(byId[id]) ?: return null
}
return cursors
}
/**
* 把一次增量折进列表(纯函数):**只动那一行**,其余原样返回。
*
* ## 未读数怎么算:先过已读游标,再加
* 增量条数**不是**直接往角标上加的,要先按本地已知的已读游标 [readAt](`readCursors[sessionId]`)筛一遍。
* **过滤口径**与服务端同源:`session_read.go` 的 `unreadCountQuery` 是 `count(created_at > read_at)`,
* 而 `read_at` 是**账号级单一游标**,可能比本端已知的最新消息还新——用户在这台机器上点开过它
* ([SessionsViewModel.select] 推的就是「现在」),随后别的设备又发了消息。那一刻
* `(lastMessageAt, readAt]` 之间的消息**服务端算已读**,一律加进角标就会多出来一个红点。所以只数
* `createdAt > readAt` 的那几条。
*
* 已有的 [SessionItem.unreadCount] 直接保留相加是安全的:它来自服务端,而这次增量里的每一条都严格新于
* [from](服务端的 `after` 就是 `created_at > ?`),两者没有交集,不会重复计数。
*
* ## 已知边界:同源的只是**过滤口径**,基数仍是上次整表的旧值
* 相加的底数 [SessionItem.unreadCount] 来自上一次整表刷新,而 `read_at` 是账号级的、会被别的设备改写。
* 断线期间他端把某个会话读掉时,那条 `session_read` 帧本地**永久收不到**(断线不补帧),于是底数偏大,
* 这一行会一直多算那几条。这是**已接受的边界**,不是漏网:红点本来就只承诺「大概有几条新的」,漂移
* 到下一次整表刷新(打开抽屉 / 初始化 / 任何一次回落)就自愈。
*
* 这里**不**去检测它(例如「[readAt] > [from] ⇒ 底数可疑 ⇒ 回落整表」):本端刚点开过这个会话时
* `readAt` 本来就被推到「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量
* 路径在正常使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门
* 不如不装。
*
* ## 并发的那一份真相
* [from] 不是「这次用了什么游标」的装饰:行上的 [SessionItem.lastMessageAt] **已经不等于** [from],
* 就说明在这次请求飞在天上的时候,另一份更权威的结果(一次整表刷新、或另一次增量)已经推进过这一行。
* 那些条数已经被别人数过一遍了,再加一次就是重复计数 ⇒ 整行不动。
*
* 一行都没被改动时返回**同一个列表实例**(与 `applyBusyFrame` 同一习惯):这条路径跑在后台,
* 没有必要为了「什么都没变」让整个抽屉重组一遍。
*
* @param from 这次请求用的 `after` 游标。
* @param readAt 本端已知的已读游标;`null` = 不知道(当作 `0`,即「一条都还没读过」)。
* @param increment 升序的增量(`MessageRepository.loadIncrement` 的产物)。
*/
internal fun List<SessionItem>.applyIncrement(
sessionId: String,
from: Long,
readAt: Long?,
increment: MessageIncrement,
): List<SessionItem> {
if (increment.messages.isEmpty()) return this
val floor = readAt ?: 0L
val unseen = increment.messages.count { it.createdAt > floor }
// 升序 ⇒ 最后一条就是最新的一条(`loadIncrement` 保证这一点)。
val preview = increment.messages.last().content.toPreviewText().takeIf { it.isNotBlank() }
var touched = false
val updated = map { item ->
if (item.id != sessionId || item.lastMessageAt != from) {
item
} else {
touched = true
item.copy(
unreadCount = item.unreadCount + unseen,
lastMessage = preview,
lastMessageAt = increment.cursor,
)
}
}
return if (touched) updated else this
}
@@ -28,7 +28,8 @@ data class SessionItem(
val lastMessage: String?,
/**
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
* 两处用:已读游标比对(见 [SessionItem.withReadCursor]),以及增量追尾的 `after` 游标
* (见 `incrementCursorFor`,由 `applyIncrement` 推进到这次增量的 `cursor`)。不直接显示。
*/
val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
@@ -82,9 +83,12 @@ data class SessionsUiState(
*
* **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带
* `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**:
* 收到非激活会话的落库广播就**节流地静默重拉**([requestSilentRefresh],窗口
* [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次抽屉**就会出现
* (S6,登记见 `devdocs/05-开发进度与交接.md`)。
* 收到非激活会话的落库广播就**节流地问一次那条会话的消息增量**([requestSilentRefresh] →
* [flushPendingIncrements],窗口 [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次
* 抽屉**就会出现(S6,登记见 `devdocs/05-开发进度与交接.md`)。
*
* 取数走的是 `GET /messages?after=<ms>`(前向增量)而不是整表 `GET /sessions`——W77 换的,理由与回落
* 规则都写在 [flushPendingIncrements] 上。整表仍是**唯一权威**,只是不再需要为一条新消息付它的价钱。
*
* ## WS 连接:全 App 共享一条,而**本类是它唯一的主人**
* `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条
@@ -136,6 +140,17 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
*/
private var trailingRefreshJob: Job? = null
/**
* 窗口里攒下的「要问增量的会话 id」(W77)。
*
* 为什么是一个**集合**而不是一个 id:节流窗口把同一段时间里的落库广播合并成一次拉取,而那几个
* 会话**未必是同一个**(主会话说一句、另一个会话也说一句)。只记住最后一条的话,前面那个会话的
* 红点就永远不会出现——那正是 W49 修过的同一类病,只是换了个成因。
*
* 只在 [requestSilentRefresh] / [flushPendingIncrements] 两处写;登出时清空(它属于上一个身份)。
*/
private val pendingIncrements = mutableSetOf<String>()
/**
* 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。
*
@@ -201,6 +216,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
trailingRefreshJob?.cancel()
trailingRefreshJob = null
silentRefreshThrottle = SilentRefreshThrottle()
// 攒下的会话 id 同理:它们是上一个身份的会话,重登后那些 id 要么不存在、
// 要么属于别人;留着只会让重登后的第一次窗口拿旧 id 去问增量。
pendingIncrements.clear()
pulledForIdentity = null
busyTurns = emptyMap()
_state.value = SessionsUiState()
@@ -316,42 +334,54 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
/**
* 节流地静默重拉一次列表 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
* 节流地静默对齐一次 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
* 必须开一次抽屉才补上」(S6)。
*
* 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理
* 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。
*
* ## 为什么是「重拉」而不是「本地把未读数加一」
* `message_persisted` 帧**不带 `unreadCount`**:本地没有逐条时间线,自己数出来的数会与编辑 /
* ## 为什么是「问服务端」而不是「本地把未读数加一」
* `message_persisted` 帧**不带 `unreadCount`**:本端没有逐条时间线,靠帧自己数出来的数会与编辑 /
* 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**,
* 只是把「服务端才是唯一真源」这件事提前做一次 —— 走的是**与抽屉打开时那次完全相同**的路径
* ([refresh],含已读游标遮罩),只是不需要用户先开一次抽屉。
* 只是把「服务端才是唯一真源」这件事提前做一次。
*
* ## W77:问的是**那一条会话的增量**,不再是整表
* 曾经的落点是 `refresh(silent = true)`(整表 `GET /sessions`),一次要为**所有**会话付一行预览 +
* 未读计数的价钱,而真正变了的只有一个会话。现在落到 [flushPendingIncrements]:
* `GET /messages?after=<已见最大 createdAt>`,只回那几条新消息。整表仍是唯一权威,但只在
* 「增量给不出可信答案」时才请它出场(回落规则写在 [flushPendingIncrements] 上)。
*
* ## 三条纪律
* 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次
* 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。
* 2. **静默**:`silent = true`,不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。
* 失败时做什么**完全沿用既有行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;
* 以及「网络异常,显示 x 分钟前的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,
* 不该比用户自己打开抽屉时的那次刷新更吵。
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。[refresh] 内部唯一会动
* `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回主会话),
* 那是既有的、正确的收敛,不是重置。
* 2. **静默**:不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。失败时做什么**完全沿用既有
* 行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;以及「网络异常,显示 x 分钟前
* 的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,不该比用户自己打开抽屉时的那次
* 刷新更吵。
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。回落那条路([refresh])
* 内部唯一会动 `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回
* 主会话),那是既有的、正确的收敛,不是重置。
*
* @param cause 这次为什么要拉;决定它走不走窗口(见 [SilentRefreshCause])。
* @param sessionId 这一帧说的是**哪个**会话;`null` = 没有具体会话([SilentRefreshCause.ContextChanged]),
* 那一档只能走整表。窗口里攒下的 id 由 [pendingIncrements] 记着,因为一次窗口可能合并**几个不同**会话。
*/
private fun requestSilentRefresh(cause: SilentRefreshCause) {
private fun requestSilentRefresh(cause: SilentRefreshCause, sessionId: String? = null) {
val step = silentRefreshStep(cause, System.currentTimeMillis(), silentRefreshThrottle)
silentRefreshThrottle = step.throttle
// 没有具体会话时不留 id:那一次整表会把所有会话都对上,攒着的 id 一个也不缺。
if (sessionId != null) pendingIncrements += sessionId
when (step.action) {
// 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。
// (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档
// 走到这里时 trailingPending 必为 false,补拉早跑完了。)
//
// 撤掉补拉**不等于**可以丢掉它攒下的 id:[pendingIncrements] 是独立的一份账本,
// 这一趟会把它们一并做掉。
SilentRefreshAction.PullNow -> {
trailingRefreshJob?.cancel()
trailingRefreshJob = null
refresh(silent = true)
flushPendingIncrements(fullTable = sessionId == null)
}
// 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串
@@ -365,15 +395,75 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
trailingRefreshJob = viewModelScope.launch {
delay(step.delayMillis)
silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis())
refresh(silent = true)
flushPendingIncrements(fullTable = false)
}
}
// 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。
// (但这一帧的会话 id 已经在上面记进 [pendingIncrements] 了 —— 补拉不会漏掉它。)
SilentRefreshAction.Ignore -> Unit
}
}
/**
* 把 [pendingIncrements] 里攒下的会话**逐个问一次消息增量**,问不了就整批回落整表(W77)。
*
* 这就是 W49 那条「非激活会话落了新消息 ⇒ 整表重拉 `GET /sessions`」的替代品:整表一次要回
* **所有**会话(每条一行预览 + 未读计数),而这里只回**真的变了的那一个**会话的新消息。
*
* ## 回落的三条理由(都落到同一条路:整表)
* 1. **问不了增量**:[incrementCursors] 给 `null` —— 行不在列表里,或本端不知道它看到哪儿了;
* 2. **追不到尾**:`complete = false` —— 整个追尾里拿过整页(服务端的 `LIMIT` 切过一刀,那一刀
* 可能劈开了同一毫秒,而服务端只给严格 `>`,被劈开的尾巴再也追不回来;此后的空页与不满页
* 都说不清),或翻满了 `MessageRepository.MAX_INCREMENT_PAGES`。那时增量只覆盖了积压的一部分,
* 拿它当「全部」就是把红点做小;
* 3. **请求失败**:含会话已在别处被删掉的 `404` —— 那一档要靠整表把这一行从列表里去掉。
*
* 三条都是「增量给不出可信答案」⇒ 去问唯一权威。**一次批次最多回落一次**:整批共用这一个失败
* 出口,否则 N 个会话同时失败就是 N 次整表。
*
* ## 刻意不做的一件事:不写快照
* [refresh] 成功时会 `prefs.saveSnapshot`,这里**不写**。快照的类型是 `List<SessionSummaryDto>`
* (整表响应),而增量只回了消息——拿它去拼一份 DTO 列表,缺的字段(`type` / `createdAt` /
* `pinned` / `personaId`)只能靠猜,那等于用一个更差的东西覆盖掉一份好快照。代价是冷启动第一帧
* 可能显示上一次整表时的未读与预览,紧接着 `init` 里那次 `refresh()` 就会把它修正。
*
* ## 也不顺带刷角色目录
* [refresh] 会捎带 `loadPersonas()`,这条路径不会。角色目录的刷新点是 init / 新建 / 删除 / 抽屉
* 打开(那几个都是用户动作或一次性事件),「别的会话来了条消息」从来不是它的刷新时机——此前它只是
* 跟着整表搭了便车。W77 把便车取消,角色目录少掉的是几次没人要的请求。
*
* @param fullTable `true` = 这一趟没有具体会话(或已判明必须整表),直接走 [refresh]。
*/
private fun flushPendingIncrements(fullTable: Boolean) {
val targets = pendingIncrements.toList()
pendingIncrements.clear()
if (fullTable) {
refresh(silent = true)
return
}
val cursors = incrementCursors(targets, _state.value.sessions)
if (cursors == null) {
refresh(silent = true)
return
}
// 空批次 = 没有事要做(不是失败):不回落、也不发请求。
if (cursors.isEmpty()) return
viewModelScope.launch {
for ((sessionId, from) in cursors) {
val increment = container.messageRepository.loadIncrement(sessionId, after = from).getOrNull()
if (increment == null || !increment.complete) {
refresh(silent = true)
return@launch
}
val readAt = readCursors[sessionId]
_state.update {
it.copy(sessions = it.sessions.applyIncrement(sessionId, from, readAt, increment))
}
}
}
}
/**
* 切换 / 进入会话。
*
@@ -506,10 +596,11 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 帧、不碰下面的分支,所以两条既有路径(W49 的节流静默重拉、[persistedRoute] 的两档)语义
* 一个字节都没变。
*
* **她主动说的话还有第三个落点**(W53N):两条路(实时帧 / 带 `proactive` 的落库帧)**都**要
* 喂给 [top.yeij.cyrene.core.notify.ProactiveNotifier]——通知该不该弹、是不是重复由它判。
* 选在这里而不是新起一个订阅,是因为本 VM 是**唯一**同时看着这两帧、又活过整个登录态的
* 消费者;而它只把帧**转交**出去,不碰连接(连接的所有权仍在登录态那一处)。
* **通知不在这里**(W57 起):她主动说的话那两条路(实时帧 / 带 `proactive` 的落库帧)**曾经**
* 在本 VM 的帧订阅里转交给 `core.notify.ProactiveNotifier`,而这份订阅挂在 `viewModelScope` 上
* ——从最近任务把 Activity 划掉时它就没了,进程被前台服务保活着也收不到帧。那条通路现在由
* **进程级**的消费点喂(`app.ProactiveFrameIntake`,与那条共享连接同一层、同寿),本类**不再
* 判断通知的任何一件事**:这里只剩列表自己的两件事(未读、「谁在跑」)。
*/
private fun onEvent(event: WsEvent) {
onBusyFrame(event)
@@ -519,37 +610,11 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
// 落了新消息。哪一个会话都有事做:正在看的那个推进游标(消息已经显示在屏幕上了,
// 否则「自己正看着的会话」会攒出一个未读角标,契约 §3.9 的客户端处理建议),
// 别的会话则去问一次服务端(红点不该等用户开抽屉才出现)——两半都在 onMessagePersisted。
is WsEvent.MessagePersisted -> {
onMessagePersisted(event)
// 她主动说的话走 **IM 桥那条路由**时,服务端把它补发在这个落库帧上(帧里的
// `proactive` 判据,见 WsEvent.MessagePersisted)。那条路**不发** `proactive_message`,
// 所以不在这一档看一眼,通知就只剩一半入口(W53N)。
if (event.proactive) {
container.proactiveNotifier.onProactiveFrame(
sessionId = event.sessionId,
id = event.message?.id,
messageId = null,
timestamp = null,
content = event.message?.content.orEmpty(),
)
}
}
is WsEvent.MessagePersisted -> onMessagePersisted(event)
// 她主动说的话(v0.72)。服务端**不为这条路补** `message_persisted`(那条只有 IM 路由
// 才广播),所以上面那一档永远不会被它触发——不在这里补一句,主会话的红点会停在她
// 开口之前,要等用户自己开一次抽屉(那次静默重拉)才补上。
//
// 同一帧也是「要不要弹通知」的入口之一(另一条是上面那个落库帧):同一条消息两条路都
// 可能到,去重由 `core.notify.ProactiveNotifier` 按 id 管,这里不做任何判断。
is WsEvent.ProactiveMessage -> {
onProactiveMessage(event)
container.proactiveNotifier.onProactiveFrame(
sessionId = event.sessionId,
id = event.id,
messageId = event.messageId,
timestamp = event.timestamp,
content = event.content,
)
}
is WsEvent.ProactiveMessage -> onProactiveMessage(event)
else -> Unit
}
}
@@ -619,23 +684,24 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 两半共用同一条纪律:**本端哪里都不替服务端数未读**(逐条累加会与编辑 / 删除 / 别端操作漂移)。
* 区别只在「那它靠什么显示」:
* - [PersistedRoute.ActiveSession] ⇒ 就地清零 + 推进游标(这段一个字没改);
* - [PersistedRoute.InactiveSession] ⇒ **节流地静默重拉**([requestSilentRefresh]),
* 用服务端下发的 `unreadCount` 说话。这正是 W49 修的那一幕:此前这一档直接 `return`,于是
* 「App 停在主会话、次级会话来了新消息」没有任何人会去发现,红点要等用户打开一次抽屉(那次
* 静默重拉)才补上。
* - [PersistedRoute.InactiveSession] ⇒ **节流地问一次这条会话的消息增量**([requestSilentRefresh]
* 带上 `sessionId` → [flushPendingIncrements] → `MessageRepository.loadIncrement`),未读由
* 「增量里越过已读游标的条数」说话(W77;W49 那一档原本整表重拉 `GET /sessions`)。
* 这正是 W49 修的那一幕:此前这一档直接 `return`,于是「App 停在主会话、次级会话来了新消息」
* 没有任何人会去发现,红点要等用户打开一次抽屉才补上。
*
* 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做),
* 而「重拉一次列表」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
* 而「问一次增量」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
* 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的
* 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。
*/
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,
// 红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地问一次**这条会话**的消息
// 增量,红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。
// 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时,
@@ -659,9 +725,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 判据也共用 [persistedRoute](只是会话 id 由这一帧自己钉:它不带 `sessionId`)。
*
* 为什么必须在这里补一句:服务端的主动推送 Web 路由只发这一帧,**不补** `message_persisted`
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流静默重拉」根本不会被
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流刷新」根本不会被
* 触发——抽屉里主会话的红点就停在她开口之前,直到用户自己开一次抽屉。这里复用**同一条**路
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage]),
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage],会话 id 取主会话),
* 不新写取数路径,也不动节流判据本身。
*
* `id` 缺席 = 服务端那一刻**没有落库行** ⇒ 历史里没有这一行,未读面上没有任何东西要更新
@@ -672,7 +738,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
val sessionId = SessionSummaryDto.MAIN_SESSION_ID
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是主会话:她这句话已经显示在聊天流里(ChatViewModel 那侧并入)⇒ 与
// message_persisted 的同一档一样「看到即已读」,免得抽屉为她刚说的话亮一个红点。
@@ -114,6 +114,27 @@ fun SettingsScreen(
)
}
/*
* 「免打扰」单独占一段,不塞进上面那个「聊天」里:它管的是**通知**(屏幕上会不会被
* 盖住),与「聊天页怎么渲染」是两件事。下一段「连接」也是同一个道理(那边问的是连接
* 会不会断)。
*
* 写法与「后台保持连接」那一行逐项对齐(`KeepAliveRow.kt`):`SwitchPreference` 外面
* 必须包一层 `PreferenceCard`(Miuix 的 Preference 系列自己没有背景与圆角),副文案
* 走一个纯函数(好单测,见 `proactiveQuietSummary`),改开关只写偏好、**不在这里做
* 任何通知相关的事**——发通知时自己会现读(见 `AppSettings.current`)。
*/
SectionTitle("通知")
PreferenceCard {
SwitchPreference(
title = "免打扰",
summary = proactiveQuietSummary(prefs.proactiveQuiet),
checked = prefs.proactiveQuiet,
onCheckedChange = { scope.launch { settings.setProactiveQuiet(it) } },
)
}
Spacer(Modifier.height(8.dp))
SectionTitle("她忙时发消息")
InterruptMode.entries.forEach { mode ->
PreferenceCard {
@@ -261,3 +282,19 @@ private fun InterruptMode.summary(): String = when (this) {
InterruptMode.Parallel ->
"她正忙时不打断当前这一句,另起一路同时处理这条;两路各算一遍,会多花一份算力与 token"
}
/**
* 「免打扰」开关下面那行字(**纯函数**,测试钉住)。它要说清的只有一件事:
* **这不是静音** —— 关掉的是横幅,不是消息、也不是提示音。
*
* 为什么非说不可:「免打扰」四个字在系统里到处都是,用户对它的第一印象常是「什么都不响」。而这个
* 开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住我正在做的事」——两句话指向的期待
* 差得很远,一句不说就等于让用户自己猜(猜错的代价是他以为通知坏了,或以为自己买到了完全静音)。
*
* 两档都说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,而只写「开着 = 安静」
* 等于让他自己去比较两种安静。
*/
internal fun proactiveQuietSummary(enabled: Boolean): String = when {
enabled -> "已开:她主动找你说话只在通知栏提示(有提示音),不在屏幕顶部弹横幅"
else -> "关(默认):她主动找你说话会在屏幕顶部弹横幅,同时有提示音与通知栏条目"
}
@@ -0,0 +1,182 @@
package top.yeij.cyrene.app
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.core.notify.AppPresence
import top.yeij.cyrene.core.notify.ProactiveNotifier
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.WsEvent
/**
* 「她主动说的话」在**帧这一层**的消费点(W57)。
*
* 这里钉的是**接线**,不是判据(判据与去重仍在 `ProactiveNotifierTest`):
* 1. **它不需要任何 Activity 活着就能收帧**——改前那份订阅挂在 `SessionsViewModel` 的
* `viewModelScope` 上,从最近任务划掉 Activity 就没了,而 `ChatSocket.events` 是 `replay = 0`
* 的 `SharedFlow` ⇒ 没有订阅者的帧**当场丢掉**。本类的第一条测试就是那个 bug 的现场;
* 2. 哪些帧算「她主动说的话」、四个入参各取帧上的哪个键([proactiveFrameOf])——错在这两个方向
* 都是静默的(她开口了却不弹、普通消息也弹),所以逐键钉住;
* 3. 判据与去重在新的消费点上**没有被放松**:两条路仍只弹一次、看着主会话仍不弹、别的会话仍不弹。
*
* 收集用的调度器传 `Dispatchers.Unconfined`:`launch` 与 `tryEmit` 都在当前线程上把 collect 体
* 走完,于是测试是同步的(不需要等、也不需要 `runBlocking`)。
* **测不了的**:真·进程存活(Activity 没了而进程还在)这件事只有真机能造,本层只能钉住「订阅不挂
* 在任何会随 UI 结束的 scope 上」这个可测的部分。
*/
class ProactiveFrameIntakeTest {
private val presence = AppPresence()
private val posted = mutableListOf<String>()
/** 与 `ChatSocket` 同一档的订阅方式:无 replay、有缓冲、溢出丢最旧。 */
private val events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** 真实的帧(键名与类型都对服务端):实时帧带落库行 `id` + IM 号 + RFC3339 时间戳。 */
private val liveJson = """
{"type":"proactive_message","data":{"platform":"cyrene-backthink","channelId":"chan-1","channelType":"private","content":"记得喝水哦","contentType":"text","sender":"Cyrene","timestamp":"2026-10-06T01:20:05.123+08:00","messageId":"im-7","id":"1371","createdAt":1791212405123}}
""".trimIndent()
/** 同一条消息的另一条路:IM 桥那条路由补发的落库帧(判据键在载荷里)。 */
private val persistedProactiveJson = """
{"type":"message_persisted","sessionId":"web","data":{"proactive":true,"message":{"id":"1371","sessionId":"web","role":"assistant","content":"记得喝水哦","createdAt":1791212405123}}}
""".trimIndent()
/** 普通消息的落库广播(她正常回话):**不是**她主动说的话。 */
private val ordinaryPersistedJson = """
{"type":"message_persisted","sessionId":"web","data":{"message":{"id":"1372","sessionId":"web","role":"assistant","content":"在的","createdAt":1791212405123}}}
""".trimIndent()
/** 她主动说的、但不属主会话:绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。 */
private val liveJsonForAnotherSession = """
{"type":"proactive_message","sessionId":"sess-abc","data":{"content":"在吗","messageId":"im-9","id":"1373"}}
""".trimIndent()
private fun intake() = ProactiveFrameIntake(
events = events,
notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text }),
dispatcher = Dispatchers.Unconfined,
)
private fun parse(json: String): WsEvent = requireNotNull(WsEvent.parse(json)) { "帧本身要能成形" }
// —— 这一档就是那个 bug 的现场 ——
@Test
fun `her words still notify when no activity is alive`() {
// Activity 已经从最近任务里划掉(没有任何实例在 started 档、聊天页也不在),进程被
// KeepAliveService 撑着、连接照旧在收帧。改前这里是空的:订阅随 VM 一起没了。
assertFalse("这一档的前提就是前台没有任何 Activity", presence.appInForeground)
intake()
events.tryEmit(parse(liveJson))
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `the notification id comes from the notifier not from this layer`() {
// 号必须按消息派生(同一条 ⇒ 同一个号、不同的句子各占一条)。这一层只转交帧,不许自己
// 造号——这里用一个把号也记下来的假 post 确认它拿到的确实是「按消息派生」的那个值。
val ids = mutableListOf<Int>()
ProactiveFrameIntake(
events = events,
notifier = ProactiveNotifier(presence = presence, post = { _, id -> ids += id }),
dispatcher = Dispatchers.Unconfined,
)
events.tryEmit(parse(liveJson))
events.tryEmit(parse(ordinaryPersistedJson))
assertEquals(1, ids.size)
}
// —— 判据与去重在新的消费点上没有被放松 ——
@Test
fun `the live frame and the persisted frame of one message notify once`() {
intake()
events.tryEmit(parse(liveJson))
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再弹。
events.tryEmit(parse(persistedProactiveJson))
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `a frame that lands on screen while the main session is open does not notify`() {
// 用户正看着她说话的那个会话:话就出现在他眼前,不打扰(判据本身在 ProactiveNotify.kt)。
presence.onActivityStarted(Any())
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
intake()
events.tryEmit(parse(liveJson))
assertEquals(emptyList<String>(), posted)
}
@Test
fun `a frame for another session never notifies`() {
intake()
events.tryEmit(parse(liveJsonForAnotherSession))
assertEquals(emptyList<String>(), posted)
}
@Test
fun `an ordinary persisted frame never notifies`() {
intake()
events.tryEmit(parse(ordinaryPersistedJson))
assertEquals(emptyList<String>(), posted)
}
// —— 哪些帧算她的话、四个入参各取自帧上的哪里 ——
@Test
fun `only the two proactive shapes are frames of her words`() {
assertNull("普通落库广播不是", proactiveFrameOf(parse(ordinaryPersistedJson)))
assertNull("流式增量不是", proactiveFrameOf(WsEvent.MessageDelta(delta = "在的", index = 0)))
assertNull("开始帧不是", proactiveFrameOf(WsEvent.MessageStart(index = 0)))
}
@Test
fun `the live frame takes the history row id and the im id apart`() {
val frame = requireNotNull(proactiveFrameOf(parse(liveJson)))
// 帧壳不带会话归属 ⇒「无法判定」,由「她主动说的话落主会话」这条事实兜住(见 ProactiveNotifier)。
assertNull(frame.sessionId)
assertEquals("1371", frame.id)
assertEquals("im-7", frame.messageId)
assertEquals("2026-10-06T01:20:05.123+08:00", frame.timestamp)
assertEquals("记得喝水哦", frame.content)
}
@Test
fun `the persisted frame has only the row id and never invents the other two`() {
val frame = requireNotNull(proactiveFrameOf(parse(persistedProactiveJson)))
assertEquals(SessionSummaryDto.MAIN_SESSION_ID, frame.sessionId)
assertEquals("1371", frame.id)
// 帧上就没有这两个键。编一个出来(比如把 `id` 也当 IM 号)就是伪造服务端键,去重会跟着漂。
assertNull(frame.messageId)
assertNull(frame.timestamp)
assertEquals("记得喝水哦", frame.content)
}
private companion object {
/** 与 `ChatSocket.EVENT_BUFFER` 同档:订阅方不设上限、发射方不阻塞。 */
const val EVENT_BUFFER = 8
}
}
@@ -0,0 +1,143 @@
package top.yeij.cyrene.app
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.domain.share.ShareRules
import top.yeij.cyrene.domain.share.ShareTextSource
/**
* 三个文本 extra(`EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE`)**原始值**的读取
* ([shareTextExtrasOf])。
*
* ## 为什么值得单测
* 这一层只有两件事要做,而两件都容易错:
*
* 1. **按形态读**(`as? CharSequence`):真实分享里这几段常是 `Spanned`(富文本),按 `String` 读会
* 直接拿到 `null` —— 那正是「分享进来什么都没发生」的来处之一。而「读不出类型」(值是个数字、
* 别种 `Parcelable`……)时本端**不猜**:那一站读不到就是读不到,交给下一站。
* 2. **「里面躺着值」要如实报上去**([ShareTextExtras.present]):值与正文是两件事。空白值、读不出
* 类型的值都算「对方给了这个字段」,于是三个 extra 一起落空时本端会走「读不进来」那条如实告知,
* 而不是静默(判据见 `ShareRules.extract` 的 `textPresent`)。
*
* ⚠️ 这里**不**覆盖 `Bundle.getCharSequence` 到底抛不抛异常:那要真安卓运行时(JVM 单测里 android.jar
* 的桩方法一调就抛 `Stub!`)。结论与出处写在 `ShareIntake.kt` 的 `rawExtra` / [ShareTextExtras] 的
* KDoc 里(核的是 AOSP 的 `android/os/BaseBundle.java`:类型不符它自己 catch 掉回 `null`,会抛的是
* `unparcel()` 的 `BadParcelableException`,那一路由 `runCatching` 挡住)。
*/
class ShareTextExtrasTest {
@Test
fun `a plain string text is read through`() {
val extras = shareTextExtrasOf(text = "正文", subject = null, title = null)
assertEquals("正文", extras.text)
assertNull(extras.subject)
assertNull(extras.title)
assertTrue(extras.present)
}
@Test
fun `a spanned like char sequence is read through`() {
// 分享链路里的 `EXTRA_TEXT` 常常是 `Spanned`(带样式的 HTML 富文本):它**不是** String,
// 按 String 读会直接拿到 null。(JVM 单测造不出 `android.text.Spanned`,用另一个
// `CharSequence` 实现把这条读法钉住。)
val raw: Any = StringBuilder("来自网页的一段富文本")
val extras = shareTextExtrasOf(text = raw, subject = null, title = null)
assertEquals("来自网页的一段富文本", extras.text)
assertTrue(extras.present)
}
@Test
fun `a value we cannot read as a char sequence yields no body but is still a value`() {
// 形态读不出来(这里是数字,真实世界是别种 Parcelable):本端不猜它是什么意思 —— 但也不许
// 因此把整次分享当成「什么都没发生」,所以它照样算「里面躺着值」。
val extras = shareTextExtrasOf(text = 42, subject = null, title = null)
assertNull(extras.text)
assertTrue(extras.present)
}
@Test
fun `a blank value is still a value`() {
// 空白也算「对方给了这个字段」:`EXTRA_STREAM` 那边早就是这个口径(原始值非 null 即
// `streamPresent`),两个 extra 用同一条判据才不会出现「流的空值说话、文本的空值闭嘴」。
val extras = shareTextExtrasOf(text = " ", subject = "", title = null)
assertEquals(" ", extras.text)
assertEquals("", extras.subject)
assertTrue(extras.present)
}
@Test
fun `an absent trio yields nothing and is not a value`() {
// 「连值都没有」才是真的没有:这条边界撑着「用户确实什么都没分享 ⇒ 一个字都不说」。
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
assertNull(extras.text)
assertNull(extras.subject)
assertNull(extras.title)
assertFalse(extras.present)
}
@Test
fun `a subject only pair of raw values lands as the body`() {
// 端到端那一步:读出来的三段交给纯函数之后,只有 SUBJECT 的那次分享真的变成了正文,
// 并且来源如实记着。
val extras = shareTextExtrasOf(text = null, subject = "对方只给了标题", title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertEquals("对方只给了标题", share.text)
assertEquals(ShareTextSource.Subject, share.textSource)
assertTrue(share.worthDelivering)
assertFalse(share.contentUnreadable)
}
@Test
fun `raw values that are all unusable speak instead of vanishing`() {
// 三个 extra 里躺着值、而一段正文都没读出来:内容照旧为「空」,但**必须**有话可说 ——
// 这是本次要修的另一种静默(此前只有 `EXTRA_TEXT` 那一路,后两个 extra 落空时全静默)。
val extras = shareTextExtrasOf(text = 42, subject = " ", title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertTrue(share.isEmpty)
assertTrue(share.contentUnreadable)
assertTrue(share.worthDelivering)
}
@Test
fun `nothing at all in the bundle is not a value`() {
// 与上一条成对:`ShareIntake` 拿到的原始值全是 null(误触分享、Intent 里什么都没有)时,
// 本端一个字都不说 —— 边界不许因为这批改动而放宽。
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertFalse(share.worthDelivering)
assertFalse(share.contentUnreadable)
}
}
@@ -136,8 +136,78 @@ class ProactiveNotificationShapeTest {
"两条渠道各自表达一件事,混在一条里就只能二选一",
PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
)
// 恒定不变:改 id = 换渠道 = 用户对旧渠道做过的静音设置全部作废。
assertEquals("proactive", PROACTIVE_CHANNEL_ID)
// 🔴 这里是 `proactive_v2` 而**不是** `proactive`:渠道重要级一旦创建就改不了(只有用户能改),
// 2026-10-07 把它从 DEFAULT 提到 HIGH 对**已建过旧渠道**的设备不生效 ⇒ 只能换一个新 id
// 让系统按新重要级重新建。这条断言就是「以后谁要改重要级,必须同时换 id」的机器可读版本。
assertEquals("proactive_v2", PROACTIVE_CHANNEL_ID)
assertEquals("proactive", LEGACY_PROACTIVE_CHANNEL_ID)
assertTrue(
"新 id 必须不同于旧 id —— 相同就说明有人改了重要级却没换 id(对老设备无效)",
PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
)
}
/**
* 「免打扰」那条渠道:它必须是**另一条**,而且是**安静的那一条**。
*
* 这几件事各有各的坏法,且都很难在真机上发现:
* - 与 `PROACTIVE_CHANNEL_ID` 同 id ⇒ 开关两档发到同一条渠道,「开着免打扰照样弹横幅」;
* - 重要级不低于 HIGH ⇒ 它照样弹横幅,用户按了开关却什么都没变(而**不会有任何报错**);
* - 撞上旧 id ⇒ 建新渠道时会被顺手删掉(那条通知随后就被系统静默丢弃)。
*/
@Test
fun `the do-not-disturb channel is a second, quieter channel`() {
assertEquals("proactive_quiet", QUIET_PROACTIVE_CHANNEL_ID)
assertEquals(
"免打扰那一档只许是 DEFAULT(有提示音、不弹横幅):HIGH 会照样弹横幅",
android.app.NotificationManager.IMPORTANCE_DEFAULT,
QUIET_PROACTIVE_IMPORTANCE,
)
assertTrue(
"两条渠道必须真的不同 —— 同 id 就等于「开关两档没有区别」",
QUIET_PROACTIVE_CHANNEL_ID != PROACTIVE_CHANNEL_ID,
)
assertTrue(
"它也不许撞上旧 id(否则建新渠道那一步会把自己删掉)",
QUIET_PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
)
assertTrue(
"更不许撞上保活那条(否则用户对保活的静音设置会被她的话一起改掉)",
QUIET_PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
)
}
/**
* 开关 → 渠道 id 的**纯函数**(两档)。这是「免打扰」唯一的落地形状:横幅由渠道重要级决定,
* 而重要级建完就改不了 ⇒ 只能按开关选一条渠道发。
*
* 同时钉住 `ProactiveChannelSpec` 的**成对性**:id / 渠道名 / 重要级必须一起跟着开关走。四处
* 各写一份 `if (quiet)` 时的坏法是「换了 id 却没换重要级」,症状是「按了开关,什么都没变」。
*/
@Test
fun `the switch picks the channel and the whole spec moves with it`() {
assertEquals(PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = false))
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = true))
val loud = proactiveChannelSpec(quiet = false)
val quiet = proactiveChannelSpec(quiet = true)
assertEquals(PROACTIVE_CHANNEL_ID, loud.id)
assertEquals(PROACTIVE_CHANNEL_NAME, loud.name)
assertEquals(PROACTIVE_IMPORTANCE, loud.importance)
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, quiet.id)
assertEquals(QUIET_PROACTIVE_CHANNEL_NAME, quiet.name)
assertEquals(QUIET_PROACTIVE_IMPORTANCE, quiet.importance)
// 系统设置的渠道列表里这两行会并排出现,用户就是靠它判断「哪条会弹横幅」——同名等于白分两条。
assertNotEquals(
"两条渠道名必须一眼看得出区别",
PROACTIVE_CHANNEL_NAME,
QUIET_PROACTIVE_CHANNEL_NAME,
)
assertTrue(
"免打扰那条的渠道名要点明它是什么:$QUIET_PROACTIVE_CHANNEL_NAME",
QUIET_PROACTIVE_CHANNEL_NAME.contains("免打扰"),
)
}
@Test
@@ -147,7 +217,13 @@ class ProactiveNotificationShapeTest {
"她要提醒人,重要级必须高于保活那条",
PROACTIVE_IMPORTANCE > top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_IMPORTANCE,
)
assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE)
// `HIGH`(4) 是**屏幕顶部弹横幅**的门槛:DEFAULT 只会响一声躺在通知栏里。
// 主人 2026-10-07 的原话「我在其他 APP 时没看到横幅」就是 DEFAULT 这一档造成的。
assertEquals(android.app.NotificationManager.IMPORTANCE_HIGH, PROACTIVE_IMPORTANCE)
assertTrue(
"免打扰那一档必须比它低 —— 否则「只关横幅」这件事就没有档位可用了",
QUIET_PROACTIVE_IMPORTANCE < PROACTIVE_IMPORTANCE,
)
}
}
@@ -0,0 +1,49 @@
package top.yeij.cyrene.core.prefs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/**
* 「免打扰」这个设置项的形状(默认值 + 读写往返)。
*
* `AppSettings` 本体要 Android + DataStore、单测里起不来(与 `InterruptModeTest` 同一条理由),
* 所以钉的是它两侧的**纯**部分。三件事各自对应一种真会发生的坏法:
*
* 1. **默认必须是 `false`(都弹)**:主人 2026-10-07 的话是「关闭时两者都弹,**默认都弹**」——
* 一个默认就安静的 App 会让人以为通知坏了(而这一条恰恰没有任何报错);
* 2. **数据类的默认参数与落盘读取口的缺省值必须是同一个值**:它们表达的是同一个「缺省」,
* 分叉的症状是「开关显示关着、行为却安静」(或反过来),同样没有任何报错;
* 3. **读写往返**:写进去什么就读出什么,两个布尔都走一遍。这一档没有 `Enum` 那种映射层,
* 唯一会坏的方式就是某个分支被写反,而写反的后果正好是「开关与行为相反」。
*/
class AppPrefsQuietTest {
@Test
fun `do not disturb is off by default so both keep alerting`() {
assertFalse(
"默认必须是「都弹」:默认就安静只会让人以为通知坏了",
AppPrefs().proactiveQuiet,
)
assertFalse("缺省常量本身就是默认值,它不能是 true", PROACTIVE_QUIET_DEFAULT)
}
@Test
fun `the data-class default and the read seam agree on one default`() {
// 两条路读的是同一个「缺省」:进程刚起来(数据类默认参数)与盘上没有这个键(读取口)。
assertEquals(
"数据类的默认参数与落盘读取口的缺省值必须同源",
AppPrefs().proactiveQuiet,
(null as Boolean?).toProactiveQuiet(),
)
}
@Test
fun `the switch round-trips from the data class through the read seam`() {
listOf(false, true).forEach { wanted ->
val written = AppPrefs(proactiveQuiet = wanted).proactiveQuiet
assertEquals("写进去什么就得读出什么", wanted, (written as Boolean?).toProactiveQuiet())
}
}
}
@@ -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)
}
}
@@ -0,0 +1,261 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
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.data.api.ApiClientFactory
import top.yeij.cyrene.domain.chat.VoiceSource
import top.yeij.cyrene.domain.chat.VoiceStatus
/**
* `voice` 键的解码钉子(契约 **v0.67 登记、v0.79 已实现**,api-client.md §4.4 与 §1.2 U19)。
*
* 形状来源就是契约里那两处(示例 JSON + 语义段),**逐键手写**(没有 v0.79 的真实抓包);
* 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。
*
* 要钉住的六件事:
* 1. **键名逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url`,
* 外加**只在 `status = "failed"` 时**出现的第八个键 `fallbackReason`(后端 M57-M59 预告,
* 契约 v0.98 的正式回执尚未到)—— 键名不许自己发明(形状错一个字母线上就是「不是语音」);
* 2. **缺席与 `null` 是同一件事**:非语音消息整键缺席 ⇒ `null`(不是 `{}`,也不是别的哨兵);
* 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉);
* 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」);
* 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢);
* `fallbackReason` 反过来**不是**闭集——本端原样留着、不做任何映射;
* 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。
*/
class VoiceDtoTest {
private val json = ApiClientFactory.json
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
/** 契约 §4.4 的示例逐字(含四个副本键 + `durationMs`)。 */
private val transcribed = """
{
"id": "msg-1",
"sessionId": "web",
"role": "user",
"content": "明天几点出发",
"createdAt": 1700000000000,
"voice": {
"source": "asr",
"status": "transcribed",
"name": "$name",
"mime": "audio/ogg",
"size": 12345,
"durationMs": 3200,
"url": "/api/v1/media/$name"
}
}
""".trimIndent()
private fun decode(body: String) = json.decodeFromString(HistoryMessageDto.serializer(), body)
@Test
fun `a transcribed voice decodes the seven keys verbatim`() {
val voice = decode(transcribed).voice!!
assertEquals(VoiceSource.ASR, voice.source)
assertEquals(VoiceStatus.TRANSCRIBED, voice.status)
assertEquals(name, voice.name)
assertEquals("audio/ogg", voice.mime)
assertEquals(12345L, voice.size)
assertEquals(3200, voice.durationMs)
// url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。
assertEquals("/api/v1/media/$name", voice.url)
assertFalse(voice.url!!.startsWith("http"))
// 转写成功的行不该有降级原因(服务端 `omitempty` 不下发)⇒ 解成 null,显示层据此不加小标。
assertNull(voice.fallbackReason)
}
@Test
fun `a message without the voice key decodes to null, not to an empty object`() {
val body = """
{"id":"msg-2","sessionId":"web","role":"user","content":"在的","createdAt":1700000000001}
""".trimIndent()
assertNull("🎤 判据 = 键存在:缺席必须是 null", decode(body).voice)
}
@Test
fun `an explicit null is the same thing as an absent key`() {
// 契约说 `voice` 是**指针**、非语音消息整键缺席(不是 null);真收到 null 也按「没有」处理。
val body = """
{"id":"msg-3","sessionId":"web","role":"user","content":"在的","createdAt":1700000000002,
"voice":null}
""".trimIndent()
assertNull(decode(body).voice)
}
@Test
fun `a voice with no audio copy has all four copy keys absent`() {
// 微信(weixin-ilink):平台自带转写就不白下载那份音频 ⇒ 只有 🎤 标记,四个键全缺席。
// ⚠️ 时长与副本**无关**:渠道给了就留着(所以这条夹具带着 durationMs,而四个副本键照样全缺)。
val body = """
{"id":"msg-4","sessionId":"web","role":"user","content":"在忙吗","createdAt":1700000000003,
"voice":{"source":"platform","status":"transcribed","durationMs":5000}}
""".trimIndent()
val voice = decode(body).voice!!
assertEquals(VoiceSource.PLATFORM, voice.source)
assertNull(voice.name)
assertNull(voice.mime)
assertNull(voice.size)
assertNull(voice.url)
assertEquals(5000, voice.durationMs)
}
@Test
fun `a copy keeps the four keys together including size zero and a missing duration`() {
// durationMs 缺席(服务端读不出来时不估算)——**其余四个键照样齐全**。
val body = """
{"id":"msg-5","sessionId":"web","role":"user","content":"","createdAt":1700000000004,
"voice":{"source":"asr","status":"empty","name":"$name","mime":"audio/ogg",
"size":0,"url":"/api/v1/media/$name"}}
""".trimIndent()
val voice = decode(body).voice!!
assertEquals(name, voice.name)
assertEquals("audio/ogg", voice.mime)
assertEquals("size 为 0 也照样解码(本端不拿它推导任何东西)", 0L, voice.size)
assertEquals("/api/v1/media/$name", voice.url)
assertNull("缺席 ≠ 0:必须是 null,才能让界面「一个字符都不画」", voice.durationMs)
}
@Test
fun `a duration reported by the channel is kept as is`() {
val body = """
{"id":"msg-6","sessionId":"web","role":"user","content":"","createdAt":1700000000005,
"voice":{"source":"asr","status":"transcribed","durationMs":60000}}
""".trimIndent()
assertEquals(60_000, decode(body).voice!!.durationMs)
}
@Test
fun `the three statuses and both sources are kept verbatim, and an unknown one is not guessed`() {
val cases = listOf(
VoiceStatus.TRANSCRIBED to VoiceSource.ASR,
VoiceStatus.EMPTY to VoiceSource.ASR,
VoiceStatus.FAILED to VoiceSource.PLATFORM,
// 认不出的取值:原样保留(本端不猜、不报错、不丢——显示层对它们与本端不认识时同形)。
"future-status" to "future-source",
)
cases.forEach { (status, source) ->
val body = """
{"id":"msg-7","sessionId":"web","role":"user","content":"x","createdAt":1700000000006,
"voice":{"source":"$source","status":"$status"}}
""".trimIndent()
val voice = decode(body).voice!!
assertEquals(status, voice.status)
assertEquals(source, voice.source)
}
}
@Test
fun `an unknown extra key inside voice does not break the message`() {
val body = """
{"id":"msg-8","sessionId":"web","role":"user","content":"x","createdAt":1700000000007,
"voice":{"source":"asr","status":"transcribed","waveform":[1,2,3]}}
""".trimIndent()
assertEquals(VoiceStatus.TRANSCRIBED, decode(body).voice!!.status)
}
@Test
fun `an empty voice object decodes as present`() {
// 契约说不会下发 `{}`(「不是 {} 也不是 null」),真收到时本端只认「键存在」这一条判据
// ⇒ 它算一条语音消息(四个副本键全缺 ⇒ 不画播放控件)。**刻意**在这里钉住这个选择:
// 「键在不在」是契约给的唯一判据,本端不额外发明一条「对象够不够完整」的判据。
val body = """
{"id":"msg-9","sessionId":"web","role":"user","content":"","createdAt":1700000000008,
"voice":{}}
""".trimIndent()
val voice = decode(body).voice
assertTrue(voice != null)
assertEquals("", voice!!.status)
}
@Test
fun `encoding a full voice keeps the seven key names`() {
// 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。
// 这里没传 `fallbackReason`(非 failed 行本来就不该有)⇒ `explicitNulls = false` 不写这个键,
// 键集仍是七个。
val encoded = json.encodeToJsonElement(
VoiceDto.serializer(),
VoiceDto(
source = VoiceSource.ASR,
status = VoiceStatus.TRANSCRIBED,
name = name,
mime = "audio/ogg",
size = 12345,
durationMs = 3200,
url = "/api/v1/media/$name",
),
).jsonObject
assertEquals(
setOf("source", "status", "name", "mime", "size", "durationMs", "url"),
encoded.keys,
)
}
@Test
fun `encoding a fallback voice keeps the fallbackReason key`() {
// 第八个键(后端 M57-M59 预告):只在 `status = "failed"` 时出现。它在 DTO 上是**真字段**,
// 不是靠 `ignoreUnknownKeys` 吞掉的陌生键——否则出站降级那一行写不出去。
val encoded = json.encodeToJsonElement(
VoiceDto.serializer(),
VoiceDto(
source = VoiceSource.TTS,
status = VoiceStatus.FAILED,
fallbackReason = "tts_timeout",
),
).jsonObject
assertEquals(setOf("source", "status", "fallbackReason"), encoded.keys)
assertEquals("tts_timeout", encoded["fallbackReason"]!!.jsonPrimitive.content)
}
@Test
fun `a fallback voice decodes its reason and has no copy`() {
// 降级行:没有可播的声音(四个副本键全缺席),原因原样留在 `fallbackReason` 里——
// 本端**不映射、不猜闭集**(取值集合后端尚未定稿),显示层原样小字。
val body = """
{"id":"msg-10","sessionId":"web","role":"user","content":"想跟你说句话",
"createdAt":1700000000009,
"voice":{"source":"tts","status":"failed","fallbackReason":"tts_timeout"}}
""".trimIndent()
val voice = decode(body).voice!!
assertEquals(VoiceSource.TTS, voice.source)
assertEquals(VoiceStatus.FAILED, voice.status)
assertEquals("tts_timeout", voice.fallbackReason)
assertNull("降级行没有可播的声音", voice.name)
assertNull(voice.mime)
assertNull(voice.size)
assertNull(voice.url)
}
@Test
fun `encoding a voice with no copy omits the nullable keys`() {
// `explicitNulls = false`(与后端 `omitempty` 同一口径):无副本时那四个键**不写出来**,
// 而不是写成 `null`——否则服务端与别的客户端会看到一个「有键但值是 null」的陌生形状。
val encoded = json.encodeToJsonElement(
VoiceDto.serializer(),
VoiceDto(source = VoiceSource.PLATFORM, status = VoiceStatus.TRANSCRIBED),
).jsonObject
assertEquals(setOf("source", "status"), encoded.keys)
assertEquals(VoiceSource.PLATFORM, encoded["source"]!!.jsonPrimitive.content)
}
}
@@ -0,0 +1,205 @@
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.assertArrayEquals
import org.junit.Assert.assertEquals
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.ApiClientFactory
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.MediaApi
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* `GET /api/v1/media/{name}` 的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带的 HTTP 服务端
* (与 `DocumentRepositoryTest` / `ImageCacheTest` 同一手法,不引新依赖)。
*
* 形状与状态码来源:契约 **v0.79** 的 §1.2 **U19** 一行(`200` / `206` / `304` / `401` / `403` /
* `404` / `416`;`Content-Type` 是音频类型;`404 media_not_found`)与 §4.5。
* **没有真实抓包**:这里的响应是按契约逐条手写的。
*
* 要证明的四件事:
* 1. 请求真的发到**服务端给的那条路径**(`/api/v1/media/<name>`;本端一个字都不拼);
* 2. **必须带 `Authorization: Bearer`**(U19 是 `owner|admin` 端点:语音是私密内容);
* 3. `200` 回的是**字节本身**(不是 JSON 信封),且一个字不改;
* 4. `404 media_not_found` / `401` / 非信封错误体 / 0 字节都折成可分支的失败,
* **0 字节不算成功**(那会让下一次播放「命中缓存」一份什么都没有的文件)。
*/
class MediaRepositoryTest {
private val backend = FakeMediaBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = MediaRepository(
ApiClientFactory.create(
baseUrl = backend.baseUrl,
service = MediaApi::class.java,
tokenProvider = { TOKEN },
),
)
/** 一段假的音频字节:这里只关心「字节原样过」,不关心它是不是真能解码。 */
private val bytes = ByteArray(64) { it.toByte() }
@Test
fun `a copy is fetched from the path the server gave, with a bearer token`() = runBlocking {
backend.reply(200, bytes, "audio/ogg")
val fetched = repository().download(backend.mediaUrl(backend.name)).getOrThrow()
assertArrayEquals(bytes, fetched)
val request = backend.lastRequest()
assertEquals("GET", request.method)
assertEquals(
"请求路径必须**逐字**是服务端给的 voice.url(客户端绝不自己拼)",
"/api/v1/media/${backend.name}",
request.path,
)
assertEquals("语音是私密内容:U19 要求 owner|admin,必须带 Bearer", "Bearer $TOKEN", request.authorization)
}
@Test
fun `a missing copy is reported as not found, not as an empty audio`() = runBlocking {
backend.reply(
404,
"""{"error":{"code":"media_not_found","message":"media not found"}}""".toByteArray(),
"application/json",
)
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
assertTrue(error is CyreneException)
// 非法名字 / 穿越 / 不存在服务端统一同码(不给探测面)⇒ 用户看到的就是本端那一句
// 「服务端上已经没有这条语音了」(**不是**把 media not found 原样抖给用户)。
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(error!!))
assertNull(repository().download(backend.mediaUrl(backend.name)).getOrNull())
}
@Test
fun `a non envelope error body is not parsed as an envelope`() = runBlocking {
// 416(Range 不可满足)由 Go 标准库直写 text/plain + Content-Range,**不套** JSON 信封
// (契约 §0.3 / §4.5)⇒ 本端不许拿 error.code 去解析它,也不能因此崩。
backend.reply(416, "invalid range".toByteArray(), "text/plain")
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
assertTrue(error is CyreneException)
val mapped = (error as CyreneException).error
assertTrue(mapped is CyreneError.Server)
assertEquals(
"非信封响应不许编出一个 code,也不许炸",
"",
(mapped as CyreneError.Server).code,
)
assertEquals(416, mapped.httpStatus)
}
@Test
fun `an unauthorized response is mapped as unauthorized`() = runBlocking {
backend.reply(
401,
"""{"error":{"code":"unauthorized","message":"no token"}}""".toByteArray(),
"application/json",
)
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
assertTrue((error as CyreneException).error is CyreneError.Unauthorized)
}
@Test
fun `an empty two hundred is a failure, never a cached empty file`() = runBlocking {
backend.reply(200, ByteArray(0), "audio/ogg")
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
assertEquals(
"0 字节必须判失败(否则下一次播放会「命中」一份什么都没有的文件)",
CyreneError.Local(CyreneError.LocalKind.Parse),
(error as CyreneException).error,
)
}
@Test
fun `a server that is not there fails and is classified as a local failure`() = runBlocking {
// 对端不在听(换服务器 / adb reverse 掉了)。
val dead = MediaRepository(
ApiClientFactory.create(
baseUrl = "http://127.0.0.1:1/",
service = MediaApi::class.java,
tokenProvider = { TOKEN },
),
)
val error = dead.download("http://127.0.0.1:1/api/v1/media/${backend.name}").exceptionOrNull()
assertTrue(error is CyreneException)
// 只要求「是本地那一档」:具体是哪一种由系统决定,不逐条钉死。
assertTrue((error as CyreneException).error is CyreneError.Local)
}
private class FakeMediaBackend {
val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
private val requests = CopyOnWriteArrayList<Request>()
private var status = 200
private var body = ByteArray(0)
private var contentType = "audio/ogg"
private val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/") { exchange -> handle(exchange) }
start()
}
val baseUrl: String = "http://127.0.0.1:${server.address.port}/"
fun mediaUrl(name: String): String = "${baseUrl}api/v1/media/$name"
fun reply(status: Int, body: ByteArray, contentType: String) {
this.status = status
this.body = body
this.contentType = contentType
}
fun lastRequest(): Request = requests.lastOrNull() ?: error("服务端一个请求都没收到")
fun close() = server.stop(0)
private fun handle(exchange: HttpExchange) {
requests += Request(
method = exchange.requestMethod,
path = exchange.requestURI.path,
authorization = exchange.requestHeaders.getFirst("Authorization"),
)
exchange.responseHeaders.add("Content-Type", contentType)
// 空体用 -1(不是 0):0 会让标准库改用 chunked,那样「0 字节」这条用例测的就不是
// 我们想测的那件事了(见 ImageCacheTest 的同名手法)。
if (body.isEmpty()) {
exchange.sendResponseHeaders(status, -1)
} else {
exchange.sendResponseHeaders(status, body.size.toLong())
}
exchange.responseBody.use { it.write(body) }
}
data class Request(val method: String, val path: String, val authorization: String?)
}
private companion object {
const val TOKEN = "fake-token-42"
}
}
@@ -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)
}
}
}
@@ -1,6 +1,7 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.VoiceDto
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -86,6 +87,35 @@ class MessageMergeTest {
assertEquals(listOf("m3"), idsOf(incoming))
}
@Test
fun `a later copy carrying voice wins over the earlier copy of the same message`() {
// 同一条消息会从**多路**到本端:落库帧、握手 `history` 回放、REST 分页、编辑帧。
// 按 id 合并时「新到的那一份胜出」是这几路唯一的收口规则——不然后到的那份白到。
// 契约 v0.97 之后 `voice` **每一条路都带**(后端 M44 `fb55769`:落库顺序 INSERT →
// 写 voice 列 → notify),所以这条规则**不再是**「语音升级的唯一通路」;
// 但它照旧必需:谁哪天把「incoming 胜出」改成「保留旧的」,编辑过的正文与任何后补字段
// 都会永远不生效。
val withoutVoice = msg("m1", 100, content = "明天几点出发")
val withVoice = withoutVoice.copy(
voice = VoiceDto(
source = "asr",
status = "transcribed",
name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
mime = "audio/ogg",
size = 12_345,
durationMs = 3_200,
url = "/api/v1/media/a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
),
)
val merged = mergeMessages(listOf(withoutVoice), listOf(withVoice))
assertEquals(1, merged.size)
assertEquals("m1", merged.single().id)
assertEquals("asr", merged.single().voice?.source)
assertEquals(3_200, merged.single().voice?.durationMs)
}
private fun msgIn(sessionId: String, id: String) = HistoryMessageDto(
id = id,
sessionId = sessionId,
@@ -0,0 +1,187 @@
package top.yeij.cyrene.data.repository
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.Before
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.domain.chat.VoiceAudio
import java.io.File
import java.nio.file.Files
/**
* 「播放前取字节」这一层([VoiceStore]):地址怎么拼、缓存怎么命中、落哪儿、失败留不留残骸。
*
* 用真临时目录 + 一个假的下载器(`suspend (String) -> Result<ByteArray>`)跑完,不需要网络也不需要
* Android([VoiceStore] 这一层的行为全是文件系统与地址拼接)。**真的走 HTTP** 那一半在
* [MediaRepositoryTest](JDK 自带服务端 + 真 OkHttp/Retrofit)。
*
* 要钉住的六件事:
* 1. **请求地址 = 当前服务器地址 + 服务端给的相对路径**(`voice.url` 逐字保留,本端不拼路径);
* 2. **重复播放一个请求都不发**(内容寻址 ⇒ 命中永不失效);
* 3. **落盘位置是 `<cacheDir>/media/`**,不在 `documents/`(那是 FileProvider 唯一暴露出去的根,
* 本片不扩大暴露面);
* 4. 名字不可信 / 地址拼不出来 ⇒ **如实失败且不发请求**(绝不退回「自己拼一条路径」);
* 5. 下载失败**不留半截文件**(否则下一次会命中一份被截断的音频);
* 6. 0 字节的残留当**未命中**(重新取),缓存目录写不进去则如实报缓存不可用。
*/
class VoiceStoreTest {
private lateinit var cacheRoot: File
@Before
fun setUp() {
cacheRoot = Files.createTempDirectory("cyrene-voice").toFile()
}
@After
fun tearDown() {
cacheRoot.deleteRecursively()
}
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
private val ogg = ByteArray(64) { it.toByte() }
private fun audio(name: String = this.name, url: String? = null) =
VoiceAudio(
name = name,
mime = "audio/ogg",
size = ogg.size.toLong(),
url = url ?: "/api/v1/media/$name",
durationMs = 3_200,
)
private fun store(
baseUrl: String = BASE_URL,
download: suspend (String) -> Result<ByteArray>,
) = VoiceStore(
download = download,
cacheDir = cacheRoot,
baseUrlProvider = { baseUrl },
)
@Test
fun `the request goes to the current server plus the path the server gave`() = runBlocking {
val requested = mutableListOf<String>()
val store = store(download = { url -> requested += url; Result.success(ogg) })
val file = store.load(audio()).getOrThrow()
assertEquals(listOf("$BASE_URL/api/v1/media/$name"), requested)
// 落盘位置:`<cacheDir>/media/<存储名>`(不是 documents/ —— 那是 FileProvider 那一层)。
assertEquals(File(VoiceStore.directory(cacheRoot), name), file)
assertEquals(ogg.size.toLong(), file.length())
}
@Test
fun `playing the same copy twice asks the server only once`() = runBlocking {
var downloads = 0
val store = store(download = { downloads++; Result.success(ogg) })
val first = store.load(audio()).getOrThrow()
val second = store.load(audio()).getOrThrow()
assertEquals("名字由内容决定 ⇒ 命中一个请求都不发", 1, downloads)
assertEquals(first, second)
}
@Test
fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
val leftover = File(VoiceStore.directory(cacheRoot), name)
leftover.parentFile!!.mkdirs()
leftover.writeBytes(ByteArray(0))
var downloads = 0
val store = store(download = { downloads++; Result.success(ogg) })
val file = store.load(audio()).getOrThrow()
assertEquals(1, downloads)
assertEquals(ogg.size.toLong(), file.length())
}
@Test
fun `an unusable server address fails without any request`() = runBlocking {
var downloads = 0
val store = store(baseUrl = "", download = { downloads++; Result.success(ogg) })
val result = store.load(audio())
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
assertEquals("拼不出可信地址就如实失败,绝不自己拼一条", 0, downloads)
}
@Test
fun `a relative path that is not a media path never becomes a request`() = runBlocking {
var downloads = 0
val store = store(download = { downloads++; Result.success(ogg) })
val result = store.load(audio(url = "/api/v1/documents/$name"))
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
assertEquals(0, downloads)
}
@Test
fun `an untrustworthy name is never turned into a cache path`() = runBlocking {
var downloads = 0
val store = store(download = { downloads++; Result.success(ogg) })
// 地址那一半照旧可信(服务端给的路径正常),不可信的是**存储名**自己:拼缓存路径的那一步
// 必须挡住它(否则能爬出 `cacheDir/media/`)。
val result = store.load(audio(name = "../../escape.ogg", url = "/api/v1/media/escape.ogg"))
assertEquals(
CyreneError.Local(CyreneError.LocalKind.Parse),
(result.exceptionOrNull() as CyreneException).error,
)
assertEquals(0, downloads)
assertFalse("名字不可信时连缓存目录都不该被碰", VoiceStore.directory(cacheRoot).exists())
}
@Test
fun `a failed download leaves no half file for the next play to believe`() = runBlocking {
var attempted = 0
val failing = store(download = {
attempted++
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
})
val first = failing.load(audio())
assertTrue(first.isFailure)
val directory = VoiceStore.directory(cacheRoot)
assertTrue(
"失败不许留下任何文件(`.part` 临时文件也要清干净)",
!directory.exists() || directory.listFiles()!!.isEmpty(),
)
// 再点一次:真的重发(不是「命中一份空文件」)。
val retry = store(download = { attempted++; Result.success(ogg) }).load(audio()).getOrThrow()
assertEquals(2, attempted)
assertEquals(ogg.size.toLong(), retry.length())
}
@Test
fun `a cache directory that cannot be written is reported as such`() = runBlocking {
// 拿一个**普通文件**当缓存目录:建目录必然失败。
val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
val store = VoiceStore(
download = { Result.success(ogg) },
cacheDir = blocked,
baseUrlProvider = { BASE_URL },
)
val result = store.load(audio())
assertTrue(result.exceptionOrNull() is DocumentCacheUnavailableException)
}
private companion object {
const val BASE_URL = "http://192.168.1.9:8080"
}
}
@@ -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))
}
}
@@ -16,6 +16,10 @@ import org.junit.Test
* 帧形状逐字取自后端 `internal/services/gateway/ws_bridge.go` 的 `toolExecutionData`
* (`tool` + `phase` 恒在,`id` 空则省略该键,`delta` 只在 `.update` 且非空时出现)与
* `streamEventData` 的类型名;这三帧**不带 `index` / `streamId`**(不属于模型流)。
*
* 新契约切片给 `.end` 补了一个**可选**的 `resultSummary`(脱敏 + 截断 ≤500 字符的成品,
* 只有这一轮真的拿到执行结果才在场)。服务端**尚未部署**它,所以「没有这个键」那一档才是
* 今天真机上的唯一路径——它必须解出 `null` 而不是报错。
*/
class WsEventToolExecutionTest {
@@ -112,6 +116,75 @@ class WsEventToolExecutionTest {
assertNull(ev.id)
}
// —— end 的可选 resultSummary(新契约切片;服务端**未部署**) ——
@Test
fun `tool execution end decodes the result summary when the backend sends one`() {
val ev = parse(
frame(
"tool_execution.end",
"""{"tool":"bash","phase":"done","id":"call_1","resultSummary":"12 行输出,退出码 0"}""",
),
) as WsEvent.ToolExecutionEnd
assertEquals("12 行输出,退出码 0", ev.resultSummary)
assertEquals("bash", ev.tool)
assertEquals("done", ev.phase)
assertEquals("call_1", ev.id)
}
@Test
fun `tool execution end without the key yields null rather than a runtime error`() {
// 服务端**当前未部署**这个键:老帧必须照旧解出来,`resultSummary` 是 `null`——
// 不是 `""`。两者在渲染上等价(都按「不画结果区」处理),但「键在不在」这件事别在下游丢掉。
val ev = parse(frame("tool_execution.end", """{"tool":"bash","phase":"done","id":"call_1"}"""))
as WsEvent.ToolExecutionEnd
assertNull(ev.resultSummary)
assertEquals("done", ev.phase)
}
@Test
fun `an empty result summary decodes to the empty string and is not invented into anything`() {
// 契约承诺不会用空串顶位;真出现了也照原样解,别在解码层替它编一个结论。
val ev = parse(
frame("tool_execution.end", """{"tool":"bash","phase":"done","resultSummary":""}"""),
) as WsEvent.ToolExecutionEnd
assertEquals("", ev.resultSummary)
}
@Test
fun `a five hundred character summary survives decoding untouched`() {
// 后端已按同一份预算截断到 ≤500 字符;本端不许再截第二刀。
val summary = "输".repeat(500)
val ev = parse(
frame("tool_execution.end", """{"tool":"bash","phase":"done","resultSummary":"$summary"}"""),
) as WsEvent.ToolExecutionEnd
assertEquals(summary, ev.resultSummary)
}
@Test
fun `only the end frame carries a result summary`() {
// 契约只给 `.end` 补了这个键:`.start` / `.update` 的领域事件上根本没有该字段。
// (多一个未知键也不该让整帧解析失败——[WsEvent.parse] 本来就是宽容的。)
val start = parse(
frame("tool_execution.start", """{"tool":"bash","phase":"running","resultSummary":"不该在这"}"""),
)
val update = parse(
frame(
"tool_execution.update",
"""{"tool":"bash","phase":"running","delta":"x","resultSummary":"不该在这"}""",
),
)
assertTrue(start is WsEvent.ToolExecutionStart)
assertEquals("bash", (update as WsEvent.ToolExecutionUpdate).tool)
assertEquals("x", update.delta)
}
// —— 回归:三帧都不能再被丢弃 / 不能读错字段 ——
@Test
@@ -0,0 +1,74 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 下行帧 `voice_output`(契约 v0.96 §3.15)。
*
* 两个容易踩空的地方在这里钉死:
* 1. **帧壳没有 `sessionId`**(契约明写,会话 id 在 `data` 里)——解析层必须把它提到帧上,
* 否则 `isForSession` 会把本帧**放行给每一个会话的 VM**(空值 = 无法判定),切到别的会话
* 时也会响一段本属于别的会话的语音;
* 2. `text` / `reply` / `provider` 三个键**一个都不能丢**:`reply` 是她说的话、`text` 是转写、
* `provider == "silent"` 是「这一轮没有音频」的唯一信号(判据在 `domain.chat`)。
*/
class WsEventVoiceOutputTest {
private val realFrame = """
{"type":"voice_output","data":{"audio":"UklGRg==","format":"wav","text":"你今天在吗","reply":"我在呢","sessionId":"web","deviceId":"voiceassist:cyrene:v1","provider":"emotiontts"}}
""".trimIndent()
@Test
fun `the real frame decodes with every payload key`() {
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
assertEquals("voice_output", event.type)
assertEquals("UklGRg==", event.audio)
assertEquals("wav", event.format)
assertEquals("你今天在吗", event.text)
assertEquals("我在呢", event.reply)
assertEquals("voiceassist:cyrene:v1", event.deviceId)
assertEquals("emotiontts", event.provider)
// 帧壳没有轮次 / 序号(这一帧不属任何流式段)。
assertNull(event.turnId)
assertNull(event.seq)
}
@Test
fun `the payload session id is lifted onto the frame so the session gate works`() {
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
assertEquals("web", event.sessionId)
assertTrue("本会话放行", event.isForSession("web"))
assertFalse("别的会话必须挡下(否则会在别的会话里响起来)", event.isForSession("web:other"))
}
@Test
fun `a silent frame keeps the marker and the text`() {
val frame = """
{"type":"voice_output","data":{"audio":"","format":"wav","text":"嗯","reply":"嗯,我在","sessionId":"web","provider":"silent"}}
""".trimIndent()
val event = WsEvent.parse(frame) as WsEvent.VoiceOutput
assertEquals("silent", event.provider)
assertEquals("", event.audio)
assertEquals("嗯,我在", event.reply)
assertEquals("web", event.sessionId)
}
@Test
fun `a frame without a payload still forms the event instead of vanishing`() {
// 坏帧(没有 data):会话归属退化成「无法判定」,但「这一帧来过」必须留下痕迹——
// 整帧丢掉就是「她回了语音而屏幕上什么都没有」,那比一张空卡片糟得多。
val event = WsEvent.parse("""{"type":"voice_output"}""") as WsEvent.VoiceOutput
assertNull(event.audio)
assertNull(event.reply)
assertEquals("", event.sessionId)
}
}
@@ -0,0 +1,52 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
/**
* 上行帧 `voice_input`(契约 v0.96 §3.15)。
*
* 两个键是**纯增量**:[WsOutbound.VoiceInput.deviceId] 与 `clientMsgId`。骨架里本来只有
* `audio` / `format` / `text` / `sessionId` 四个——那四者的线上形状在这里逐字钉死(老帧一个
* 字节都不许变),新增的两个只在有值时才出现(与后端 `omitempty` 同义)。
*
* `clientMsgId` 与文本 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条。所以真正的
* 「幂等」不在这一层(它只负责照发),而在 `ChatViewModel` + 纯函数 `voiceClientMsgIdFor`:
* 重发复用同一个键(那边有穷举单测)。
*/
class WsOutboundVoiceTest {
@Test
fun `the original four key frame is byte for byte what it was`() {
assertEquals(
"""{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web"}}""",
WsOutbound.VoiceInput(audio = "QUJD", format = "wav", sessionId = "web").toJson(),
)
}
@Test
fun `device id and client message id are carried next to the audio`() {
val frame = WsOutbound.VoiceInput(
audio = "QUJD",
format = "wav",
sessionId = "web",
deviceId = "voiceassist:cyrene:v1",
clientMsgId = "voice-9f3c",
).toJson()
val expected = """{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web","deviceId":"voiceassist:cyrene:v1","clientMsgId":"voice-9f3c"}}"""
assertEquals(expected, frame)
}
@Test
fun `omitting the idempotency key omits the key itself rather than sending null`() {
val json = WsOutbound.VoiceInput(audio = "QUJD", format = "wav").toJson()
assertFalse("省略时不该出现 clientMsgId 键:$json", json.contains("clientMsgId"))
assertFalse("省略时不该出现 deviceId 键:$json", json.contains("deviceId"))
assertNull(WsOutbound.VoiceInput(audio = "QUJD").reqId)
}
}
@@ -0,0 +1,358 @@
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.data.api.dto.DocumentRefDto
/**
* 预览内容下方那一行元数据的组装(逐档穷举)。
*
* 这一块的价值全在「**不出现**」上:一屏小字里凭空多出一项(一个假的编码、一个猜的日期、
* 一个 `--` 占位符)比少一项糟得多,而且它看起来完全正常。所以这里的断言分两类:
* 1. **有证据时逐字钉住那一项**(连限定语一起钉:`UTF-8(本端解码口径)` 少了括号里的四个字
* 就从「本端口径」变成了对文件编码的断言);
* 2. **没证据时断言它整项缺席**(`assertFalse(line.contains(...))`),而不只是断言列表长度。
*
* 另有一档是「行数」本身:空文本 / 只有换行 / `\r\n` / 结尾没有换行这四种边界都在
* [textItems] 里重算过一次(不是照抄 `PreviewSlice.shownLines`),所以要从**字节**跑起,
* 走 [DocumentPreview.slice] 这条真实的路。
*/
class PreviewMetadataTest {
// —— 行数:从预览内容算,四个边界 ——
@Test
fun `empty text is zero lines`() {
val items = textItems(DocumentPreview.slice(ByteArray(0)))
assertEquals("0 行", items.first())
}
@Test
fun `a single newline is one line not two`() {
val items = textItems(DocumentPreview.slice("\n".toByteArray()))
assertEquals("1 行", items.first())
}
@Test
fun `carriage returns do not add lines`() {
// `\r\n` 只数 `\n`:Windows 换行不该被算成两行,`\r` 单独出现也不是换行。
val items = textItems(DocumentPreview.slice("a\r\nb\r\nc".toByteArray()))
assertEquals("3 行", items.first())
}
@Test
fun `a missing trailing newline still counts the last line`() {
val items = textItems(DocumentPreview.slice("a\nb".toByteArray()))
assertEquals("2 行", items.first())
}
@Test
fun `a trailing newline does not count an extra line`() {
val items = textItems(DocumentPreview.slice("a\nb\n".toByteArray()))
assertEquals("2 行", items.first())
}
@Test
fun `a preview cut short reports both the shown and the total line count`() {
// 5000 行上限正好等于上限时不截断;这里给 5001 行,确认「显示 / 共」两个数都在。
val bytes = lines(DocumentPreviewLimits.MAX_LINES + 1).toByteArray()
val slice = DocumentPreview.slice(bytes)
assertEquals(PreviewTruncation.Lines, slice.truncatedBy)
assertEquals(
"显示 ${grouped(DocumentPreviewLimits.MAX_LINES)} 行 / 共 ${grouped(DocumentPreviewLimits.MAX_LINES + 1)} 行",
textItems(slice).first(),
)
}
@Test
fun `a complete preview says one number only`() {
val items = textItems(DocumentPreview.slice("a\nb\nc".toByteArray()))
assertFalse(items.first(), items.first().contains("共"))
assertFalse(items.first(), items.first().contains("显示"))
}
// —— 字符数:两个口径分得开 ——
@Test
fun `the server character count is shown with its scope`() {
val items = textItems(sliceOf("hello"), serverCharsLabel = "8,000 字")
assertEquals("8,000 字(服务端注入口径)", items[1])
}
@Test
fun `without a server count the local one is used and says so`() {
val items = textItems(sliceOf("你好"), serverCharsLabel = null)
assertEquals("2 字(本端预览内容)", items[1])
}
@Test
fun `the local count is code points not utf16 units`() {
// 一个 emoji 在 UTF-16 里是两个单元(代理对);「字符数」该数成 1。
val items = textItems(sliceOf("😀"), serverCharsLabel = null)
assertEquals("1 字(本端预览内容)", items[1])
}
@Test
fun `a server count of zero is still a report and is shown`() {
// `chars: 0` 是「服务端报了 0」,与「服务端没报」是两件事——不许被归一成后者。
val items = textItems(sliceOf("abc"), serverCharsLabel = DocumentChips.charsLabel(0))
assertEquals("0 字(服务端注入口径)", items[1])
}
// —— 解码口径 ——
@Test
fun `the decoding scope is always stated and never as a bare utf8`() {
val items = textItems(sliceOf("x"), serverCharsLabel = null)
assertTrue(items.toString(), items.contains("UTF-8(本端解码口径)"))
assertFalse("裸写 UTF-8 会被读成「这份文件是 UTF-8 编码的」", items.contains("UTF-8"))
}
// —— 服务端的 truncated:两态 ——
@Test
fun `the server truncation flag is spelled out when set`() {
val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = true)
assertEquals("正文被截断(进上下文的部分)", items.last())
}
@Test
fun `nothing is said about truncation when the server did not report it`() {
val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = false)
assertFalse(items.toString(), items.any { it.contains("截断") })
assertEquals(3, items.size)
}
// —— 非文本档:只有嗅探所得的 mime ——
@Test
fun `a binary file shows the sniffed mime`() {
assertEquals(listOf("application/pdf"), PreviewMetadata.fileItems("application/pdf"))
}
@Test
fun `a local draft without a server mime shows nothing at all`() {
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(null))
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(""))
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(" "))
}
@Test
fun `a binary file line never invents a placeholder`() {
val line = PreviewMetadata.join(PreviewMetadata.fileItems(null))
assertEquals("", line)
assertFalse(line.contains("未知"))
assertFalse(line.contains("--"))
}
// —— 图片:四项各自的可有可无 ——
@Test
fun `a full image line carries format, size in pixels, bytes and the shot date`() {
val items = PreviewMetadata.imageItems(
mime = "image/jpeg",
width = 4032,
height = 3024,
sizeBytes = 2_411_724,
capturedAt = "2019:07:04 20:31:05",
)
assertEquals(
listOf("image/jpeg", "4032 × 3024", "2.3 MB", "拍摄于 2019年7月4日 20:31"),
items,
)
}
@Test
fun `an image with no exif simply has no date`() {
val items = PreviewMetadata.imageItems(
mime = "image/png",
width = 100,
height = 50,
sizeBytes = 2048,
capturedAt = null,
)
assertEquals(listOf("image/png", "100 × 50", "2.0 KB"), items)
}
@Test
fun `an undecodable size is left out rather than guessed`() {
val items = PreviewMetadata.imageItems(mime = "image/gif", width = null, height = null)
assertEquals(listOf("image/gif"), items)
}
@Test
fun `a half known size is not reported`() {
// `inJustDecodeBounds` 只会两侧都给或两侧都不给;真出现半边时宁可不报。
val items = PreviewMetadata.imageItems(mime = null, width = 800, height = null)
assertEquals(emptyList<String>(), items)
}
@Test
fun `non positive sizes are not reported`() {
val items = PreviewMetadata.imageItems(mime = null, width = -1, height = 0)
assertEquals(emptyList<String>(), items)
}
@Test
fun `an unknown byte count leaves the size out`() {
val items = PreviewMetadata.imageItems(
mime = null,
sizeBytes = PreviewMetadata.UNKNOWN_BYTES,
)
assertEquals(emptyList<String>(), items)
}
@Test
fun `the image wildcard is not a format and must not be shown`() {
// 调用方在这一档传的是 null(`image/` 通配由弹层滤掉);纯函数这层再确认一遍空值不产出项。
assertEquals(emptyList<String>(), PreviewMetadata.imageItems(mime = null))
assertEquals(emptyList<String>(), PreviewMetadata.imageItems(mime = " "))
}
// —— EXIF 日期:严格到不近人情 ——
@Test
fun `a canonical exif timestamp is formatted`() {
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05"))
}
@Test
fun `the time is zero padded and the date is not`() {
assertEquals("2001年1月2日 03:04", PreviewMetadata.formatExifDateTime("2001:01:02 03:04:09"))
}
@Test
fun `surrounding whitespace is tolerated`() {
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime(" 2019:07:04 20:31:05 "))
}
@Test
fun `the all zero placeholder means unknown and is rejected`() {
assertNull(PreviewMetadata.formatExifDateTime("0000:00:00 00:00:00"))
}
@Test
fun `out of range components are rejected`() {
assertNull(PreviewMetadata.formatExifDateTime("2019:13:04 20:31:05"))
assertNull(PreviewMetadata.formatExifDateTime("2019:07:32 20:31:05"))
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 24:31:05"))
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:61:05"))
}
@Test
fun `a leap second is a valid exif value`() {
assertEquals("2016年12月31日 23:59", PreviewMetadata.formatExifDateTime("2016:12:31 23:59:60"))
}
@Test
fun `malformed values are rejected rather than repaired`() {
assertNull("另一个分隔符的形态不当成 EXIF 认", PreviewMetadata.formatExifDateTime("2019-07-04 20:31:05"))
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04T20:31:05"))
assertNull("缺秒", PreviewMetadata.formatExifDateTime("2019:07:04 20:31"))
assertNull(PreviewMetadata.formatExifDateTime("2019:7:4 20:31:05"))
assertNull(PreviewMetadata.formatExifDateTime("hello"))
assertNull(PreviewMetadata.formatExifDateTime(""))
assertNull(PreviewMetadata.formatExifDateTime(null))
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05+08:00"))
}
@Test
fun `an invalid exif date leaves the whole item out`() {
val items = PreviewMetadata.imageItems(
mime = "image/jpeg",
capturedAt = "0000:00:00 00:00:00",
)
assertEquals(listOf("image/jpeg"), items)
}
@Test
fun `exif time is read as local wall clock with no timezone conversion`() {
// 同一串数字在任何时区下都得原样出来——EXIF 里根本没有偏移量字段,换算就是凭空错几小时。
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05"))
}
// —— 拼接 ——
@Test
fun `items are joined with a middle dot`() {
assertEquals("a · b · c", PreviewMetadata.join(listOf("a", "b", "c")))
assertEquals("", PreviewMetadata.join(emptyList()))
}
// —— 走一遍真实链路:DTO → 芯片 → 这一行 ——
@Test
fun `the chip model feeds the two server facts without re-formatting them`() {
val chip = DocumentChips.of(
DocumentRefDto(
name = "0123456789abcdef0123456789abcdef.txt",
mime = "text/plain",
size = 12_345,
chars = 8_000,
truncated = true,
status = DocumentStatus.EXTRACTED,
),
)
val items = PreviewMetadata.textItems(
slice = DocumentPreview.slice("a\nb".toByteArray()),
serverCharsLabel = chip.charsLabel,
contextTruncated = chip.truncatedLabel != null,
)
assertEquals(
listOf("2 行", "8,000 字(服务端注入口径)", "UTF-8(本端解码口径)", "正文被截断(进上下文的部分)"),
items,
)
}
@Test
fun `a draft attachment only has the local half`() {
val chip = DocumentChips.of(
DraftDocument(uri = "content://x/y.txt", displayName = "notes.txt", sizeBytes = 1024),
)
val items = PreviewMetadata.textItems(
slice = DocumentPreview.slice("hello\n".toByteArray()),
serverCharsLabel = chip.charsLabel,
contextTruncated = chip.truncatedLabel != null,
)
assertEquals(listOf("1 行", "6 字(本端预览内容)", "UTF-8(本端解码口径)"), items)
}
private fun textItems(
slice: PreviewSlice,
serverCharsLabel: String? = null,
contextTruncated: Boolean = false,
) = PreviewMetadata.textItems(slice, serverCharsLabel, contextTruncated)
private fun sliceOf(text: String): PreviewSlice = DocumentPreview.slice(text.toByteArray())
private fun lines(count: Int): String = (1..count).joinToString("\n") { "line $it" } + "\n"
private fun grouped(value: Int): String = String.format(java.util.Locale.ROOT, "%,d", value)
}
@@ -0,0 +1,234 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 语音**上行**那一半的纯函数(契约 v0.96 §3.15 ①):录音参数、WAV 封装、体积闸门、幂等键。
*
* 这一组钉的是「发出去的字节长什么样」——WAV 头一旦有一个字段写错,服务端的解码器与 STT
* 会**静默**给出垃圾(或直接拒),而客户端这边完全看不出问题。所以头的每个字段都在这里逐个
* 读出来断言,而不是只比长度。
*/
class VoiceClipTest {
// —— 换算:16 kHz / 单声道 / 16-bit ⇒ 1 ms = 32 字节 ——
@Test
fun `one second of pcm is 32 000 bytes at the chosen parameters`() {
assertEquals(32_000, VoiceClip.BYTES_PER_SECOND)
assertEquals(32_000, VoiceClip.pcmBytesFor(1_000))
assertEquals(1_000, VoiceClip.durationMillisOf(32_000))
}
@Test
fun `duration and byte count round trip for typical clips`() {
assertEquals(1_920_000, VoiceClip.MAX_PCM_BYTES)
assertEquals(VoiceClip.MAX_RECORDING_MILLIS, VoiceClip.durationMillisOf(VoiceClip.MAX_PCM_BYTES))
// 不足一毫秒的零头一律向下取整(绝不把 0 字节说成 1 毫秒)。
assertEquals(0, VoiceClip.durationMillisOf(31))
assertEquals(0, VoiceClip.durationMillisOf(0))
assertEquals(0, VoiceClip.durationMillisOf(-5))
assertEquals(0, VoiceClip.pcmBytesFor(-5))
}
// —— WAV 头:逐字段 ——
@Test
fun `the wav header carries the riff wav fmt data chunks of pcm`() {
val pcmBytes = 32_000
val header = VoiceClip.wavHeader(pcmBytes)
assertEquals("RIFF-WAVE 头恒为 44 字节", 44, header.size)
assertEquals("RIFF", header.ascii(0, 4))
// RIFF 的 size 字段 = 文件总长 - 8(不含 "RIFF" 与 size 自己)。
assertEquals(44 - 8 + pcmBytes, header.intLe(4))
assertEquals("WAVE", header.ascii(8, 4))
assertEquals("fmt ", header.ascii(12, 4))
assertEquals("fmt 子块长度 = 16(PCM)", 16, header.intLe(16))
assertEquals("audioFormat = 1(PCM)", 1, header.shortLe(20))
assertEquals("单声道", 1, header.shortLe(22))
assertEquals("采样率 16 kHz", 16_000, header.intLe(24))
assertEquals("字节率 = 采样率 × 声道 × 位深/8", 32_000, header.intLe(28))
assertEquals("块对齐 = 声道 × 位深/8", 2, header.shortLe(32))
assertEquals("16-bit", 16, header.shortLe(34))
assertEquals("data", header.ascii(36, 4))
assertEquals("data 子块长度 = PCM 字节数", pcmBytes, header.intLe(40))
}
@Test
fun `toWav length is header plus pcm and the payload is untouched`() {
val pcm = ByteArray(64) { (it + 1).toByte() }
val wav = VoiceClip.toWav(pcm)
assertEquals("字节数 = 44 + PCM", 44 + pcm.size, wav.size)
assertEquals(44 - 8 + pcm.size, wav.intLe(4))
assertEquals(pcm.size, wav.intLe(40))
// 头之后一个字节都不许动(重采样 / 量化是录音机的事,这里只搬字节)。
assertArrayEquals(pcm, wav.copyOfRange(44, wav.size))
}
@Test
fun `an empty pcm still yields a self consistent 44 byte wav`() {
val wav = VoiceClip.toWav(ByteArray(0))
assertEquals(44, wav.size)
assertEquals(44 - 8, wav.intLe(4))
assertEquals(0, wav.intLe(40))
assertEquals("RIFF", wav.ascii(0, 4))
assertEquals("data", wav.ascii(36, 4))
}
// —— 准入:空 / 太短 / 超限 / 可发 ——
@Test
fun `an empty recording is rejected as empty rather than too short`() {
assertEquals(VoiceClipPlan.Empty, VoiceClip.plan(ByteArray(0)))
assertEquals(VOICE_EMPTY_MESSAGE, voiceClipRejection(VoiceClipPlan.Empty))
}
@Test
fun `a few milliseconds of audio is rejected as too short`() {
// 100 字节 = 3 ms:按下就松手的那一下。
val plan = VoiceClip.plan(ByteArray(100))
assertTrue("极短录音判 TooShort,实际 $plan", plan is VoiceClipPlan.TooShort)
assertEquals(3, (plan as VoiceClipPlan.TooShort).durationMs)
assertEquals(VOICE_TOO_SHORT_MESSAGE, voiceClipRejection(plan))
}
@Test
fun `exactly one second is the shortest acceptable clip`() {
val plan = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(1_000)))
assertTrue("满一秒就该放行,实际 $plan", plan is VoiceClipPlan.Ready)
assertEquals(1_000, (plan as VoiceClipPlan.Ready).durationMs)
assertEquals(44 + 32_000, plan.wav.size)
}
@Test
fun `one millisecond under the minimum is still rejected`() {
val pcm = ByteArray(VoiceClip.pcmBytesFor(1_000) - 32) // 32 字节 = 1 ms
val plan = VoiceClip.plan(pcm)
assertTrue("999 ms 仍算太短,实际 $plan", plan is VoiceClipPlan.TooShort)
assertEquals(999, (plan as VoiceClipPlan.TooShort).durationMs)
}
// —— 体积闸门(含「等于」那一格)——
@Test
fun `the byte ceiling equals the server's decoded inline cap`() {
// 与 chat.image 解码后同一条线:契约没给 voice_input 的上限,本端按已知最严的那一档设计。
assertEquals(ChatImageBudget.SERVER_MAX_DECODED_BYTES, VoiceClip.MAX_AUDIO_BYTES)
assertEquals(5 * 1024 * 1024, VoiceClip.MAX_AUDIO_BYTES)
}
@Test
fun `a clip exactly at the ceiling is accepted`() {
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES)
val total = VoiceClip.totalBytesFor(pcm.size)
assertEquals("上界取等于(<=)", VoiceClip.MAX_AUDIO_BYTES, total)
assertTrue(VoiceClip.withinLimit(total))
assertTrue("恰好到顶应当放行", VoiceClip.plan(pcm) is VoiceClipPlan.Ready)
}
@Test
fun `a clip one byte over the ceiling is refused with a named reason`() {
// 43 字节:total = 44 + 43 = MAX + 1。
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES + 1)
val total = VoiceClip.totalBytesFor(pcm.size)
assertEquals(VoiceClip.MAX_AUDIO_BYTES + 1, total)
assertFalse(VoiceClip.withinLimit(total))
val plan = VoiceClip.plan(pcm)
assertTrue("超限判 TooLarge,实际 $plan", plan is VoiceClipPlan.TooLarge)
assertEquals(total, (plan as VoiceClipPlan.TooLarge).totalBytes)
assertEquals(VOICE_TOO_LARGE_MESSAGE, voiceClipRejection(plan))
}
@Test
fun `a 60 second recording stays well under the ceiling`() {
// 录满长度只用到闸门的 36%:撞上它说明硬件给了异常的量,不是正常路径。
val total = VoiceClip.totalBytesFor(VoiceClip.MAX_PCM_BYTES)
assertTrue(VoiceClip.withinLimit(total))
assertEquals(1_920_044, total)
}
@Test
fun `the rejection of a sendable clip is null`() {
val ready = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(2_000)))
assertNull(voiceClipRejection(ready))
}
// —— 录音中的时长文案 ——
@Test
fun `the elapsed label starts at zero and pads seconds`() {
assertEquals("0:00", voiceElapsedLabel(0))
assertEquals("0:00", voiceElapsedLabel(999))
assertEquals("0:07", voiceElapsedLabel(7_000))
assertEquals("1:05", voiceElapsedLabel(65_000))
// 负数(时钟回拨 / 调用方算错)不显示成 -1:-1。
assertEquals("0:00", voiceElapsedLabel(-1_000))
}
// —— 幂等键:重发必须复用(契约 v0.96:同一个非空值只承认第一条)——
@Test
fun `a fresh recording mints a new prefixed key`() {
val key = voiceClientMsgIdFor(null) { "9f3c" }
assertEquals("voice-9f3c", key)
assertTrue("前缀写进键里,好让服务端与日志一眼看出这条是语音", key.startsWith(VOICE_CLIENT_MSG_ID_PREFIX))
}
@Test
fun `retrying the same clip reuses its client message id`() {
val draft = VoiceDraftInfo(clientMsgId = "voice-keep", durationMs = 1_800, totalBytes = 57_644)
// 用户连点三次「重发」:每一次都必须拿同一个键(否则服务端会把同一句话答三遍)。
val attempts = List(3) {
voiceClientMsgIdFor(draft) { error("重发不该铸新键") }
}
assertEquals(listOf("voice-keep", "voice-keep", "voice-keep"), attempts)
}
@Test
fun `a different recording never inherits the old key`() {
val old = VoiceDraftInfo("voice-old", durationMs = 1_200, totalBytes = 38_444)
// 丢弃之后再录一段:状态里那一格已经清掉 ⇒ 现铸新键(否则新录音会被服务端当成重发吞掉)。
val fresh = voiceClientMsgIdFor(null) { "fresh" }
assertEquals("voice-fresh", fresh)
assertTrue(fresh != old.clientMsgId)
}
}
/** 从字节数组里取一段 ASCII(断言 WAV 头的四个 chunk 名用)。 */
private fun ByteArray.ascii(offset: Int, length: Int): String =
String(this, offset, length, Charsets.US_ASCII)
/** 小端有符号整数(WAV 头里全是 32 位小端)。 */
private fun ByteArray.intLe(offset: Int): Int =
(this[offset].toInt() and 0xFF) or
((this[offset + 1].toInt() and 0xFF) shl 8) or
((this[offset + 2].toInt() and 0xFF) shl 16) or
((this[offset + 3].toInt() and 0xFF) shl 24)
/** 小端 16 位无符号整数。 */
private fun ByteArray.shortLe(offset: Int): Int =
(this[offset].toInt() and 0xFF) or ((this[offset + 1].toInt() and 0xFF) shl 8)
@@ -0,0 +1,342 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
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.data.api.dto.VoiceDto
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.VoiceTooLargeException
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
import top.yeij.cyrene.ui.chat.isContentlessUserMessage
/**
* 语音消息的**呈现判据**(纯函数,接收侧)。
*
* 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,六条口径逐条钉在这里:
* 1. **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**(`isVoiceMessage`)——**绝不**从正文里
* 认媒体类型(正文里可能出现用户自己打出的 `[语音]` 字样),也绝不把 `failed` 行画成语音形态
* (那一行没有可播的声音,见 `isPlayableVoice` / `hasVoice`);
* 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全);
* 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充;
* 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示;
* 5. **`failed` 行多一行灰色小标**(`voiceFallbackNote`):固定前半句 + 服务端 `fallbackReason` 原样;
* 6. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**,
* 绝不退回「自己拼一条 `/api/v1/media/<name>`」。
*/
class VoiceMessageTest {
private fun message(
content: String = "",
role: String = "user",
voice: VoiceDto? = null,
) = HistoryMessageDto(
id = "msg-1",
sessionId = "web",
role = role,
content = content,
voice = voice,
createdAt = 1_791_088_571_785L,
)
private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
private fun voice(
source: String = VoiceSource.ASR,
status: String = VoiceStatus.TRANSCRIBED,
name: String? = copyName,
mime: String? = "audio/ogg",
size: Long? = 12_345,
durationMs: Int? = 3_200,
fallbackReason: String? = null,
url: String? = "/api/v1/media/$copyName",
) = VoiceDto(
source = source,
status = status,
name = name,
mime = mime,
size = size,
durationMs = durationMs,
fallbackReason = fallbackReason,
url = url,
)
// —— 1. 「来自一条语音」的判据 = 键存在(`hasVoice`)——
@Test
fun `the voice key is the only criterion for coming from a voice`() {
assertTrue(hasVoice(message(voice = voice())))
assertFalse(hasVoice(message(content = "在的")))
// 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发,
// 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。
assertFalse(hasVoice(message(content = "[语音]")))
assertFalse(hasVoice(message(content = "语音")))
assertTrue(hasVoice(message(content = "[语音]", voice = voice())))
// 「要不要画语音形态」是**另一个**问题(见下面第 4b 节),但键在是它的前提。
assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice())))
}
// —— 2. 四者同生共死 ⇒ 能不能播 ——
@Test
fun `an audio copy is playable only when all four keys are there`() {
val audio = voiceAudio(voice())!!
assertEquals(copyName, audio.name)
assertEquals("audio/ogg", audio.mime)
assertEquals(12_345L, audio.size)
assertEquals("/api/v1/media/$copyName", audio.url)
assertEquals(3_200, audio.durationMs)
}
@Test
fun `a missing key on any of the four makes the copy unplayable`() {
// 四者同生共死:缺任何一个 ⇒ 一个播放控件都不画(不能给一个点下去必然失败的按钮)。
assertNull(voiceAudio(voice(name = null)))
assertNull(voiceAudio(voice(mime = null)))
assertNull(voiceAudio(voice(size = null)))
assertNull(voiceAudio(voice(url = null)))
// 空白与 null 同等对待(空白串拼出来是一条没有名字的地址)。
assertNull(voiceAudio(voice(name = " ")))
assertNull(voiceAudio(voice(mime = "")))
assertNull(voiceAudio(voice(url = " ")))
}
@Test
fun `an absent voice key has no copy and a zero byte copy is still a copy`() {
assertNull(voiceAudio(null))
// size 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西。
assertEquals(0L, voiceAudio(voice(size = 0))!!.size)
}
@Test
fun `a voice without a copy keeps its duration`() {
// 微信那条路:`source = platform`、四个副本键全缺 ⇒ 不可播,但时长照留(渠道给了)。
val platform = voice(
source = VoiceSource.PLATFORM,
name = null,
mime = null,
size = null,
url = null,
durationMs = 5_000,
)
assertNull(voiceAudio(platform))
assertEquals("0:05", voiceDurationLabel(platform.durationMs))
}
// —— 3. 时长 ——
@Test
fun `a missing duration shows nothing at all`() {
// 「缺席就不显示时长」:不拿 size / 码率反算,也不显示 `0:00` 冒充。
assertNull(voiceDurationLabel(null))
assertNull(voiceDurationLabel(0))
assertNull(voiceDurationLabel(-1))
// 不足一秒时**向下取整会发生什么**:100ms 会变成 `0:00` —— 那正是「冒充」,
// 所以这一条也当缺席(契约里不存在 0 秒的语音)。
assertNull(voiceDurationLabel(100))
}
@Test
fun `a duration is formatted as minutes and seconds`() {
assertEquals("0:01", voiceDurationLabel(1_000))
assertEquals("0:03", voiceDurationLabel(3_200))
assertEquals("0:59", voiceDurationLabel(59_999))
assertEquals("1:00", voiceDurationLabel(60_000))
assertEquals("1:05", voiceDurationLabel(65_000))
assertEquals("10:00", voiceDurationLabel(600_000))
// 不折算成小时制:语音消息不会长到那个量级,多一种表达就多一种自己发明的说法。
assertEquals("124:03", voiceDurationLabel(7_443_000))
}
// —— 4. 三态 status 在界面上多说的那一句 ——
@Test
fun `only an empty transcription with blank content gets the extra line`() {
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, ""))
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, " "))
// transcribed:正文就是转写文本,多一句都是噪声。
assertNull(voiceNote(VoiceStatus.TRANSCRIBED, "明天几点出发"))
// failed:正文**就是**服务端那行诚实提示,本端一个字都不改、也不添油加醋。
assertNull(voiceNote(VoiceStatus.FAILED, "这段语音没能转成文字(未配置语音识别)"))
// 认不出的 status:不猜(与 failed 同形:只显示正文)。
assertNull(voiceNote("", ""))
assertNull(voiceNote("future-status", ""))
// 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. 相对路径接到当前服务器地址 ——
@Test
fun `a server relative path is rebased onto the current server`() {
assertEquals(
"http://192.168.1.9:8080/api/v1/media/$copyName",
voiceMediaUrl("/api/v1/media/$copyName", "http://192.168.1.9:8080"),
)
// 尾斜杠、带路径的 baseUrl、大小写 scheme:都按「scheme://authority」处理(与 ImageHostRebase 同)。
assertEquals(
"http://host:8080/api/v1/media/$copyName",
voiceMediaUrl("/api/v1/media/$copyName", "http://host:8080/"),
)
assertEquals(
"HTTPS://host/api/v1/media/$copyName",
voiceMediaUrl("/api/v1/media/$copyName", "HTTPS://host/cyrene"),
)
}
@Test
fun `an unusable base url yields nothing instead of a guessed path`() {
// 拼不出可信地址就回 null(调用方据此如实报「暂时播不了」),**绝不**退回自己拼一条路径。
assertNull(voiceMediaUrl("/api/v1/media/$copyName", ""))
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "192.168.1.9:8080"))
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "http://"))
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "ftp://host"))
}
@Test
fun `a path that is not exactly one media name is refused`() {
// 只看服务端给的那条路径:不是「恰好一段 /api/v1/media/<name>」就不接(看不懂的不改、不发)。
assertNull(voiceMediaUrl("/api/v1/files/$copyName", "http://host"))
assertNull(voiceMediaUrl("/api/v1/media/", "http://host"))
assertNull(voiceMediaUrl("/api/v1/media/.", "http://host"))
assertNull(voiceMediaUrl("/api/v1/media/..", "http://host"))
assertNull(voiceMediaUrl("/api/v1/media/a/../b.ogg", "http://host"))
assertNull(voiceMediaUrl("/api/v1/media/%2e%2e%2fsecret", "http://host"))
assertNull(voiceMediaUrl("/api/v1/media/a.ogg?token=x", "http://host"))
assertNull(voiceMediaUrl("/api/v1/media/a.ogg#f", "http://host"))
assertNull(voiceMediaUrl("http://evil/api/v1/media/a.ogg", "http://host"))
}
// —— 失败文案 ——
@Test
fun `a failure always says something, and says the server's own words when it has them`() {
// 服务端有话说(404 media_not_found 的两种情形同码)⇒ 只说本端能确定的那半句。
val notFound = CyreneException(
CyreneError.Server(code = "media_not_found", message = "media not found", httpStatus = 404),
)
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(notFound))
// 其它服务端错 / 本地网络错:原样带上服务端那句(或本端的分类说明),**绝不静默**。
assertEquals(
"语音加载失败,服务器开小差了",
voicePlaybackFailureMessage(
CyreneException(CyreneError.Server(code = "x", message = "服务器开小差了", httpStatus = 500)),
),
)
assertEquals(
"语音加载失败,网络不可用,请检查连接",
voicePlaybackFailureMessage(
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
),
)
assertEquals("当前服务器地址不可用,暂时播不了这条语音", voicePlaybackFailureMessage(VoiceUrlUnavailableException()))
assertEquals("这条语音太大,本机放不了", voicePlaybackFailureMessage(VoiceTooLargeException()))
assertEquals(
"本地缓存不可用,暂时播不了这条语音",
voicePlaybackFailureMessage(DocumentCacheUnavailableException()),
)
assertEquals("语音加载失败,请稍后重试", voicePlaybackFailureMessage(IllegalStateException("boom")))
}
// —— 与「这条消息什么都没有」那条既有判据的交界 ——
@Test
fun `a voice message is never reported as contentless`() {
// 语音的正文可能为空(status = empty,或服务端那行提示没到),但那条消息**不是什么都没有**
// ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。
val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null))
assertFalse(isContentlessUserMessage(empty))
// `failed` 那一档同样不成立,只是理由不同:它**没有**语音形态,但有那行降级小标
// (见 `voiceFallbackNote`)—— 同样不是「什么都没有」。判据因此挂在 `hasVoice` 上而**不是**
// `isVoiceMessage` 上:两种落点都画得出东西。
val failed = message(
content = "",
voice = voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED, fallbackReason = "tts_timeout"),
)
assertFalse(isContentlessUserMessage(failed))
// 对照:同样空的正文,没有 voice 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。
assertTrue(isContentlessUserMessage(message(content = "")))
}
}
@@ -0,0 +1,86 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `voice_output` 的呈现判据(契约 v0.96 §3.15):两件事必须判对,否则 UI 一定画错。
*
* 1. **这一轮有没有真音频**——`provider == "silent"` 是明文降级信号(`audio` 空、`text`/`reply`
* 有)。判错成「有」就会画出一个点下去必然失败的播放器;判错成「没有」则让本来能放的语音
* 变成一段文字。
* 2. **屏幕上该显示哪一句**——`reply` 是**她说的话**(音频念的就是它),`text` 是**转写**
* (我们那段音频被识别成什么)。两者的顺序反过来,用户看到的就是自己说的话冒充她的回答。
*/
class VoiceOutputTest {
// —— provider 降级信号 ——
@Test
fun `the silent provider is recognised literally`() {
assertTrue(isSilentVoiceProvider(VoiceProvider.SILENT))
// 机器可读码:容忍首尾空白与大小写,但不容忍别的词。
assertTrue(isSilentVoiceProvider(" SILENT "))
assertFalse(isSilentVoiceProvider("emotiontts"))
assertFalse(isSilentVoiceProvider(""))
assertFalse(isSilentVoiceProvider(null))
}
@Test
fun `silent frames never count as having audio`() {
// 契约:silent ⇒ audio 空。万一服务端还是塞了字节,也不能画播放器(它自己说这轮没发音)。
assertFalse(voiceOutputHasAudio(audio = null, provider = VoiceProvider.SILENT))
assertFalse(voiceOutputHasAudio(audio = "QUJD", provider = VoiceProvider.SILENT))
}
@Test
fun `a normal provider needs actual bytes to be playable`() {
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = "emotiontts"))
// 没给 provider(老服务端 / 省略该键)、但有字节 ⇒ 能放。
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = null))
// 有 provider 却没字节 ⇒ 没有音频(空串与 null 同一档)。
assertFalse(voiceOutputHasAudio(audio = "", provider = "emotiontts"))
assertFalse(voiceOutputHasAudio(audio = null, provider = "emotiontts"))
}
// —— text vs reply ——
@Test
fun `reply wins because it is what she actually said`() {
assertEquals("我在呢", voiceReplyText(reply = "我在呢", text = "你今天在吗"))
}
@Test
fun `a missing reply falls back to the transcript`() {
assertEquals("你今天在吗", voiceReplyText(reply = null, text = "你今天在吗"))
assertEquals("你今天在吗", voiceReplyText(reply = " ", text = "你今天在吗"))
}
@Test
fun `both missing means show nothing instead of inventing a line`() {
assertNull(voiceReplyText(reply = null, text = null))
assertNull(voiceReplyText(reply = "", text = " "))
}
// —— 那一句说明:两档说的不是同一件事 ——
@Test
fun `the silent note says only text this round`() {
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = false))
// silent 档即便本地碰巧有个文件也不该说「本机没准备好」。
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = true))
}
@Test
fun `a local decode failure is reported as a local problem not as silence`() {
assertEquals(VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE, voiceReplyNote(silent = false, hasAudioFile = false))
}
@Test
fun `a playable reply needs no note`() {
assertNull(voiceReplyNote(silent = false, hasAudioFile = true))
}
}
@@ -0,0 +1,152 @@
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
/**
* 语音播放通道的状态机(纯函数)。
*
* 它要钉住的是**跨气泡**的那几条事实——少了任何一条,真机上就会出现「两条一起响」、
* 「界面显示 A 在响而喇叭里是 B」或者「A 的失败提示挂在 B 上」:
*
* 1. 再点一下同一目标 = 停(**加载中也算**:用户不该被慢下载困住);
* 2. 点另一条 = 从它重新开始(旧的那条由调用方停下并释放,纯函数只说状态变成什么);
* 3. **迟到的回调不许改状态**:`prepared` / `failed` / `finished` 三个转换都只认「目标就是当前
* 这一条」,而且 `prepared` 只从 `Loading` 出发(正在响的那一条不会被一个迟到的失败说成失败);
* 4. `Failed` 不粘:再点一次就是重试(回 `Loading`);
* 5. `silenced`(停下 / 换会话 / 组件销毁)**收所有态**,包括 `Failed` 那句提示。
*/
class VoicePlaybackTest {
private val a = "msg-a"
private val b = "msg-b"
// —— 1 / 2. 按下与换目标 ——
@Test
fun `pressing from idle starts loading that message`() {
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Idle.pressed(a))
}
@Test
fun `pressing the message that is playing stops it`() {
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).pressed(a))
}
@Test
fun `pressing the message that is loading cancels it`() {
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).pressed(a))
}
@Test
fun `pressing another message switches to it`() {
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Playing(a).pressed(b))
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(a).pressed(b))
}
@Test
fun `pressing a failed message retries it`() {
val failed = VoicePlaybackState.Failed(a, "网络不可用")
assertFalse("失败不算「占着这条」,否则再点一下只会被当成「停」", failed.isActiveFor(a))
assertEquals(VoicePlaybackState.Loading(a), failed.pressed(a))
}
// —— 3. 迟到的回调 ——
@Test
fun `prepared only moves the message that was loading`() {
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Loading(a).prepared(a))
// 用户已经点了别的 / 点了停:迟到的「准备好」不许把界面拽回去。
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).prepared(a))
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.prepared(a))
// 正在响的那一条收到 prepared 也原样(它已经在响了)。
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Playing(a).prepared(a))
}
@Test
fun `failed only moves the message that was loading`() {
assertEquals(VoicePlaybackState.Failed(a, "坏了"), VoicePlaybackState.Loading(a).failed(a, "坏了"))
// 正在响的那一条不该被一个迟到的失败回调说成失败(解码错误的真实顺序是「先停再报」)。
assertEquals(
VoicePlaybackState.Playing(a),
VoicePlaybackState.Playing(a).failed(a, "坏了"),
)
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).failed(a, "坏了"))
}
@Test
fun `finished only clears the message that was playing`() {
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).finished(a))
assertEquals(VoicePlaybackState.Playing(b), VoicePlaybackState.Playing(b).finished(a))
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Loading(a).finished(a))
}
// —— 4 / 5. 停下来收所有态 ——
@Test
fun `silencing collects every state including a failure note`() {
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.silenced)
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).silenced)
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).silenced)
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Failed(a, "坏了").silenced)
}
@Test
fun `a silenced channel is active for nobody`() {
assertFalse(VoicePlaybackState.Idle.silenced.isActiveFor(a))
assertTrue(VoicePlaybackState.Loading(a).isActiveFor(a))
assertTrue(VoicePlaybackState.Playing(a).isActiveFor(a))
}
// —— 视图查询(气泡据此决定画什么)——
@Test
fun `only playing counts as playing`() {
assertTrue(VoicePlaybackState.Playing(a).isPlayingFor(a))
assertFalse("加载中不算在响", VoicePlaybackState.Loading(a).isPlayingFor(a))
assertFalse(VoicePlaybackState.Playing(b).isPlayingFor(a))
}
@Test
fun `loading and playing are two different questions`() {
// 界面那一句「加载中…」只能在**真的在取字节**时出现(`isActiveFor` 会把「正在响」也算进来,
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」)。
assertTrue(VoicePlaybackState.Loading(a).isLoadingFor(a))
assertFalse(VoicePlaybackState.Playing(a).isLoadingFor(a))
assertFalse(VoicePlaybackState.Loading(b).isLoadingFor(a))
assertFalse(VoicePlaybackState.Idle.isLoadingFor(a))
assertFalse(VoicePlaybackState.Failed(a, "坏了").isLoadingFor(a))
}
@Test
fun `a failure note is only reported for its own message`() {
val failed = VoicePlaybackState.Failed(a, "这条语音太大,本机放不了")
assertEquals("这条语音太大,本机放不了", failed.failureOf(a))
assertNull(failed.failureOf(b))
assertNull(VoicePlaybackState.Idle.failureOf(a))
assertNull(VoicePlaybackState.Playing(a).failureOf(a))
}
// —— 一条完整的用户路径 ——
@Test
fun `a full press load failure retry stop path`() {
var state: VoicePlaybackState = VoicePlaybackState.Idle
state = state.pressed(a)
assertEquals(VoicePlaybackState.Loading(a), state)
state = state.failed(a, "语音加载失败,请稍后重试")
assertEquals("失败提示挂在这一条上", "语音加载失败,请稍后重试", state.failureOf(a))
state = state.pressed(a)
assertEquals("重试清掉上一句", null, state.failureOf(a))
state = state.prepared(a)
assertEquals(VoicePlaybackState.Playing(a), state)
state = state.finished(a)
assertEquals("放完回初始外观,不留痕迹", VoicePlaybackState.Idle, state)
}
}
@@ -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,
)
}
@@ -68,6 +68,348 @@ class ChatContentParserTest {
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].tail)
}
// ------------------------------------------------------------------
// `<br>` 分隔符(契约 v0.89 更正:与 `<p>` 同级的气泡/消息分隔符,不是行内标签)
// ------------------------------------------------------------------
/** 契约表第一行:`<p>甲<br>乙</p>` ⇒ **两条**气泡。 */
@Test
fun lineBreakInsideParagraphSplitsBubbles() {
val bubbles = parse("<p>甲<br>乙</p>")
assertEquals(2, bubbles.size)
assertEquals(listOf("甲", "乙"), bubbles.map { it.body })
assertEquals(listOf(ContentPart.Text("甲"), ContentPart.Text("乙")), bubbles.flatMap { it.parts })
}
/** 契约表第二行:没有 `<p>` 时同理 ⇒ 两条气泡。 */
@Test
fun lineBreakWithoutParagraphAlsoSplitsBubbles() {
assertEquals(listOf("甲", "乙"), parse("甲<br>乙").map { it.body })
assertEquals(listOf("甲", "乙", "丙"), parse("甲<br>乙<br>丙").map { it.body })
}
/** 契约表第三行:`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`(**空气泡照旧丢弃**,与既有 `<p>` 口径一致)。 */
@Test
fun emptyBubblesBetweenLineBreaksAreDropped() {
assertEquals(listOf("甲", "乙"), parse("<p>甲<br><br>乙</p>").map { it.body })
assertEquals(listOf("甲", "乙"), parse("甲<br><br>乙").map { it.body })
assertEquals(listOf("甲", "乙"), parse("甲<br>\n<br>乙").map { it.body })
assertTrue(parse("<p><br></p>").isEmpty())
assertTrue(parse("<br>").isEmpty())
assertTrue(parse("<br><br>").isEmpty())
}
/** 契约表第四行:`<br>` 两侧都是空白 ⇒ 不产生多余空气泡(只吃空白,正文一个字不掉)。 */
@Test
fun whitespaceAroundLineBreakProducesNoExtraBubble() {
assertEquals(listOf("甲", "乙"), parse("甲 <br> 乙").map { it.body })
assertEquals(listOf("甲", "乙"), parse(" 甲<br>乙 ").map { it.body })
assertEquals(listOf("甲"), parse(" <br>甲").map { it.body })
assertEquals(listOf("甲"), parse("甲<br> ").map { it.body })
assertEquals(listOf("甲", "乙"), parse("<p>甲<br> </p><p>乙</p>").map { it.body })
}
/** 三种写法等价(`<br>` / `<br/>` / `</br>`),带属性一样算,`<br >` 这种空白写法也收。 */
@Test
fun lineBreakFormsAreEquivalent() {
for (form in listOf("<br>", "<br/>", "<br />", "</br>", "</br/>", "<br >", "<br class=x>")) {
assertEquals("「$form」应当是一样的一条分隔", listOf("甲", "乙"), parse("甲${form}乙").map { it.body })
assertEquals("「$form」在 `<p>` 里同理", listOf("甲", "乙"), parse("<p>甲${form}乙</p>").map { it.body })
}
}
/**
* 大小写不敏感(`<BR>` 也分隔):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,且后端 IM
* 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 原话)。
*/
@Test
fun lineBreakTagIsCaseInsensitive() {
for (form in listOf("<BR>", "<Br>", "</BR>", "<bR/>", "<BR class=x>")) {
assertEquals("「$form」应当是一样的一条分隔", listOf("甲", "乙"), parse("甲${form}乙").map { it.body })
}
}
/** 名字后面不是 `>`/空白/`/` 的都不是分隔符:`<br2>`、`<boolean>`、`<br-x>` 只是普通文本。 */
@Test
fun lookalikeTagsAreNotLineBreaks() {
for (raw in listOf("<br2>甲</br2>", "<boolean>甲</boolean>", "<br-x>甲</br-x>", "<br/x>甲", "1 < 2")) {
assertEquals("「$raw」不是分隔符", listOf(raw), parse(raw).map { it.body })
}
}
// ------------------------------------------------------------------
// `<br>` 的**保护区**(`回执三十三 §三②`:只有普通文本层的 `<br>` 才分隔)
//
// `回执三十一` 说的是「**始终**处理为多条消息隔断」,`回执三十三` 把它收紧了:`<code>` 与角色
// 注记**内部**的不分隔。后端给的两条理由在我们这边同样成立:
// 1. `<code>` 是**成对**元素,从中间切开必然两半都不平衡 ⇒ 按硬口径「不平衡 ⇒ 不渲染」
// **整段降级为字面量**,用户直接看到裸标签;
// 2. `<action>她<br>点点头</action>` 若切成两段,就成了「她」「点点头」**两条聊天消息**
// ——等于把注记正文当正文发了出去。
// 口径与理由的全文写在 `ChatContentParser.appendRegion` / `protectedSpans` 的 KDoc 里。
// ------------------------------------------------------------------
/**
* `回执三十三 §三②` 第一半:**`<code>` 内部的 `<br>` 不分隔**,且作用域不能被切坏。
*
* 我们定的口径:作用域里的 `<br>` 就是**普通字符、逐字原样**——与 `<code><b>甲</b></code>` 里的
* `<b>` 同一规矩(`codeContentIsNotParsed` 钉着)。所以这条同时钉三件事:**只有一条气泡**、
* `<code>` 那一对**被消费掉**(正文里没有裸 `<code>` ⇒ 作用域没被降级)、内容**逐字保留**
* (含那个字面的 `<br>`,字符一个不丢)。
*/
@Test
fun lineBreakInsideCodeDoesNotSplitAndTheScopeSurvives() {
val bubbles = parse("<p><code>甲<br>乙</code></p>")
assertEquals("`<code>` 里的 `<br>` 不该分隔", 1, bubbles.size)
assertEquals("甲<br>乙", bubbles.single().body)
assertEquals(listOf(run("甲<br>乙", InlineStyle.Code)), bubbles.single().runs)
assertFalse("`<code>` 作用域不该被降级成裸标签", bubbles.single().body.contains("<code"))
// 无 `<p>` 时同理(分隔符本来就与 `<p>` 同级)
assertEquals(listOf("甲<br>乙"), parse("<code>甲<br>乙</code>").map { it.body })
// 分隔符的三种写法 + 大小写 + 属性,在保护区里同样都拦下来(它们先要是分隔符候选)
for (form in listOf("<br>", "<br/>", "<br />", "</br>", "<BR>", "<br class=x>")) {
val one = parse("<p><code>甲${form}乙</code></p>")
assertEquals("「$form」在 `<code>` 里不该分隔", 1, one.size)
assertEquals("「$form」必须逐字留在代码里", "甲${form}乙", one.single().body)
}
// 保护区**只管**作用域内部:`<code>` 外面的 `<br>` 照旧分隔
assertEquals(listOf("丙", "甲<br>乙"), parse("<p>丙<br><code>甲<br>乙</code></p>").map { it.body })
}
/**
* `回执三十三 §三②` 第二半:**角色注记内部的 `<br>` 不分隔、不产生气泡**。
*
* 「不产生气泡」正是后端点名的信息泄露:切成两段等于把注记正文当两条聊天消息发出去。
* 我们这边的落点是**既有旁白语义**——注记整体被 `ROLE_RE` 认下、进 `head` / `tail`、正文为空;
* 注记文本**逐字原文**(`parts` 是红线:编辑框/搜索/通知都读它,而旁白那一行画的正是它),
* 所以里面那个 `<br>` 与 `<b>` 一样只是字面文本(`narrationTextIsLeftExactlyAsWritten` 同款)。
*/
@Test
fun lineBreakInsideNoteBlocksDoesNotSplitAndProducesNoBubble() {
for (raw in listOf("<p><action>她<br>点点头</action></p>", "<action>她<br>点点头</action>")) {
val bubbles = parse(raw)
assertEquals("「$raw」不该切成两条消息", 1, bubbles.size)
assertEquals("注记仍然不产生正文气泡", "", bubbles.single().body)
assertTrue("注记不做行内渲染 ⇒ runs 为空", bubbles.single().runs.isEmpty())
assertEquals(listOf(ContentPart.Action("她<br>点点头")), bubbles.single().head)
assertEquals(
"注记文本逐字原文(`parts` 红线)",
listOf(ContentPart.Action("她<br>点点头")),
bubbles.single().parts,
)
}
// `<narration>` 同理,且**附着语义一个字没动**:尾部 → tail,首部 → head
val tailed = parse("<p>你好<narration>她<br>点点头</narration></p>").single()
assertEquals("你好", tailed.body)
assertEquals(listOf(ContentPart.Narration("她<br>点点头")), tailed.tail)
val headed = parse("<p><narration>她<br>点点头</narration>你好</p>").single()
assertEquals("你好", headed.body)
assertEquals(listOf(ContentPart.Narration("她<br>点点头")), headed.head)
// 保护区只管作用域内部:注记**外面**的 `<br>` 照旧分隔,旁白照旧附着到下一段
val across = parse("<p><action>她<br>点点头</action><br>你好</p>")
assertEquals(listOf("你好"), across.map { it.body })
assertEquals(listOf(ContentPart.Action("她<br>点点头")), across.single().head)
}
/**
* 保护区**只管 `<br>` 算不算分隔符**,别的语义一个字都不动;而且只有 `<code>` 与角色注记算
* 保护区——**白名单行内元素(`<b>` / `<i>` / `<u>`)不算**(W68 登记的「跨分隔符的标签各自回落
* 原文」因此逐字保留,见 [inlineTagsCannotSpanALineBreak])。
*/
@Test
fun onlyCodeAndNoteScopesProtectLineBreaks() {
// `<b>` 里那个 `<br>`:照样分隔(口径没变)
assertEquals(listOf("<b>甲", "乙</b>"), parse("<p><b>甲<br>乙</b></p>").map { it.body })
// `<u>` 一样(W71 刚加回白名单,但它不是作用域)
assertEquals(listOf("<u>甲", "乙</u>"), parse("<p><u>甲<br>乙</u></p>").map { it.body })
// 一个 `<code>` 里几个 `<br>` 全都不分隔
assertEquals(listOf("甲<br>乙<br>丙"), parse("<code>甲<br>乙<br>丙</code>").map { it.body })
// 保护区**不**改注记/旁白/星号的任何既有语义:注记照样进 tail、`*笑*` 照样是动作,
// 正文里只剩 `<code>` 那一段(它没被切开,`<br>` 是代码里的内容)
val mixed = parse("<p><code>甲<br>乙</code>与<action>她<br>点点头</action>*笑*</p>").single()
assertEquals("甲<br>乙与", mixed.body)
assertEquals(
listOf(ContentPart.Action("她<br>点点头"), ContentPart.Action("笑")),
mixed.tail,
)
}
/**
* **嵌套**:`<code>` 里再套白名单标签时,里面的东西仍是「内容」(不解析、不计结构),
* 保护区也照样罩着它;`<code>` 套在 `<b>` 里时两边的边界各自成立。
*/
@Test
fun nestedTagsInsideACodeZoneAreContentAndTheirLineBreakStillDoesNotSplit() {
val codeInCode = parse("<p><code>甲<b>乙<br>丙</b></code></p>")
assertEquals(1, codeInCode.size)
assertEquals("甲<b>乙<br>丙</b>", codeInCode.single().body)
assertEquals(listOf(run("甲<b>乙<br>丙</b>", InlineStyle.Code)), codeInCode.single().runs)
val boldAroundCode = parse("<p><b>甲<code>乙<br>丙</code>丁</b></p>")
assertEquals(1, boldAroundCode.size)
assertEquals("甲乙<br>丙丁", boldAroundCode.single().body)
assertEquals(
listOf(
run("甲", InlineStyle.Bold),
run("乙<br>丙", InlineStyle.Bold, InlineStyle.Code),
run("丁", InlineStyle.Bold),
),
boldAroundCode.single().runs,
)
}
/**
* ⚠️ **未闭合的作用域不算作用域**(我们定的口径,理由写在 `appendRegion` 的 KDoc 里):
* 判据只看「**成对**」,而消费侧本来就不认未闭合的那个(未闭合的 `<code>` 在行内层不是作用域、
* 整段降级为原文;未闭合的 `<action>` 在 `ROLE_RE` 那里也配不出旁白)——去保护一个消费侧不认的
* 作用域,等于让切分层与消费层对同一段文本有两种结构判断。
* 另一条理由是**爆炸半径**:若「未闭合 ⇒ 保护到这段文本结尾」,一个漏掉 `</code>` 的笔误就会把
* 后面所有 `<br>` 一起吞掉、把本该分开的消息并成一条。
* 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切。
*/
@Test
fun unclosedProtectedScopesDoNotProtectLineBreaks() {
assertEquals(listOf("<code>甲", "乙"), parse("<code>甲<br>乙").map { it.body })
assertEquals(listOf("<action>她", "点点头"), parse("<action>她<br>点点头").map { it.body })
assertEquals(listOf("<code>甲", "乙"), parse("<p><code>甲<br>乙</p>").map { it.body })
// 闭合写错了名字(`</code>` 写成别的)同样不算成对
assertEquals(listOf("<code>甲", "乙</action>"), parse("<code>甲<br>乙</action>").map { it.body })
// 而且它仍然是**字面**(未闭合那一档照旧「不平衡 ⇒ 不渲染」),一个字都没丢
assertEquals("<code>甲", parse("<code>甲<br>乙").first().body)
}
/**
* **两支各算各的**(我们定的口径,见 `protectedSpans`):`<code>` 与角色注记各自配对,
* 因为那本来就是两个消费侧各自的尺子。所以「某一支没闭合」只影响**那一支**——里面那个已经
* 配对好的作用域照样保护它自己的 `<br>`,而没闭合的那一支里的 `<br>` 照旧分隔。
*/
@Test
fun aPairedZoneStillProtectsItsLineBreakWhileAnotherZoneIsUnclosed() {
// 外层 `<code>` 没闭合(不保护),里面那个配对好的 `<action>` 照样保护它的 `<br>`
val bubbles = parse("<code>甲<br>乙<action>她<br>点点头</action>丙")
assertEquals(2, bubbles.size)
assertEquals(listOf("<code>甲", "乙丙"), bubbles.map { it.body })
assertEquals(
"注记整体被认下(语序照原文:`parts` 为准)",
listOf(ContentPart.Text("乙"), ContentPart.Action("她<br>点点头"), ContentPart.Text("丙")),
bubbles[1].parts,
)
assertEquals(listOf(ContentPart.Action("她<br>点点头")), bubbles[1].head)
assertTrue("注记不产生气泡正文 ⇒ 第二条气泡的 runs 为空", bubbles[1].runs.isEmpty())
}
/**
* 流式:**保护区**里的残缺 `<br` 一样不许把字吐上屏。
*
* 中间态靠的是既有那条扣留规则——`<code>` / `<action>` / `<narration>` 的开标签本来就在栈里,
* 所以整个作用域(连同里面还没写完的 `<br`)一起被扣到闭合为止。收口后与一次性解析逐字一致,
* 且作用域真的没被切开:`<code>` 里的那个 `<br>` 是字面内容。
*/
@Test
fun streamingWithholdsPartialLineBreakInsideProtectedScopes() {
// 作用域还没闭合时,屏幕上只该有它前面那一段(里面的字面内容一个字都不许先露脸)
for (partial in listOf(
"甲<code", "甲<code>乙", "甲<code>乙<br", "甲<CODE>乙<br",
"甲<action>她", "甲<action>她<br", "甲<narration>她<br",
)) {
assertEquals(
"「$partial」残缺时必须扣住,只显示作用域前的正文",
listOf("甲"),
StreamingContentParser().onDelta(partial).map { it.body },
)
}
val text = "甲<code>乙<br>丙</code>丁"
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in text) {
val bodies = parser.onDelta(ch.toString()).map { it.body }
assertTrue("`<code>` 漏上了屏:$bodies", bodies.none { it.contains("<code") })
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
assertEquals(listOf("甲乙<br>丙丁"), parser.finish().map { it.body })
assertEquals(parse(text), parser.finish())
}
/**
* 跨分隔符的行内标签:**先切分、再逐段各自做行内解析** ⇒ 两段各自「结构不平衡」⇒ 两段都回落原文。
*
* 层序理由写在 `ChatContentParser.appendRegion` 的 KDoc:`<br>` 与 `<p>` 同级 ⇒ 分隔符先于行内
* 解析;而且一个气泡 = 一条消息,跨分隔符的标签在结构上不可能成立(后端剥标签时同样是按单条
* 消息判平衡的)。
*/
@Test
fun inlineTagsCannotSpanALineBreak() {
for (raw in listOf("<p><b>甲<br>乙</b></p>", "<b>甲<br>乙</b>")) {
val bubbles = parse(raw)
assertEquals("「$raw」两段都该回落原文", listOf("<b>甲", "乙</b>"), bubbles.map { it.body })
assertTrue(bubbles.all { it.runs.isEmpty() })
}
// 每一段**各自**平衡时照常渲染:粗体只罩住它自己那一段
val ok = parse("<p><b>甲</b><br><b>乙</b></p>")
assertEquals(listOf("甲", "乙"), ok.map { it.body })
assertEquals(listOf(run("甲", InlineStyle.Bold)), ok[0].runs)
assertEquals(listOf(run("乙", InlineStyle.Bold)), ok[1].runs)
}
/** `parts` 仍是**逐字原文**:分隔符 `<br>` 与 `<p>` / `</p>` 一样属于结构,不进片段。 */
@Test
fun lineBreakIsStructureSoItStaysOutOfParts() {
val bubble = parse("<p>日落 <b>18:35</b><br>记住了。</p>").first()
assertEquals(listOf(ContentPart.Text("日落 <b>18:35</b>")), bubble.parts)
assertEquals("日落 18:35", bubble.body)
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubble.runs)
}
/** `<br>` 与旁白依附的关系:纯旁白那一段照旧不独立成气泡,跨分隔符附着到下一段(与 `<p>` 同款)。 */
@Test
fun noteOnlySegmentStillAttachesAcrossALineBreak() {
val bubbles = parse("<action>笑</action><br>你好")
assertEquals(1, bubbles.size)
assertEquals("你好", bubbles[0].body)
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].head)
}
/** 逐字符流式:中间态一帧都不许把 `<br` 当正文吐上屏,收口后真的变成两条气泡。 */
@Test
fun lineBreakSplitsBubblesWhileStreaming() {
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in "甲<br>乙") {
val bodies = parser.onDelta(ch.toString()).map { it.body }
assertTrue("中间态把标签漏上了屏:$bodies", bodies.none { it.contains('<') })
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
assertEquals(listOf("甲", "乙"), parser.finish().map { it.body })
}
/** 残缺的 `<br` 同样扣留(否则中间态先把字面 `<br` 吐上屏、收口时再变成两条气泡——字会跳)。 */
@Test
fun streamingWithholdsPartialLineBreakTag() {
for (partial in listOf("甲<br", "甲<BR", "甲</br", "甲<br/", "甲<br ", "甲<br class=\"x\"")) {
val mid = StreamingContentParser().onDelta(partial)
assertEquals("「$partial」残缺时必须扣住,只显示分隔符前的正文", listOf("甲"), mid.map { it.body })
}
// 补全之后才产出第二条气泡,且已显示过的正文没有改口
val parser = StreamingContentParser()
assertEquals(listOf("甲"), parser.onDelta("甲<br").map { it.body })
assertEquals(listOf("甲"), parser.onDelta(" class=x").map { it.body })
assertEquals(listOf("甲", "乙"), parser.onDelta(">乙").map { it.body })
assertEquals(listOf("甲", "乙"), parser.finish().map { it.body })
}
// ------------------------------------------------------------------
// 旁白依附:首部 → head,尾部 → tail,中间 → 上提 head
// ------------------------------------------------------------------
@@ -325,6 +667,14 @@ class ChatContentParserTest {
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
}
/** 剥不剥只看它**后面**还有没有内容:后面有正文的留字面,后面什么都没有的才剥。 */
@Test
fun aLooseTagIsKeptWheneverThereIsStillContentAfterIt() {
assertEquals(listOf("<p>甲<p>乙"), parse("<p>甲<p>乙").map { it.body })
assertEquals(listOf("<p>甲"), parse("<p>甲<p>").map { it.body })
assertEquals(listOf("<p>甲"), parse("<p>甲<p><p><p>").map { it.body })
}
@Test
fun strayRoleTagOutsideParagraphIsDropped() {
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
@@ -509,6 +859,115 @@ class ChatContentParserTest {
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
}
// ------------------------------------------------------------------
// 复杂度回归:一大段落单标签不许把 parse 拖成 O(N²)
//
// ⚠️ 这条测的是**复杂度**,不是精确耗时:阈值宽到 20000 个落单标签给 2 秒,只用来区分
// 「线性」与「平方」。修复前实测:2000 个 854ms、8000 个 4254ms、20000 个 26405ms(每翻
// 一倍涨约 4 倍,典型平方);修复后 20000 个是十几毫秒。所以阈值别卡紧(CI 机器可能更慢),
// 这里量到的数也别当性能指标读——要盯的是「有没有退回 O(N²)」。
//
// 这个形状同时压住两处(修复前各占一部分,都在 `ChatContentParser` 里):
// - 落单标签的「后面还有没有正文」判据(`ContentAfterQuery`,修复前约 23 秒);
// - 完整块搜索 `P_BLOCK_RE.findAll`(没有 `</p>` 时每个 `<p` 候选点都回溯到串尾,约 8.2 秒)。
// ------------------------------------------------------------------
/** 复杂度回归的宽松上限(毫秒):只区分线性与平方,不是耗时指标。 */
private val looseTagBudgetMs = 2_000L
@Test
fun hugeRunOfLooseTagsIsDroppedWithinALinearBudget() {
val count = 20_000
val inputs = listOf(
"<p>".repeat(count), // 一串纯落单开标签
" \n<p> ".repeat(count), // 标签之间夹空白:跳过空白后仍是「后面什么都没有」
)
for (input in inputs) {
val startedAt = System.nanoTime()
val bubbles = parse(input)
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
assertTrue("落单标签应当被剥净,实际留下:${bubbles.map { it.body }}", bubbles.isEmpty())
assertTrue(
"${input.length} 字符($count 个落单标签)用了 ${elapsedMs}ms," +
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < looseTagBudgetMs,
)
}
}
/**
* `<br>` 那趟扫描([BR_TAG_RE] 的 `findAll`)同样不许把 `parse` 拖成平方:一串只有尖括号、
* 或一串「像分隔符但缺一个字符」的文本,每个 `<` 都只允许常数级尝试。
*/
@Test
fun hugeRunOfLineBreakLookalikesStaysWithinALinearBudget() {
val count = 20_000
for (input in listOf("<br".repeat(count), "<".repeat(count), "<br)x".repeat(count / 3))) {
val startedAt = System.nanoTime()
val bubbles = parse(input)
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
assertTrue("「${input.take(6)}…」不该分出气泡:${bubbles.size}", bubbles.size == 1)
assertTrue(
"${input.length} 字符(一串像 `<br>` 的文本)用了 ${elapsedMs}ms," +
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < looseTagBudgetMs,
)
}
}
/**
* W71 新增的那趟**保护区扫描**([protectedSpans])同样不许把 `parse` 拖成平方:一串只有作用域
* 开标签、或一串「像作用域」的尖括号,每个 `<` 都只允许常数级尝试;成对作用域那一趟靠
* 「区间按起点升序 + 游标不回头」,整体仍是线性的。判据与上一条同源(同样只是「线性 or 平方」)。
*
* ⚠️ 刻意**不**喂 `"<action>".repeat(n)`:那个形状会先踩到 `ROLE_RE` 自己的回溯(一串没有闭合的
* 角色开标签,正则引擎每处都要 `([\s\S]*?)` 一路试到串尾),那是**既有**的平方点、与本次改动无关,
* 拿它来当预算用例只会把一个既存问题记到新代码头上。这里用的是 `<code>`(不是 `ROLE_RE` 的词)。
*/
@Test
fun hugeRunOfScopeLikeTagsStaysWithinALinearBudget() {
val count = 20_000
val budget = { input: String, label: String, elapsedMs: Long ->
assertTrue(
"${input.length} 字符($label)用了 ${elapsedMs}ms," +
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < looseTagBudgetMs,
)
}
// 一串只有开标签:未闭合 ⇒ 不产生区间
val unclosed = "<code>".repeat(count)
val unclosedAt = System.nanoTime()
val unclosedBubbles = parse(unclosed)
budget(unclosed, "一串未闭合的 `<code>`", (System.nanoTime() - unclosedAt) / 1_000_000)
assertEquals(1, unclosedBubbles.size)
assertEquals(unclosed, unclosedBubbles.single().body)
// 一串「开标签 + `<br>`」:每个 `<br>` 都不在保护区里 ⇒ 每个都分隔
val split = "<code>甲<br>".repeat(count)
val splitAt = System.nanoTime()
val splitBubbles = parse(split)
budget(split, "$count 个未闭合的 `<code>` + `<br>`", (System.nanoTime() - splitAt) / 1_000_000)
assertEquals(count, splitBubbles.size)
// 一串**成对**的作用域(每个里面一个 `<br>`):全部被保护 ⇒ 一条气泡、逐字内容
val paired = "<code>甲<br>乙</code>".repeat(count / 4)
val pairedAt = System.nanoTime()
val pairedBubbles = parse(paired)
budget(paired, "$count 个成对作用域", (System.nanoTime() - pairedAt) / 1_000_000)
assertEquals("每个 `<br>` 都在保护区里 ⇒ 一条气泡", 1, pairedBubbles.size)
assertEquals("甲<br>乙".repeat(count / 4), pairedBubbles.single().body)
// 角色注记那一支:一串成对的注记,各自保护自己的 `<br>`(一条气泡、注记全认下)
val noted = "<action>她<br>点点头</action>".repeat(2_000)
val notedAt = System.nanoTime()
val notedBubbles = parse(noted)
budget(noted, "2000 条成对注记", (System.nanoTime() - notedAt) / 1_000_000)
assertEquals(1, notedBubbles.size)
assertEquals(2_000, notedBubbles.single().head.size)
assertEquals("", notedBubbles.single().body)
}
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
for (bubble in bubbles) {
@@ -517,4 +976,6 @@ class ChatContentParserTest {
}
}
}
private fun run(text: String, vararg styles: InlineStyle) = InlineRun(text, styles.toSet())
}
@@ -0,0 +1,759 @@
package top.yeij.cyrene.domain.parse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.chat.editableMessageOf
import top.yeij.cyrene.ui.chat.plainSearchText
import top.yeij.cyrene.ui.chat.sharePreviewBlocks
import kotlin.random.Random
/**
* 行内标签(`InlineMarkup.kt`)单测:六项白名单各自渲染成什么、边界口径、以及
* 「星号语义一个字都没被动过」的反证。
*
* 断言的是**纯函数的结果**(片段文本 + 样式),不碰 Compose:`runs` 拼起来就是渲染文本,
* 所以「哪段字是什么样式」在这里是直接可断言的。
*
* ## 口径沿革(三轮,都按后端契约改的)
* **W68 按 v0.89**:标签名**大小写不敏感**(`<B>` = `<b>`);**属性一律不解析**
* (`<b class="x">` 就是粗体,属性整段丢掉);**结构不平衡 ⇒ 该气泡正文整段降级为原文**
* (取代 W67 的「只坏那一对」)。⚠️ 大小写那条**消费侧与流式扣留侧必须一起改**:
* `<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>` 到了再吃掉,
* 屏幕上显示过的字会改口(由 `streamingWithholdsMixedCaseAndAttributeTagsToo` 钉着)。
*
* **W69 按 `回执三十` / `回执三十一`**:
* 1. **白名单收窄到五项**(`b` / `strong` / `i` / `em` / `code`)⇒ **`u` / `s` 撤掉**:
* 它们在她 100 条输出里 0 命中,多支持反而是制造跨端不一致(Web 不渲染、我们渲染)。
* 2. **`<br>` 不是行内标签**,而是与 `<p>` 同级的气泡/消息分隔符 ⇒ 从这一层**整体移出**,
* 切分改在 `ChatContentParser.appendRegion`,用例搬去 `ChatContentParserTest`。
* 这一层里 `<br>` 只剩「普通字符」这一种身份(见 [lineBreakTagIsNotAnInlineTagHere])。
*
* **W71 按 v0.89 最终版(`回执三十三`,收口)**:
* 1. **`u` 加回**:`回执三十三 §一` 明说「收窄到五项」是**后端的疏漏** —— 主人裁定下划线保留,
* 后端把 `u` 加回契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。
* 最终白名单 = **六项**,`u` 在这一层渲染成下划线([underlineBecomesAnUnderlineRun]);
* **`s` 仍不在内**、逐字原样([sEveryCaseAndShapeIsStillShownLiterally])。
* 2. **`<br>` 的「始终分隔」收紧成「只有普通文本层才分隔」**(`回执三十三 §三②`):`<code>` 与
* 角色注记**内部**的不分隔。那两处的切分在 `ChatContentParser`(保护区),这一层只是因此**真的**
* 会收到 `<br>` —— `<code>` 里的那个会走到这里,按「内容」字面显示
* ([lineBreakInsideCodeStaysLiteralContent])。
*
* 凡是不与这三轮契约冲突的老断言(绝不丢字、`parts` 逐字原文、星号语义、流式一致性、复杂度)
* 一条都没放松。
*/
class InlineMarkupTest {
// ------------------------------------------------------------------
// 六项白名单各自渲染成什么
// ------------------------------------------------------------------
@Test
fun boldTagBecomesABoldRun() {
assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("<b>18:35</b>"))
}
@Test
fun strongIsJustAnotherWayToWriteBold() {
assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("<strong>18:35</strong>"))
}
@Test
fun iAndEmBothBecomeItalicRuns() {
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<i>斜</i>"))
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<em>斜</em>"))
}
@Test
fun codeTagBecomesACodeRun() {
assertEquals(listOf(run("abc", InlineStyle.Code)), parseInlineMarkup("<code>abc</code>"))
}
/**
* W71 按 `回执三十三 §一` 新增(`u` 加回白名单,六项):`<u>x</u>` 渲染成**下划线**
* (`InlineStyle.Underline` → `MessageBubble.inlineBody` 里的 `TextDecoration.Underline`)。
*
* 加回的原因写在类 KDoc:`回执三十` 那次「收窄到五项」是后端的疏漏,主人裁定下划线保留。
*/
@Test
fun underlineBecomesAnUnderlineRun() {
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<u>重点</u>"))
// 大小写不敏感、属性同样整段丢掉(与其余五项同一套判据)
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<U>重点</U>"))
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<u class=x>重点</u>"))
// 与别的样式叠加、与别的标签混排
assertEquals(
listOf(run("加粗又下划线", InlineStyle.Bold, InlineStyle.Underline)),
parseInlineMarkup("<b><u>加粗又下划线</u></b>"),
)
val bubble = parse("<p><b>粗</b>与<u>下划线</u></p>").single()
assertEquals("粗与下划线", bubble.body)
assertEquals(
listOf(run("粗", InlineStyle.Bold), run("与"), run("下划线", InlineStyle.Underline)),
bubble.runs,
)
// 未配成对时仍是「整段降级」那一档,不因为多了一项而放松
assertTrue(parseInlineMarkup("<u>没有闭合").isEmpty())
}
@Test
fun mixedTagsSplitTheTextIntoStyledRuns() {
assertEquals(
listOf(run("记住 "), run("abc", InlineStyle.Code), run(" 与 "), run("斜体", InlineStyle.Italic), run(" 哦")),
parseInlineMarkup("记住 <code>abc</code> 与 <i>斜体</i> 哦"),
)
}
/** 线上实测的那一条:只有 `<b>` 一种标签,命中的正是她给的那个时间点。 */
@Test
fun theRealMessageRendersAsThreeRuns() {
val runs = parseInlineMarkup("日落 <b>18:35</b>,比上海晚了整整一小时呢。")
assertEquals(
listOf(
run("日落 "),
run("18:35", InlineStyle.Bold),
run(",比上海晚了整整一小时呢。"),
),
runs,
)
assertEquals("日落 18:35,比上海晚了整整一小时呢。", runs.concat())
}
/** 没有标签时返回空列表 = 调用方走原来的字面路径(这是绝大多数消息的那一档)。 */
@Test
fun plainTextYieldsNoRunsAtAll() {
assertTrue(parseInlineMarkup("今天那儿是晴,13~25℃").isEmpty())
assertTrue(parseInlineMarkup("").isEmpty())
assertTrue(parseInlineMarkup("多行\n文本\n也是普通的").isEmpty())
}
// ------------------------------------------------------------------
// 契约 v0.89 之一:标签名大小写不敏感
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc):**六项**标签的大小写任意写法
* 都认,且改不成别的样式——`<B>` 就是 `<b>`(`u` 由 W71 按 `回执三十三 §一` 加回)。
*/
@Test
fun tagNamesAreCaseInsensitive() {
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<B>粗</B>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<Strong>粗</Strong>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<STRONG>粗</STRONG>"))
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<I>斜</I>"))
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<Em>斜</Em>"))
assertEquals(listOf(run("码", InlineStyle.Code)), parseInlineMarkup("<CODE>码</CODE>"))
assertEquals(listOf(run("线", InlineStyle.Underline)), parseInlineMarkup("<U>线</U>"))
// 大小写混着嵌套也照常叠加
assertEquals(
listOf(run("粗斜", InlineStyle.Bold, InlineStyle.Italic)),
parseInlineMarkup("<B><i>粗斜</i></B>"),
)
}
/**
* 大写**只对行内标签**成立:段落/旁白标签仍只认小写(既有契约 `<P>大小写不认</P>`、
* `MessageEditText` 同一口径;契约也写明「`<action>`/`<narration>` 的既有行为逐字未改」)。
*/
@Test
fun uppercaseStillMeansNothingForParagraphAndRoleTags() {
assertEquals(listOf("<P>大小写不认</P>"), parse("<P>大小写不认</P>").map { it.body })
assertEquals(listOf("甲<ACTION>笑</ACTION>"), parse("<p>甲<ACTION>笑</ACTION></p>").map { it.body })
}
// ------------------------------------------------------------------
// 契约 v0.89 之二:属性一律不解析
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 改(原口径「`<b class="x">` 原样保留」与它冲突,见类 KDoc):
* 白名单标签带任何属性都按标签名处理,属性**整段丢掉**,绝不落进正文、更不落成任何视图属性。
*/
@Test
fun attributesAreDroppedEntirely() {
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b class=\"x\">粗</b>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b onclick=\"y\">粗</b>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b class=x onclick=y style=z>粗</b>"))
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b data-甲=一>粗</b>"))
assertEquals(listOf(run("码", InlineStyle.Code)), parseInlineMarkup("<code class=lang>码</code>"))
// 属性里那串字一个都不许留在渲染文本里(`class` / `x` / `onclick` / `y` 全都不见)
val runs = parseInlineMarkup("日落 <b class=\"x\" onclick=\"y\">18:35</b>,比上海晚了整整一小时呢。")
assertEquals(
listOf(run("日落 "), run("18:35", InlineStyle.Bold), run(",比上海晚了整整一小时呢。")),
runs,
)
assertFalse(runs.concat().contains("class"))
assertFalse(runs.concat().contains("onclick"))
// 带属性的标签同样要配成对:`<b class=x>` 没闭合 ⇒ 整段降级(不是「属性被丢、粗体照旧」)
assertEquals("<b class=\"x\">甲", parse("<p><b class=\"x\">甲</p>").single().body)
}
// ------------------------------------------------------------------
// 边界 2:嵌套;边界 5:code 里的内容不再解析
// ------------------------------------------------------------------
@Test
fun nestedBoldItalicMergesTheTwoStyles() {
assertEquals(
listOf(run("粗", InlineStyle.Bold), run("粗斜", InlineStyle.Bold, InlineStyle.Italic)),
parseInlineMarkup("<b>粗<i>粗斜</i></b>"),
)
}
@Test
fun boldOutsideItalicKeepsBothStyles() {
assertEquals(
listOf(run("甲", InlineStyle.Bold), run("乙", InlineStyle.Bold, InlineStyle.Code), run("丙", InlineStyle.Bold)),
parseInlineMarkup("<b>甲<code>乙</code>丙</b>"),
)
}
/**
* `<code>` 里想显示什么就显示什么——包括尖括号(这也是「想显示尖括号怎么办」的答案)。
*
* 它与契约「白名单外按纯文本原样显示」**不冲突、方向一致**,但不是同一条规则:第 1 条管的是
* 白名单外的**标签名**,`<code>` 管的是一条**作用域**(里面连白名单标签也按内容处理)。
* 两者的结果都落在同一条硬口径上:字符一个不丢。理由写在 `parseInlineMarkup` 的 KDoc 里。
*/
@Test
fun codeContentIsNotParsed() {
assertEquals(listOf(run("<b>甲</b>", InlineStyle.Code)), parseInlineMarkup("<code><b>甲</b></code>"))
assertEquals(listOf(run("1 < 2 && a<b", InlineStyle.Code)), parseInlineMarkup("<code>1 < 2 && a<b</code>"))
// 里面的东西也**不算结构**:这个「未闭合的 `<b>`」只是四个字符,不把整段拖成字面
assertEquals(listOf(run("<b>甲", InlineStyle.Code), run(" 尾")), parseInlineMarkup("<code><b>甲</code> 尾"))
}
/**
* W69(`回执三十一`):`<br>` **不再是行内标签**,所以这一层对它只剩「普通字符」这一种身份——
* 它不换行、也不影响旁边的成对标签(`<b>甲<br>乙</b>` 在这里是一对正常的粗体,文本里带着
* 字面的 `<br>`)。
*
* 真实链路上这一层**只有一条路**会看到 `<br>`:`<code>` 作用域内部的那些(`回执三十三 §三②`
* 起,分隔符切分层有保护区,`<code>` / `<action>` / `<narration>` 里的 `<br>` 不分隔)。
* 其余形状只可能出现在「函数被单独调用」的场景里。气泡切分那一侧的用例在
* `ChatContentParserTest`(含三种写法等价、空气泡丢弃、保护区、跨分隔符的标签各自回落原文)。
*/
@Test
fun lineBreakTagIsNotAnInlineTagHere() {
assertEquals(listOf(run("甲<br>乙", InlineStyle.Bold)), parseInlineMarkup("<b>甲<br>乙</b>"))
assertTrue(parseInlineMarkup("甲<br>乙").isEmpty())
assertTrue(parseInlineMarkup("甲<br/>乙").isEmpty())
assertTrue(parseInlineMarkup("甲</br>乙").isEmpty())
assertTrue(parseInlineMarkup("甲<br2>乙</br2>").isEmpty())
}
/**
* W71(`回执三十三 §三②`):`<code>` 作用域**内部**的 `<br>` 走到这一层时,就是代码里的几个
* 普通字符——**逐字原样**(不换行、也不消掉),与 `<code><b>甲</b></code>` 里的 `<b>` 同一规矩。
*
* 口径的理由写在 `ChatContentParser.appendRegion` 的 KDoc 里(注记那一侧要显示 `parts` 的逐字
* 原文 = 红线,所以同一个形状不能在 `<code>` 里是一种意思、在 `<action>` 里又是另一种;
* 「换成 `\n`」还得在这一层破例给 `<br>` 单开一条规则——W68 刚从这一层把它整体删掉)。
*/
@Test
fun lineBreakInsideCodeStaysLiteralContent() {
assertEquals(listOf(run("甲<br>乙", InlineStyle.Code)), parseInlineMarkup("<code>甲<br>乙</code>"))
// `<code>` 里再套 `<b>`、`<code>` 套在 `<b>` 里:内容一律字面,`<br>` 也是内容的一部分
assertEquals(
listOf(run("甲<b>乙<br>丙</b>", InlineStyle.Code)),
parseInlineMarkup("<code>甲<b>乙<br>丙</b></code>"),
)
assertEquals(
listOf(run("甲", InlineStyle.Bold), run("乙<br>丙", InlineStyle.Bold, InlineStyle.Code), run("丁", InlineStyle.Bold)),
parseInlineMarkup("<b>甲<code>乙<br>丙</code>丁</b>"),
)
}
/** 而它作为**气泡分隔符**的作用由气泡切分层负责:`<br>` 现在真的分成两条气泡。 */
@Test
fun lineBreakTagSplitsBubblesAtTheParserLevel() {
assertEquals(listOf("甲", "乙"), parse("<p>甲<br>乙</p>").map { it.body })
assertEquals(listOf("甲", "乙"), parse("甲<br>乙").map { it.body })
}
// ------------------------------------------------------------------
// 契约 v0.89 之三:结构不平衡 ⇒ 整段降级为原文
// (粒度是「该气泡的正文」;W67 的局部降级口径与它冲突,W68 已按契约改掉)
// ------------------------------------------------------------------
/**
* W68 按契约 v0.89 改:**未闭合**的开标签 ⇒ 整段原文。
* 返回空列表 = 调用方按字面渲染,正文一个字符都不动。
*/
@Test
fun unpairedTagsStayLiteral() {
// 没有闭合 ⇒ 整段字面(列表为空 = 按字面渲染,正文一个字符都不动)
assertTrue(parseInlineMarkup("<b>没有闭合").isEmpty())
assertTrue(parseInlineMarkup("没有闭合</b>").isEmpty())
assertTrue(parseInlineMarkup("日落 <b>18:35").isEmpty())
assertTrue(parseInlineMarkup("<i>斜").isEmpty())
assertTrue(parseInlineMarkup("<code>看星星").isEmpty())
// 大小写混用、带属性也一样算未闭合
assertTrue(parseInlineMarkup("日落 <B>18:35").isEmpty())
assertTrue(parseInlineMarkup("日落 <b class=x>18:35").isEmpty())
}
/**
* W68 按契约 v0.89 改:原断言是「配成对的另一半照常生效,只有落单那半个留着字面」
* (`<b>甲<i>乙</i>` ⇒ `<b>甲` 字面 + `乙` 斜体)。契约把它换成了**整段降级**:
* 一处不平衡,这一整段一个标签都不认——粗粒度换来「可逐字断言」,理由见类 KDoc。
*/
@Test
fun oneUnclosedTagFallsTheWholeBodyBackToLiteralText() {
assertTrue(parseInlineMarkup("<b>甲<i>乙</i>").isEmpty())
assertEquals("<b>甲<i>乙</i>", parse("<p><b>甲<i>乙</i></p>").single().body)
assertEquals(emptyList<InlineRun>(), parse("<p><b>甲<i>乙</i></p>").single().runs)
}
/**
* W68 按契约 v0.89 改:原断言是「`</b>` 关不掉 `i`,于是它连同 `<b>` 一起按字面留着,
* 只有配成对的 `<i>…</i>` 生效」。契约把它换成整段降级:**交错**是最容易各端各说各话的形状,
* 一律按原文显示。
*/
@Test
fun interleavedTagsFallTheWholeBodyBackToLiteralText() {
assertTrue(parseInlineMarkup("<b>甲<i>乙</b>丙</i>").isEmpty())
assertEquals("<b>甲<i>乙</b>丙</i>", parse("<p><b>甲<i>乙</b>丙</i></p>").single().body)
}
/** W68 按契约 v0.89 改:原断言是「落在后面的那个 `</b>` 什么都不关,前面的粗体照旧」。 */
@Test
fun aStrayCloseTagFallsTheWholeBodyBackToLiteralText() {
// 落单的闭标签(栈空)与「只有闭标签」都是不平衡 ⇒ 整段原文
assertTrue(parseInlineMarkup("<b>甲</b>乙</b>").isEmpty())
assertTrue(parseInlineMarkup("</b>").isEmpty())
assertTrue(parseInlineMarkup("甲</i>乙</b>").isEmpty())
assertEquals("<b>甲</b>乙</b>", parse("<p><b>甲</b>乙</b></p>").single().body)
}
/**
* W68 按契约 v0.89 新增:**四种不平衡形状**各自的整段降级,且降级的是「整段原文」,
* 逐字对照。
*/
@Test
fun everyImbalancedShapeFallsBackToTheOriginalTextCharacterForCharacter() {
for (raw in listOf(
"日落 <b>18:35", // 未闭合
"甲<b>粗</b>乙<b>没闭合", // 一半配好、后一半未闭合
"甲<b>粗</b>乙</b>", // 落单闭标签
"</b>", // 只有闭标签
"<b>甲<i>乙</b>丙</i>", // 交错
"<B>甲", // 大写 + 未闭合
"<b class=x>甲", // 属性 + 未闭合
)) {
assertTrue("「$raw」结构不平衡,必须整段降级", parseInlineMarkup(raw).isEmpty())
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
assertTrue(parse("<p>$raw</p>").single().runs.isEmpty())
}
}
/**
* 契约说的「整段」= **该气泡的正文**(粒度口径与理由写在 `parseInlineMarkup` 的 KDoc 里)。
* 这条钉住它的边界:**坏掉的那条气泡整段原文,别条气泡照常渲染**——既不是整条消息,
* 也不是(更细的)按行/按句局部生效。
*/
@Test
fun theFallbackCoversOnlyTheBubbleThatIsBroken() {
val bubbles = parse("<p>日落 <b>18:35</b></p><p>甲<b>没闭合</p>")
assertEquals(listOf("日落 18:35", "甲<b>没闭合"), bubbles.map { it.body })
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubbles[0].runs)
assertTrue(bubbles[1].runs.isEmpty())
// 落单闭标签同理:只影响它自己那条气泡
val strayClose = parse("<p>日落 <b>18:35</b></p><p>乙</b></p>")
assertEquals(listOf("日落 18:35", "乙</b>"), strayClose.map { it.body })
assertTrue(strayClose[1].runs.isEmpty())
}
// ------------------------------------------------------------------
// 契约之一:白名单外一律按纯文本原样显示(字符一个不丢)
// W71 起 `s` 在这一档里(六项白名单不含它;`u` 曾经短暂撤出、已加回)
// ------------------------------------------------------------------
@Test
fun plainAngleBracketsAreNeverEatenAndNeverRendered() {
for (text in listOf(
"1 < 2 且 3 > 2",
"a<b",
"a<b>c", // 没有 `</b>` ⇒ 未闭合 ⇒ 整段字面
"你好<b", // 残缺,没有 `>`
"<p2>这不关 p 标签的事</p2>",
"<span>不认识的标签</span>",
"<br2>名字后面不是空白</br2>",
"<b2>名字后面不是空白</b2>",
"<boolean>以 b 开头的普通单词</boolean>",
)) {
val runs = parseInlineMarkup(text)
assertTrue("「$text」不该被判成行内标签", runs.isEmpty())
assertEquals("「$text」必须逐字保留", text, parse("<p>$text</p>").single().body)
}
}
/**
* 契约第 1 条点名的形状:`<script>` 之类**白名单外**的标签显示成字面量,字符一个不丢——
* 渲染侧是 `AnnotatedString` + `SpanStyle`(结构化),正文从来不经过任何 HTML 解析/注入。
*
* **`s` 落在这一档**:白名单 v0.89 最终版是六项(`b`/`strong`/`i`/`em`/`code`/`u`),**`s` 不在内**
* ——`回执三十` 撤它、`回执三十三 §一` 加回 `u` 时明确「`s` 仍不在内」,所以它逐字原样。
* (`u` 在这一档里只待了 W69 一轮,见 [underlineBecomesAnUnderlineRun] 与类 KDoc。)
*
* ⚠️ 我们的读法(**待与后端确认**):白名单外的标签**不参与结构判定**——它们按纯文本算,
* 所以既不会因为「没闭合」把整段拖成字面(下面第二条),配对与否也不改变别的标签;
* 只有白名单那**六个**名字才构成契约说的「结构」。
*/
@Test
fun tagsOutsideTheWhitelistAreShownLiterallyWithoutLosingACharacter() {
val raw = "<script>alert(1)</script>"
assertTrue(parseInlineMarkup(raw).isEmpty())
assertEquals(raw, parse("<p>$raw</p>").single().body)
// 白名单外的标签不参与结构判定:旁边那对 `<b>` 照常生效,尖括号原样留着
val mixed = parse("<p>甲<b>粗</b><script>x</script>乙</p>").single()
assertEquals("甲粗<script>x</script>乙", mixed.body)
assertEquals(
listOf(run("甲"), run("粗", InlineStyle.Bold), run("<script>x</script>乙")),
mixed.runs,
)
// 落单的白名单外标签同理:它只是几个字符,不把整段拖成字面
assertEquals(
listOf(run("甲"), run("粗", InlineStyle.Bold), run("<script>乙")),
parseInlineMarkup("甲<b>粗</b><script>乙"),
)
}
/**
* W71 按 `回执三十三 §一` 改写:**`s` 仍然不在白名单里**(「`s` 保持逐字原样不变」是原话),
* 所以 `<s>` 的**每一种形态**都不产生样式、只按原文显示——这条与 [underlineBecomesAnUnderlineRun]
* 是一对:`u` 加回来了,`s` 没有。
*
* 枚举里也**不该**有 `Strikethrough`:本项目不留「白名单外却有渲染分支」的死代码
* (W69 撤 `u`/`s` 时连枚举、渲染分支、import 一起删净,这次只把 `Underline` 加回来)。
*/
@Test
fun sEveryCaseAndShapeIsStillShownLiterally() {
for (raw in listOf(
"<s>改口</s>",
"<S>改口</S>",
"<s class=x>改口</s>",
"<s>没闭合",
"只有闭标签</s>",
)) {
assertTrue("「$raw」不在白名单里,不该产出任何样式", parseInlineMarkup(raw).isEmpty())
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
}
// 与真样式混在一起时同理:白名单那六项起作用,`s` 的字面留在文本里
val bubble = parse("<p><b>粗</b>与<s>删除线</s></p>").single()
assertEquals("粗与<s>删除线</s>", bubble.body)
assertEquals(
listOf(run("粗", InlineStyle.Bold), run("与<s>删除线</s>")),
bubble.runs,
)
// `u` 回来了、`s` 没有:枚举里只有前者,绝不留一条走不到的渲染分支
assertTrue(InlineStyle.entries.contains(InlineStyle.Underline))
assertTrue(InlineStyle.entries.map { it.name }.none { it == "Strikethrough" })
}
// ------------------------------------------------------------------
// 边界 6:HTML 实体(不做)
// ------------------------------------------------------------------
@Test
fun htmlEntitiesStayLiteralBecauseTheyAreNotDecoded() {
for (text in listOf("a &amp; b", "1 &lt; 2", "空&nbsp;格", "&#39;引号&#39;")) {
assertTrue(parseInlineMarkup(text).isEmpty())
assertEquals(text, parse("<p>$text</p>").single().body)
}
}
// ------------------------------------------------------------------
// 契约第 3 条:绝不把一段非空正文渲染成空
// ------------------------------------------------------------------
@Test
fun aBodyMadeOfTagsOnlyFallsBackToItsLiteralText() {
for (raw in listOf("<b></b>", "<b> </b>", "<i></i><b></b>", "<code></code>")) {
assertTrue("「$raw」没有可渲染的字,必须整段回落字面", parseInlineMarkup(raw).isEmpty())
assertEquals(raw, parse("<p>$raw</p>").single().body)
}
}
// ------------------------------------------------------------------
// 与既有的解析结果怎么接:body / runs / parts 各拿自己那一份
// ------------------------------------------------------------------
@Test
fun bodyIsTheRenderedTextAndRunsSpellItOut() {
val bubble = parse("<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>").single()
assertEquals("今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。", bubble.body)
assertEquals(
listOf(run("今天那儿是晴,13~25℃,日落 "), run("18:35", InlineStyle.Bold), run(",比上海晚了整整一小时呢。")),
bubble.runs,
)
assertEquals(bubble.body, bubble.runs.concat())
}
/** `parts` 保持**逐字原文**:编辑框、搜索片段、通知文案读的都是它,一个字都不许改。 */
@Test
fun partsKeepTheRawTextForEditingAndSearch() {
val bubble = parse("<p>日落 <b>18:35</b></p>").single()
assertEquals(listOf(ContentPart.Text("日落 <b>18:35</b>")), bubble.parts)
assertEquals("日落 <b>18:35</b>", bubble.parts.joinToString("") { it.text })
}
/**
* `parse` 的其它调用点一律照旧——这条是「本次改动只碰渲染」的证据,也是它们的护栏:
* 编辑框读到的是**原文**(行内标签一个都不掉)、搜索照样搜得到那句话、后台那段结论预览
* 跟着拿到渲染文本(标签不再露出来)。谁哪天想让这些地方换一套文本,必须先删掉这里的断言。
*/
@Test
fun theOtherConsumersOfTheBubbleStillSeeWhatTheySawBefore() {
val plain = "日落 <b>18:35</b>,比上海晚了整整一小时呢。"
// 编辑框:原文不含 `<p>`/`<action>` 时是**恒等**变换(一个字节都不动,也不谎称有标记被替换)
val editable = editableMessageOf(plain)
assertEquals(plain, editable.text)
assertFalse(editable.hasMarkup)
// 编辑框:外层的 `<p>` 照旧被剥(既有语义),而行内标签**原样留着**——它是要被编辑的源码本身
val wrapped = editableMessageOf("<p>$plain</p>")
assertTrue(wrapped.hasMarkup)
assertEquals(plain, wrapped.text)
// 搜索:按片段原文拼纯文本,那句话照旧搜得到(`<b>` 夹在中间不影响那几个字连在一起)
val searchable = plainSearchText("<p>$plain</p>")
assertTrue(searchable.contains("日落"))
assertTrue(searchable.contains("18:35"))
}
/** 分享预览走的是同一个 [parse](W65)⇒ 它拿到的正文与气泡逐字一致:标签同样不再露出来。 */
@Test
fun theSharePreviewSeesTheSameBodyAsTheBubble() {
val content = "<p>日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>"
assertEquals(parse(content).map { it.body }, sharePreviewBlocks(content).map { it.body })
}
/** 旁白/动作行不做行内解析(理由见 `MessageBubble.AnnotationLine` 的 KDoc):原文进 head,一个字不动。 */
@Test
fun narrationTextIsLeftExactlyAsWritten() {
val bubble = parse("<p><action>笑<b>了</b></action></p>").single()
assertEquals(listOf(ContentPart.Action("笑<b>了</b>")), bubble.head)
assertEquals("", bubble.body)
assertTrue(bubble.runs.isEmpty())
}
@Test
fun inlineTagsDoNotChangeBubbleSplitting() {
val bubbles = parse("<p>甲<b>乙</b></p><p>丙</p>")
assertEquals(listOf("甲乙", "丙"), bubbles.map { it.body })
assertEquals(listOf(ContentPart.Text("甲<b>乙</b>"), ContentPart.Text("丙")), bubbles.flatMap { it.parts })
}
/** 未闭合的 `<b>` 在一次性解析里同样是字面(与 `<action>` 那条既有契约同一口径)。 */
@Test
fun unclosedInlineTagFallsBackToLiteralText() {
val bubble = parse("<p>日落 <b>18:35</p>").single()
assertEquals("日落 <b>18:35", bubble.body)
assertTrue(bubble.runs.isEmpty())
}
// ------------------------------------------------------------------
// 反证:星号语义一个字都没被动过
//
// 行内解析发生在**段落/分隔符解析之后**(`toBubble` 里对最终正文做一次),而星号那一套
// 在 `appendAnnotations` 里就已经定稿并入 `parts` 了。所以「星号不产生斜体/粗体」这条
// 语义在结构上不可能被行内解析改了——下面这几条既是反证,也是它与它相邻的样子。
// ------------------------------------------------------------------
@Test
fun starsKeepTheirExistingMeaningInsteadOfBecomingMarkdown() {
// 半角 `*…*` 没有关键词 ⇒ 保持普通文本,**不**变成斜体(既有用例:halfWidthStarWithoutKeywordStaysMarkdownText)
val emphasis = parse("<p>这是*强调*文字</p>").single()
assertEquals("这是*强调*文字", emphasis.body)
assertTrue(emphasis.runs.isEmpty())
// 半角 `**…**` 同理:**不**变成粗体(既有用例:halfWidthDoubleStarStaysMarkdownText)
val double = parse("<p>这是**加粗**文字</p>").single()
assertEquals("这是**加粗**文字", double.body)
assertTrue(double.runs.isEmpty())
// 全角 `*…*` 仍是「动作」(既有用例:shortFullWidthStarWithoutKeywordBecomesAction)
val starred = parse("<p>你好*弯起眼睛*</p>").single()
assertEquals("你好", starred.body)
assertEquals(listOf(ContentPart.Action("弯起眼睛")), starred.tail)
// 带关键词的半角星号仍是旁白(既有用例:halfWidthStarWithNarrationKeywordBecomesNarration)
val narration = parse("<p>*内心:有点开心*你好</p>").single()
assertEquals("你好", narration.body)
assertEquals(listOf(ContentPart.Narration("有点开心")), narration.head)
}
/** 同一段正文里两种机制各干各的:`*笑*` 照旧是旁白,`<b>` 照旧是粗体。 */
@Test
fun starsAndInlineTagsCoexistWithoutTouchingEachOther() {
val bubble = parse("<p>日落 <b>18:35</b>*笑*</p>").single()
assertEquals("日落 18:35", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.tail)
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubble.runs)
}
// ------------------------------------------------------------------
// 流式:未闭合的行内标签同样扣留(否则屏幕上已经显示过的字会改口)
// ------------------------------------------------------------------
/**
* 逐字符喂一条带 `<b>` 的消息:中间态**一帧都不许**把标签当正文吐上屏,且已经显示过的正文
* 只许「加」不许「改口」(与 `ChatContentParserTest` 那条同名的性质同源)。
*
* 这条消息刻意没有 `<p>`:有 `<p>` 的话整个段落本来就会被扣留,测不到行内标签这一条。
*/
@Test
fun streamingWithholdsUnclosedInlineTags() {
val text = "日落 <b>18:35</b>,比上海晚了整整一小时呢。记住了。"
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in text) {
val bubbles = parser.onDelta(ch.toString())
val bodies = bubbles.map { it.body }
// 只看**渲染文本**(`body`):`parts` 是逐字原文,配成对的标签本来就该留在里面
// (编辑框/搜索读的就是它),所以那一边不能用「有没有尖括号」来判。
for (bubble in bubbles) {
assertFalse("标签漏上了屏:${bubble.body}", bubble.body.contains("<b"))
}
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
assertEquals(parse(text), parser.finish())
assertEquals("日落 18:35,比上海晚了整整一小时呢。记住了。", parser.finish().single().body)
}
/**
* W68 按契约 v0.89 新增(这条是那一轮最要紧的护栏):**大小写混用与带属性**的标签,
* 流式中间态同样一个字符都不许漏上屏。
*
* 消费侧与扣留侧的词表一旦分叉(W67 只有消费侧认小写、扣留侧只扫 `a..z`),`<B` / `<b class`
* 这种残缺形态就会在中间态被当正文吐上屏、等 `>` 到了再被吃掉——屏幕上显示过的字当场改口。
* 所以这条同时钉住三件事:中间态不吐字面标签、已显示的正文只增不改、收口逐字等于一次性解析。
*/
@Test
fun streamingWithholdsMixedCaseAndAttributeTagsToo() {
val text = "日落 <B class=\"x\">18:35</B> 记住了。"
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in text) {
val bubbles = parser.onDelta(ch.toString())
val bodies = bubbles.map { it.body }
for (bubble in bubbles) {
// 这条样本里渲染文本不该出现任何一个尖括号/属性字:全是标签字符或属性
assertFalse("标签或属性漏上了屏:${bubble.body}", bubble.body.contains('<') || bubble.body.contains('>'))
}
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
assertEquals(parse(text), parser.finish())
assertEquals("日落 18:35 记住了。", parser.finish().single().body)
}
/**
* 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签、`<br>` 分隔符、
* 以及 W71 起新增的两处:`u` 与**保护区**内的 `<br>`)。
*/
@Test
fun streamingAnyChunkingMatchesOneShot() {
val sample = """
<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>
<p>记住 <CODE>abc</CODE>、<i>斜体</i> 与 <strong class="x">加粗</strong> 哦</p>
<p>这是<S>删除线</S>与<U>下划线</U>,*弯起眼睛*</p>
<p>1 < 2 且 <span class="y">白名单外的标签原样</span> 收尾</p>
<p>日落 <B>18:35</B></p>
<p>上一行<br>下一行</p>
<p>甲<br/>乙</br>丙</p>
<p><code>甲<br>乙</code>与<u>下划线</u></p>
<p>她<action>她<br>点点头</action></p>
""".trimIndent()
val expected = parse(sample)
val charByChar = StreamingContentParser()
for (ch in sample) charByChar.onDelta(ch.toString())
assertEquals(expected, charByChar.finish())
val random = Random(20261007)
repeat(25) {
val parser = StreamingContentParser()
var index = 0
while (index < sample.length) {
val end = minOf(index + 1 + random.nextInt(7), sample.length)
parser.onDelta(sample.substring(index, end))
index = end
}
assertEquals(expected, parser.finish())
}
}
/** 一整段只有标记时,收口也必须是字面(与一次性解析一致;未闭合的 `<p>` 同样照字面留着)。 */
@Test
fun streamingUnclosedInlineTagFallsBackOnFinish() {
val parser = StreamingContentParser()
parser.onDelta("<p>日落 <b>18:35")
assertEquals(listOf("<p>日落 <b>18:35"), parser.finish().map { it.body })
}
// ------------------------------------------------------------------
// 回归:正文里一串尖括号不许把后面的真标签「遮住」,也不许把扫描拖成 O(N²)
// ------------------------------------------------------------------
@Test
fun angleBracketsBeforeATagDoNotHideIt() {
assertEquals(
listOf(run("1 < 2 且 3 > 2,日落 "), run("18:35", InlineStyle.Bold)),
parseInlineMarkup("1 < 2 且 3 > 2,日落 <b>18:35</b>"),
)
// 半路那个 `<` 与它后面那个 `>` 之间还夹着一个真标签:不能「从 `<` 一路找到下一个 `>`」
// 就把整段跳过(那样 `<b>` 会被连同它一起跳过,粗体永远不生效)。
assertEquals(
listOf(run("1 < 2 "), run("甲", InlineStyle.Bold)),
parseInlineMarkup("1 < 2 <b>甲</b>"),
)
}
/**
* ⚠️ 与 `ChatContentParserTest` 那条复杂度回归同一性质:阈值只用来区分「线性」与「平方」,
* 不是耗时指标。正文里成串的 `<` 若用 `indexOf('>', lt)` 逐个找配对(找不到就一路扫到串尾),
* 这里就会退回 O(N²)——W63 刚在同一片区域修掉两处同形状的问题。
*
* W68 注意:因为契约要求「属性一律不解析」,标签长度不再有上界,`inlineTagEnd` 那扇定宽窗
* 换成了「扫到 `>` 或下一个 `<` 为止」。这条回归因此更要紧——它钉住换完之后仍是线性的。
*/
@Test
fun aHugeRunOfAngleBracketsStaysWithinALinearBudget() {
val count = 20_000
for (body in listOf("<".repeat(count), "a<b".repeat(count / 3), "<b class=x".repeat(count / 9))) {
val startedAt = System.nanoTime()
val bubble = parse("<p>$body</p>").single()
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
assertEquals(body, bubble.body)
assertTrue(bubble.runs.isEmpty())
assertTrue(
"${body.length} 字符(一串普通尖括号)用了 ${elapsedMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < 2_000,
)
}
}
// ------------------------------------------------------------------
// 工具
// ------------------------------------------------------------------
private fun run(text: String, vararg styles: InlineStyle) = InlineRun(text, styles.toSet())
private fun List<InlineRun>.concat(): String = joinToString("") { it.text }
}
@@ -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,170 @@
package top.yeij.cyrene.domain.share
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 正文的**回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`([ShareRules.extract])。
*
* ## 这条链修的是哪种静默
* 有些发送方**不填** `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。本端此前只读
* `EXTRA_TEXT` ⇒ 那样一次分享**整条静默**:用户点了分享,界面毫无反应、后端零请求、也没有一句解释
* (`SharePayload.kt` 里那条 `contentUnreadable` 判据也不含这两个 extra)。修法就是这条链。
*
* ## 本文件钉住的三件事
* 1. **回落链本身**:只有 `SUBJECT` / 只有 `TITLE` ⇒ 用它当正文,且来源如实记进
* `SharedContent.textSource`(渲染层靠它说一句「来自分享标题」);
* 2. **既有情形逐字不变**:`EXTRA_TEXT` 非空时它**一定**赢(它是第一站),结果与今天的实现完全相同
* ——回落链不许「取到更多东西」,它补的是原先那条缝;
* 3. **都不成正文时不许闭嘴**:三个文本 extra 里躺着值(含空白、含读不出类型)而没换来正文 ⇒
* `contentUnreadable`,走既有的「读不进来」告知;而**三个都没有值**(用户确实什么都没分享)照旧
* 静默。两者的分野就是 `textPresent` 这个由调用方(读了原始值的那一层)报进来的事实。
*
* 真实 `Intent` 的读取在 `app/ShareIntake.kt`(`rawExtra` + [top.yeij.cyrene.app.shareTextExtrasOf],
* 那个「值在不在」的判断与 `runCatching` 的守卫各有一份用例,见 `ShareTextExtrasTest`)。
*/
class ShareTextFallbackTest {
/** 一次最普通的 `ACTION_SEND`,只把三个文本 extra 摆出来(其余字段都空)。 */
private fun extract(
text: CharSequence? = null,
subject: CharSequence? = null,
title: CharSequence? = null,
textPresent: Boolean = false,
): SharedContent = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = text,
subject = subject,
title = title,
textPresent = textPresent,
)
@Test
fun `a subject only share becomes the body and says where it came from`() {
// 这次分享的现场:`EXTRA_TEXT` 一个值都没有,内容全在 `EXTRA_SUBJECT` 里。
val share = extract(subject = "分享自某某App:一篇文章的标题")
assertEquals("分享自某某App:一篇文章的标题", share.text)
assertEquals(ShareTextSource.Subject, share.textSource)
// 拿到的是一段真内容 ⇒ 既不是「空分享」、也不是「读不进来」,与一次普通的文本分享没有区别。
assertFalse(share.isEmpty)
assertFalse(share.contentUnreadable)
assertTrue(share.worthDelivering)
}
@Test
fun `a title only share becomes the body`() {
val share = extract(title = "只有标题的一次分享")
assertEquals("只有标题的一次分享", share.text)
// 来源分档只为「如实登记」,用户那边 Subject 与 Title 说的是同一句话(见 `ShareTextOrigin.kt`)。
assertEquals(ShareTextSource.Title, share.textSource)
assertTrue(share.worthDelivering)
}
@Test
fun `a non blank text always wins the chain`() {
// 既有情形逐字不变:三个都有值时,正文与来源都是今天那一份(回落链对它**完全**不生效)。
val share = extract(text = "对方写的正文", subject = "对方给的标题", title = "对方给的另一个标题")
assertEquals("对方写的正文", share.text)
assertEquals(ShareTextSource.Text, share.textSource)
}
@Test
fun `a blank text falls back to the subject`() {
// 空白串(不是 null)同样是「没有正文」:口径与取 text 时那一次 trim 完判空完全一致。
val share = extract(text = " \n\t ", subject = "标题顶上")
assertEquals("标题顶上", share.text)
assertEquals(ShareTextSource.Subject, share.textSource)
}
@Test
fun `a blank text and a blank subject fall back to the title`() {
val share = extract(text = " ", subject = "\n", title = "第三站顶上")
assertEquals("第三站顶上", share.text)
assertEquals(ShareTextSource.Title, share.textSource)
}
@Test
fun `the body is trimmed the same way whichever station it came from`() {
// 三站的归一化口径必须一模一样(`toString` → 两侧 trim):不然同一段字从 SUBJECT 来就会多带
// 一个换行,而正文那一段是要**逐字**发出去的。
listOf(
ShareTextSource.Text to extract(text = " 正文 "),
ShareTextSource.Subject to extract(subject = " 正文 "),
ShareTextSource.Title to extract(title = " 正文 "),
).forEach { (source, share) ->
assertEquals(source.name, "正文", share.text)
assertEquals(source.name, source, share.textSource)
}
}
@Test
fun `a fallen back body is a normal share in every other respect`() {
// 回落来的正文走的是同一条既有的路:`isEmpty` / `worthDelivering` / 附件上限这些判据一个字都
// 没为它改过 —— 除了「来源」那一格。
val text = extract(text = "正文")
val subject = extract(subject = "正文")
assertTrue(subject.text == text.text)
assertEquals(text.isEmpty, subject.isEmpty)
assertEquals(text.contentUnreadable, subject.contentUnreadable)
assertEquals(text.images, subject.images)
assertEquals(text.documents, subject.documents)
assertEquals(ShareTextSource.Text, text.textSource)
}
@Test
fun `when none of the three yields a body we speak instead of staying silent`() {
// 三者都空(甚至读不出类型)**不是**「什么都没分享」:用户点了分享,本端一个字节都没读出来。
// 那件事必须能说出口(`textPresent` 由读了原始值的调用方如实报进来),否则又是一次静默。
val share = extract(text = null, subject = " ", title = null, textPresent = true)
assertTrue(share.isEmpty)
assertTrue(share.contentUnreadable)
assertTrue(share.worthDelivering)
}
@Test
fun `a share whose three extras hold nothing at all still stays silent`() {
// 边界(既有口径,不许放宽):三个 extra **连值都没有** ⇒ 用户确实什么都没分享,说一句才是噪声。
// 分野就在 `textPresent` 这一个事实上,不在「正文是不是空的」上。
val share = extract()
assertTrue(share.isEmpty)
assertFalse(share.contentUnreadable)
assertFalse(share.worthDelivering)
}
@Test
fun `a fallen back body next to unusable streams is not a whole read failure`() {
// 「读不进来」那条判据只在**一条可用内容都没有**时成立:从标题回落的正文照样是一次拿到了东西
// 的分享,不能因为对方给的那几个地址用不了就说成「这次分享的内容没能读进来」。
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
subject = "从标题来的正文",
streamStrings = listOf("file:///etc/hosts"),
textPresent = true,
)
assertEquals("从标题来的正文", share.text)
assertFalse(share.contentUnreadable)
}
@Test
fun `an unrelated intent with a subject in it still extracts to nothing`() {
// 不是分享 action 就不是分享:哪怕三个 extra 里照样躺着值,也不该走这条路(更不该出一条告知)。
val share = ShareRules.extract(
action = "android.intent.action.VIEW",
subject = "标题",
textPresent = true,
)
assertEquals(SharedContent(), share)
}
}
@@ -5,6 +5,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.VoiceDto
/**
* 「这条消息什么都没传过来」的判据(纯函数,`user` 消息的空气泡修复)。
@@ -15,8 +16,9 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
*
* 这个测试要钉住两件事:
* 1. 真的什么都没有时成立(空 / 纯空白);
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链,
* 以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上「内容没传过来」的帽子。
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链、
* 有 `voice` 键(语音形态),以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上
* 「内容没传过来」的帽子。
*/
class ContentlessMessageTest {
@@ -26,6 +28,7 @@ class ContentlessMessageTest {
image: String? = null,
documents: List<DocumentRefDto> = emptyList(),
thinking: String? = null,
voice: VoiceDto? = null,
) = HistoryMessageDto(
id = "m1",
sessionId = "web",
@@ -34,6 +37,7 @@ class ContentlessMessageTest {
image = image,
documents = documents,
thinking = thinking,
voice = voice,
createdAt = 1_791_088_571_785L,
)
@@ -65,6 +69,17 @@ class ContentlessMessageTest {
assertTrue(isContentlessUserMessage(message(image = " ")))
}
@Test
fun `a voice message is never contentless`() {
// 语音的转写文本可能为空(`status = empty`,或微信那条根本没有副本),但那条消息
// **不是什么都没有**:它有语音形态。判据只看 `voice` 键在不在(契约 §4.4)。
assertFalse(
isContentlessUserMessage(
message(content = "", voice = VoiceDto(source = "platform", status = "empty")),
),
)
}
// —— 不成立:凡有一点点内容就一个字都不说 ——
@Test
@@ -0,0 +1,202 @@
package top.yeij.cyrene.ui.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.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.VoiceDto
import top.yeij.cyrene.domain.parse.ParsedBubble
/**
* 「这条消息外侧那枚时间挂在哪一块上」的判据(纯函数 [messageTimeCarrier])。
*
* **背景**:主人 2026-10-07 验收 W69 时报的缺口 —— 纯文字消息都有时间,**纯附件消息边上没有**。
* 根因在 W69 的落脚点写法上:时间挂在「这条消息**最后一个有正文的气泡**」外侧,而纯附件消息
* (`documents` 非空 / `image` 非空、`content` 为空)解析出来的唯一那个气泡 `body` 也是空,
* [Bubble] 见空 `body` 什么都不画 ⇒ 那一行**根本没被画出来**。
*
* 这个测试要钉住三件事:
* 1. **一个正文气泡都没有时,时间落到最后画出来的那个附件块上**(附件芯片排 / 图片 / 语音形态),
* 而且顺序与渲染顺序一致(谁最后画谁拿)——这就是「纯附件消息也有时间」;
* 2. **有正文气泡时一个字都不许变**(W69 的口径:最后一个有正文的气泡,末段的纯旁白气泡不算);
* 3. **真的空气泡**([isContentlessUserMessage])在这里返回 `null`,但那种消息**仍然有时间**
* —— 那一处由 [MessageBubble] 给 [ContentlessNote] 单独包一层 [BubbleWithTime],是另一条路。
*
* 「贴左还是贴右」不在这里测:那是 [BubbleWithTime] 按 `isUser` 定的,本函数只回答「挂在哪一块」。
*/
class MessageTimeCarrierTest {
private fun message(
role: String = "user",
content: String = "",
image: String? = null,
documents: List<DocumentRefDto> = emptyList(),
thinking: String? = null,
voice: VoiceDto? = null,
createdAt: Long = 1_791_088_571_785L,
) = HistoryMessageDto(
id = "m1",
sessionId = "web",
role = role,
content = content,
image = image,
documents = documents,
thinking = thinking,
voice = voice,
createdAt = createdAt,
)
private val document = DocumentRefDto(
name = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt",
mime = "text/plain",
size = 10,
chars = 10,
status = "extracted",
)
/** 解析出来的一个正文气泡(`plainBubble` 那份形状)。 */
private fun body(text: String) = ParsedBubble(
body = text,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
)
/**
* 纯附件消息的 `bubbles`:`content` 为空 ⇒ 解析器一个都没给 ⇒ `MessageBubble` 兜一个占位气泡
* (`plainBubble("")`)。它 `body` 为空,所以 [Bubble] 一个像素都不画 —— 缺口就在这一步。
*/
private val placeholderBubbles = listOf(body(""))
private val voiceWithoutTranscript = VoiceDto(source = "asr", status = "empty")
// —— 一个正文气泡都没有:落到最后画出来的那个附件块上 ——
@Test
fun `an attachments only message hangs the time on the chip row`() {
val message = message(content = "", documents = listOf(document))
assertEquals(
MessageTimeCarrier.Attachments,
messageTimeCarrier(message, placeholderBubbles),
)
}
@Test
fun `an image only message hangs the time on the image`() {
val message = message(content = "", image = "http://host/api/v1/files/a.png")
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(message, placeholderBubbles))
}
@Test
fun `a voice message with nothing transcribed hangs the time on the voice row`() {
// `status = empty` ⇒ 正文为空,而这条消息**不是什么都没有**(`isContentlessUserMessage`
// 特意把语音排除掉)。它的可视块只有语音形态,时间就该落在那里。
val message = message(content = "", voice = voiceWithoutTranscript)
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(message, placeholderBubbles))
assertFalse(isContentlessUserMessage(message))
}
@Test
fun `when several blocks are there the last one drawn wins`() {
// 渲染顺序是「附件芯片排 → 图片 → 语音形态」,所以时间的落点也按这个顺序往回退。
val chipsAndImage = message(
content = "",
documents = listOf(document),
image = "http://host/api/v1/files/a.png",
)
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(chipsAndImage, placeholderBubbles))
val allThree = message(
content = "",
documents = listOf(document),
image = "http://host/api/v1/files/a.png",
voice = voiceWithoutTranscript,
)
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(allThree, placeholderBubbles))
}
@Test
fun `her attachments get the time too`() {
// 判据与角色无关:她也会发只有图 / 只有附件的一条。
val message = message(role = "assistant", content = "", documents = listOf(document))
assertEquals(
MessageTimeCarrier.Attachments,
messageTimeCarrier(message, placeholderBubbles),
)
}
// —— 有正文气泡时:W69 的口径一个字都不变 ——
@Test
fun `a text bubble always wins over the blocks above it`() {
val message = message(
content = "甲",
documents = listOf(document),
image = "http://host/api/v1/files/a.png",
)
assertEquals(MessageTimeCarrier.TextBubble(0), messageTimeCarrier(message, listOf(body("甲"))))
}
@Test
fun `the last non empty bubble carries the time`() {
val bubbles = listOf(body("甲"), body("乙"))
assertEquals(
MessageTimeCarrier.TextBubble(1),
messageTimeCarrier(message(content = "甲<p>乙</p>"), bubbles),
)
}
@Test
fun `a trailing annotation only bubble never carries the time`() {
// 末段只有旁白(`<action>`/`<narration>`)时不画正文框:时间退回上一个有正文的气泡。
val bubbles = listOf(body("甲"), body(""))
assertEquals(
MessageTimeCarrier.TextBubble(0),
messageTimeCarrier(message(content = "甲<action>她抬头</action>"), bubbles),
)
}
// —— 真的什么都没有:这里返回 null,时间由 ContentlessNote 那条路自己画 ——
@Test
fun `a truly empty user message has no block to hang the time on`() {
val message = message(content = " ")
// 判据与 `isContentlessUserMessage` 是互补的:那条路**仍然**有时间(`BubbleWithTime`
// 包着 `ContentlessNote`),所以这里返回 null 不等于「这条消息没有时间」。
assertTrue(isContentlessUserMessage(message))
assertNull(messageTimeCarrier(message, placeholderBubbles))
}
@Test
fun `blank image strings and empty documents are not blocks`() {
val message = message(content = "", image = " ", documents = emptyList())
assertNull(messageTimeCarrier(message, placeholderBubbles))
}
// —— 与时间戳缺失的关系:两件事分开判 ——
@Test
fun `the carrier ignores the timestamp and the label ignores the carrier`() {
// 「`createdAt <= 0` ⇒ 整条不画」是 [messageTimeLabel] 那一档的事:落点照旧算出来,
// 只是文案为 null ⇒ `BubbleWithTime` 连 `Box` 都不建。两处刻意分开判,谁都不替谁兜底。
val message = message(content = "", documents = listOf(document), createdAt = 0L)
assertEquals(
MessageTimeCarrier.Attachments,
messageTimeCarrier(message, placeholderBubbles),
)
assertNull(messageTimeLabel(createdAt = 0L, nowMillis = 1_791_088_571_785L))
}
}
@@ -0,0 +1,183 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* 气泡外侧那枚时间([messageTimeLabel])的回归钉子。
*
* 四档文案错了不会有异常、也不会有编译错误——只是屏幕上写着一个错的日子,而且最容易错的恰恰是
* 边界(`23:59` 与次日 `00:01`、12 月 31 日与 1 月 1 日、闰年 2 月 29 日)。所以这里把每一档的
* 两侧都钉住:判据按**本地日历日**分,不是「相差不到 24 小时」。
*
* 时区与「现在」都由测试喂(`zone` 注入、`nowMillis` 传参),所以这些断言与跑测试的机器无关。
*/
class MessageTimeLabelTest {
private val shanghai = ZoneId.of("Asia/Shanghai")
private val utc = ZoneId.of("UTC")
/** 本地 `yyyy-MM-dd HH:mm` → 毫秒:测试里只写看得懂的时刻,不摆魔数。 */
private fun at(
year: Int,
month: Int,
day: Int,
hour: Int,
minute: Int,
zone: ZoneId = shanghai,
): Long = ZonedDateTime.of(year, month, day, hour, minute, 0, 0, zone).toInstant().toEpochMilli()
// ---------------------------------------------------------------- 今天:只给时分
@Test
fun `same day renders hour and minute`() {
val now = at(2025, 8, 14, 20, 0)
assertEquals("14:30", messageTimeLabel(at(2025, 8, 14, 14, 30), now, shanghai))
}
@Test
fun `exactly midnight is still today`() {
val now = at(2025, 8, 14, 9, 0)
assertEquals("00:00", messageTimeLabel(at(2025, 8, 14, 0, 0), now, shanghai))
assertEquals("23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), at(2025, 8, 14, 23, 59), shanghai))
}
@Test
fun `one digit hours and minutes stay zero padded`() {
val now = at(2025, 8, 14, 12, 0)
assertEquals("09:05", messageTimeLabel(at(2025, 8, 14, 9, 5), now, shanghai))
}
// ---------------------------------------------------------------- 昨天:跨天不由时长决定
@Test
fun `two minutes across midnight flips to yesterday`() {
val now = at(2025, 8, 15, 0, 1)
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), now, shanghai))
assertEquals("00:01", messageTimeLabel(at(2025, 8, 15, 0, 1), now, shanghai))
}
@Test
fun `yesterday keeps the time`() {
val now = at(2025, 8, 14, 10, 0)
assertEquals("昨天 09:05", messageTimeLabel(at(2025, 8, 13, 9, 5), now, shanghai))
}
@Test
fun `yesterday across a month boundary`() {
val now = at(2025, 3, 1, 10, 0)
// 2025-02-28 → 2025-03-01 是**昨天**(不是「2月28日」那档),月份少一天 / 多一天都不影响。
assertEquals("昨天 23:30", messageTimeLabel(at(2025, 2, 28, 23, 30), now, shanghai))
}
@Test
fun `yesterday across a year boundary`() {
val now = at(2026, 1, 1, 0, 1)
// 12-31 与 01-01 只差两分钟,「昨天」这一档**优先于**跨年那一档。
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 12, 31, 23, 59), now, shanghai))
assertEquals("昨天 09:00", messageTimeLabel(at(2025, 12, 31, 9, 0), at(2026, 1, 1, 0, 30), shanghai))
}
// ---------------------------------------------------------------- 今年内更早:月日 + 时分
@Test
fun `earlier this year shows month day and time`() {
val now = at(2025, 8, 14, 10, 0)
assertEquals("3月7日 09:05", messageTimeLabel(at(2025, 3, 7, 9, 5), now, shanghai))
}
@Test
fun `month and day are not zero padded`() {
val now = at(2025, 12, 31, 23, 59)
assertEquals("1月1日 00:00", messageTimeLabel(at(2025, 1, 1, 0, 0), now, shanghai))
}
@Test
fun `a week ago stays in the month day branch`() {
val now = at(2025, 8, 14, 10, 0)
// 没有「星期几」这一档:七天前与半年后走的是同一档。
assertEquals("8月7日 09:05", messageTimeLabel(at(2025, 8, 7, 9, 5), now, shanghai))
}
// ---------------------------------------------------------------- 跨年:只给年月日
@Test
fun `crossing the year boundary drops the clock`() {
val now = at(2026, 1, 1, 0, 1)
assertEquals("2025年12月30日", messageTimeLabel(at(2025, 12, 30, 23, 59), now, shanghai))
}
@Test
fun `the year decides the branch not the elapsed time`() {
val now = at(2026, 8, 14, 10, 0)
// 整整一年前、同一个「8月14日」:仍然是跨年那一档(不带时分)。
assertEquals("2025年8月14日", messageTimeLabel(at(2025, 8, 14, 10, 0), now, shanghai))
}
// ---------------------------------------------------------------- 闰年
@Test
fun `leap day is yesterday in a leap year and a full date afterwards`() {
assertEquals(
"昨天 23:59",
messageTimeLabel(at(2024, 2, 29, 23, 59), at(2024, 3, 1, 0, 0), shanghai),
)
assertEquals(
"2024年2月29日",
messageTimeLabel(at(2024, 2, 29, 12, 0), at(2025, 3, 1, 12, 0), shanghai),
)
}
// ---------------------------------------------------------------- 未来时间(时钟漂移)
@Test
fun `future timestamps fall back to the today branch`() {
val now = at(2025, 8, 14, 14, 30)
// 同一刻前几分钟:服务端时钟略快 / 本机略慢,就是个普通时刻。
assertEquals("14:35", messageTimeLabel(at(2025, 8, 14, 14, 35), now, shanghai))
// 整整一天 / 一年之后也一样:不造「明天」这个词,也不摆一个假日期(见 KDoc)。
assertEquals("09:00", messageTimeLabel(at(2025, 8, 15, 9, 0), now, shanghai))
assertEquals("00:00", messageTimeLabel(at(2026, 1, 1, 0, 0), now, shanghai))
}
// ---------------------------------------------------------------- 缺失 / 非法
@Test
fun `missing or bogus timestamps render nothing`() {
val now = at(2025, 8, 14, 14, 30)
// 0 = 这个键根本没传过来;负数只可能来自解析事故。都不是真实时刻 ⇒ 一个字都不画。
assertNull(messageTimeLabel(0L, now, shanghai))
assertNull(messageTimeLabel(-1L, now, shanghai))
assertNull(messageTimeLabel(Long.MIN_VALUE, now, shanghai))
assertNull(messageTimeLabel(-1L, now, utc))
}
// ---------------------------------------------------------------- 时区
@Test
fun `the day boundary follows the injected zone`() {
// 同一毫秒:上海是 8 月 15 日 07:30(今天),UTC 还是 8 月 14 日 23:30(昨天)。
val millis = at(2025, 8, 14, 23, 30, utc)
val now = at(2025, 8, 15, 1, 0, utc)
assertEquals("07:30", messageTimeLabel(millis, now, shanghai))
assertEquals("昨天 23:30", messageTimeLabel(millis, now, utc))
}
}
@@ -0,0 +1,329 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.TurnBinding
import top.yeij.cyrene.domain.chat.TurnBucket
import top.yeij.cyrene.domain.chat.TurnPhase
import top.yeij.cyrene.domain.chat.allClosed
import top.yeij.cyrene.domain.chat.withTurnBegin
import top.yeij.cyrene.domain.parse.ParsedBubble
/**
* **连接重建时把「进行中」全部收口**([isReconnect] + [settledAfterReconnect])。
*
* 修的是主人 2026-10-06 报的那个现场:后端卡在回合里被重启 ⇒ 那一轮在服务端已经不存在 ⇒
* 本端**永远**等不到 `turn.end` / `stream_end` / 助手落库行 ⇒ 气泡永久灰着、思维链永久「思考中…」、
* 工具卡永久「执行 X…」、底栏永久「正在思考」。
*
* 四组测试各钉一件事:
* 1. **收口真的发生**:连接重建 ⇒ 未收口的绑定全 `closed`(六态 ④ → ⑤)、流式卡片全部停止生长;
* 2. **第一次连接不算重建**:`Idle/Connecting → Connected` 一个字节都不收(判据的穷举表在下面);
* 3. **幂等且单调**:已经收口的不会被翻回,收两次等于一次;
* 4. **可降级**:消息 / 待发 / 草稿 / 队列 / 错误**一律原样**——收口只改「进行中的外观」。
*/
class ReconnectResetTest {
private fun bucket(turnId: String) = TurnBucket("web", turnId)
private fun bubble(text: String) =
ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList())
private fun message(id: String, role: String = "user", turnId: String? = null) =
HistoryMessageDto(
id = id,
sessionId = "web",
role = role,
content = id,
createdAt = 1L,
turnId = turnId,
)
/** 两路都还开着 + 一切「进行中」的字面承诺都挂着 —— 后端重启那一刻的本端样子。 */
private fun stuckState() = ChatUiState(
messages = listOf(message("m-a")),
pending = listOf(PendingMessage("local-1", "还没发出去")),
streams = listOf(StreamingLeg(bucket("t1"), streaming("半句回复", thinking = "她在想…"))),
turns = emptyList<TurnBinding>()
.withTurnBegin("t1", 1, listOf("m-a"), 2)
.withTurnBegin("t2", 2, listOf("m-b"), 2, kind = "parallel"),
queuedKeys = setOf("local-1"),
queueDepth = 3,
draft = "打到一半的字",
busyMode = "thinking",
activeTurns = 2,
error = "上一次失败的原因",
)
private fun streaming(text: String, thinking: String = "") = StreamingState(
bubbles = text.takeIf { it.isNotBlank() }?.let { listOf(bubble(it)) }.orEmpty(),
thinking = thinking,
thinkingActive = thinking.isNotBlank(),
active = true,
)
// —— 1. 收口真的发生 ——
@Test
fun `a rebuilt connection closes every binding that was still open`() {
// 两路都还在飞(并行的两路也一样)⇒ 一次收口把**所有**未收口的绑定标 closed。
// 六态的判据只有这一个闸(`turnPhaseOf` 第 2/3 条),所以这就是「气泡不再永远灰着」。
val stuck = stuckState()
assertEquals(TurnPhase.Answering, stuck.phaseOf(stuck.messages.single()))
val settled = stuck.settledAfterReconnect()
assertTrue(settled.turns.isNotEmpty())
assertTrue("每一路都该收口", settled.turns.all { it.closed })
assertEquals(TurnPhase.Answered, settled.phaseOf(settled.messages.single()))
}
@Test
fun `a rebuilt connection stops every leg from growing`() {
// 「思考中…」/「执行 X…」的唯一闸就是这几个位;没有卡住的那一路不在这里(它还在正常跑)。
val stuck = stuckState().copy(
streams = listOf(
StreamingLeg(bucket("t1"), streaming("半句回复", thinking = "她在想…")),
StreamingLeg(
bucket("t2"),
StreamingState(tools = listOf(ToolCallState("call-1", 0, tool = "search"))),
),
),
)
val settled = stuck.settledAfterReconnect()
assertEquals(2, settled.streams.size)
settled.streams.forEach { leg ->
assertFalse("收口后不再生长", leg.state.active)
assertFalse("标题不该再是「思考中…」", leg.state.thinkingActive)
}
assertEquals(listOf("半句回复"), settled.streams.first().state.bubbles.map { it.body })
assertEquals("她在想…", settled.streams.first().state.thinking)
}
@Test
fun `a rebuilt connection takes the busy banner down with it`() {
// 底栏那句「正在思考…」是**服务端报的事实**(session_busy)——旧连接没了它就不可知了。
// 与 `session_idle` / `session_interrupted` 那两处的处置逐字一致。
val settled = stuckState().settledAfterReconnect()
assertNull(settled.busyMode)
assertEquals(0, settled.activeTurns)
assertEquals(0, settled.queueDepth)
}
// —— 2. 「是不是重建」的判据(穷举) ——
@Test
fun `the first connection is never a rebuild`() {
// 冷启动:Idle → Connecting → Connected。把这一跳当重建会在用户刚打开会话的瞬间
// 把正当在跑的路收口掉(首屏一到就可能有 turn_begin 把它绑成灰态)。
assertFalse(isReconnect(WsState.Idle, WsState.Connected, everConnected = false))
assertFalse(isReconnect(WsState.Connecting, WsState.Connected, everConnected = false))
}
@Test
fun `coming back to connected after a rebuild is a rebuild`() {
// 后端重启:Connected → Reconnecting → Connected。这是本判据的主场。
assertTrue(isReconnect(WsState.Reconnecting, WsState.Connected, everConnected = true))
// 合流吞掉中间那一跳:收集器只看到 Connected → Connected,它**仍然是**一次真实的重建。
assertTrue(isReconnect(WsState.Connected, WsState.Connected, everConnected = true))
// 没记过账时的第二证据:上一跳是重连退避(只在本 VM 是离线期间建起来时才走到)。
assertTrue(isReconnect(WsState.Reconnecting, WsState.Connected, everConnected = false))
// 离线 → 重新 connect(`close()` 之后 attempt 归零走 Connecting)也是一次重建,
// 只要本 VM 见过 Connected。
assertTrue(isReconnect(WsState.Offline, WsState.Connected, everConnected = true))
}
@Test
fun `anything that is not connected is not a rebuild`() {
// 掉下去的那一刻不收口(那一刻正是「进行中」最要紧的时候:还在等它自己跑完)。
for (previous in WsState.entries) {
for (ever in listOf(false, true)) {
for (current in WsState.entries.filter { it != WsState.Connected }) {
assertFalse(
"previous=$previous current=$current ever=$ever",
isReconnect(previous, current, ever),
)
}
}
}
}
@Test
fun `the judgement is exhaustive over every state triple`() {
// 5 × 5 × 2 = 50 格全表。期望值**独立**写一遍(当前不是 Connected ⇒ 假;见过 Connected ⇒
// 真;没记过账但上一跳是重连退避 ⇒ 真;其余(第一次接上)⇒ 假)。
for (previous in WsState.entries) {
for (current in WsState.entries) {
for (ever in listOf(false, true)) {
val expected = when {
current != WsState.Connected -> false
ever -> true
previous == WsState.Reconnecting -> true
else -> false
}
assertEquals(
"previous=$previous current=$current everConnected=$ever",
expected,
isReconnect(previous, current, ever),
)
}
}
}
}
// —— 3. 幂等 / 单调 ——
@Test
fun `settling twice is the same as settling once`() {
val once = stuckState().settledAfterReconnect()
assertSame(once, once.settledAfterReconnect())
}
@Test
fun `nothing in flight returns the very same instance`() {
// 每次 Connected 都会调它;没有「进行中」可收时必须返回同一个实例(否则 StateFlow 白发一轮)。
val idle = ChatUiState(messages = listOf(message("m-a")))
assertSame(idle, idle.settledAfterReconnect())
assertSame(emptyList<StreamingLeg>(), emptyList<StreamingLeg>().allFinished())
assertSame(emptyList<ToolCallState>(), emptyList<ToolCallState>().settledAfterReconnect())
}
@Test
fun `a binding that was already closed is never flipped back`() {
// `closed` 是**单调**的既成事实(`withTurnBegin` 合并时也保持原值)——收口之后
// 一句迟到的「这一路还在跑」不许把它翻回灰态。
val closed = emptyList<TurnBinding>()
.withTurnBegin("t1", 1, listOf("m-a"), 1)
.allClosed()
val state = ChatUiState(messages = listOf(message("m-a")), turns = closed)
val settled = state.settledAfterReconnect()
assertTrue(settled.turns.all { it.closed })
assertEquals(TurnPhase.Answered, settled.phaseOf(settled.messages.single()))
// 一句迟到的 turn_begin 也不会把它翻回去(既有的 withTurnBegin 纪律,这里一起钉住)。
val reopened = settled.turns.withTurnBegin("t1", 1, listOf("m-a"), 1)
assertEquals(TurnPhase.Answered, settled.copy(turns = reopened).phaseOf(settled.messages.single()))
}
// —— 4. 可降级:收口只改「进行中的外观」 ——
@Test
fun `settling touches nothing the user typed or sent`() {
val stuck = stuckState()
val settled = stuck.settledAfterReconnect()
assertSame(stuck.messages, settled.messages)
assertSame(stuck.pending, settled.pending)
assertSame(stuck.queuedKeys, settled.queuedKeys)
assertEquals(stuck.draft, settled.draft)
assertEquals(stuck.error, settled.error)
assertFalse(settled.sessionGone)
// 绑定表**只标 closed,不删**:并行路角标靠它,删了等于事后篡改历史。
assertEquals(stuck.turns.map { it.turnId }, settled.turns.map { it.turnId })
assertEquals(stuck.turns.map { it.messageIds }, settled.turns.map { it.messageIds })
}
// —— 工具卡的收口 ——
@Test
fun `a running tool card stops claiming to be running`() {
val tools = listOf(
ToolCallState(
id = "call-1",
index = 0,
tool = "search",
arguments = "{\"q\":1}",
output = "前 3 条",
done = true,
phase = ToolPhase.Running,
),
)
val settled = tools.settledAfterReconnect()
val card = settled.single()
assertNull("不再声称在跑", card.phase)
assertTrue("省略号必须去掉", card.done)
// 参数与输出一个都没动:这是「不再是进行中」,不是「清空这张卡」。
assertEquals("{\"q\":1}", card.arguments)
assertEquals("前 3 条", card.output)
assertEquals("search", card.tool)
}
@Test
fun `a tool call without an end frame loses its ellipsis too`() {
// 只到了 `tool_call.*`、没到 `tool_execution.*`:标题是「调用工具…」——那个省略号
// 同样是「还在跑」,收口之后它不再在跑。
val settled = listOf(ToolCallState("call-1", 0, tool = "search")).settledAfterReconnect()
assertTrue(settled.single().done)
assertNull(settled.single().phase)
}
@Test
fun `a tool card with a verdict from the server is left alone`() {
// Done / Error 是**服务端给的结论**,本端不许改写它,更不许把它降级成「不知道」。
val done = ToolCallState("c1", 0, phase = ToolPhase.Done, done = true, resultSummary = "2 条")
val failed = ToolCallState("c2", 1, phase = ToolPhase.Error, done = true)
val tools = listOf(done, failed).also { assertEquals(2, it.size) }
val settled = tools.settledAfterReconnect()
assertSame(ToolPhase.Done, settled[0].phase)
assertSame(ToolPhase.Error, settled[1].phase)
assertEquals("2 条", settled[0].resultSummary)
assertSame(tools, tools.settledAfterReconnect())
}
@Test
fun `a real frame after settling can put the card back to running`() {
// 如实登记「闪动」的方向:收口之后**真的又来了一帧**时,卡片会重新变成「执行 X…」——
// 那是如实的(它确实又在跑了),而且是自愈的:这条帧不来的话卡片就停在「工具调用 · X」。
val settled = listOf(ToolCallState("c1", 0, tool = "search", phase = ToolPhase.Running))
.settledAfterReconnect()
val again = applyToolExecution(settled, "c1", "search", "running")
assertSame(ToolPhase.Running, again.single().phase)
}
@Test
fun `an empty leg is dropped instead of lingering as a ghost`() {
// `active = true` 而什么都不画时,气泡里只有一个光标;收口它等于留下一个**一个像素都不画、
// 却仍占着列表末项**的幽灵(末项正是「跟随底部」的判据)。这一路本来就没内容,丢掉它
// 是本函数唯一的删除动作。
val empty = listOf(StreamingLeg(bucket("t1"), StreamingState(active = true)))
assertEquals(emptyList<StreamingLeg>(), empty.allFinished())
}
@Test
fun `a leg with only a running tool card survives as an inert card`() {
val leg = StreamingLeg(
bucket("t1"),
StreamingState(
active = true,
tools = listOf(ToolCallState("c1", 0, tool = "search", phase = ToolPhase.Running)),
),
)
val settled = listOf(leg).allFinished()
assertEquals(1, settled.size)
assertFalse(settled.single().state.active)
assertNull(settled.single().state.tools.single().phase)
}
}
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -43,12 +44,26 @@ import top.yeij.cyrene.domain.share.SharedDocument
* `the preview window stays three lines tall`。
*
* ## W56 又加上了什么(主人拍板:「可以滚动 可以展开」,两个都要)
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以正文排下来超过三行时([sharePreviewTextOverflowsWindow])
* 卡片右上一枚「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以这一窗**装不下**里面的内容时
* ([sharePreviewTextOverflowsWindow])卡片右上一枚「展开」把这一窗放到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
* **展开与滚动本身仍然是渲染层的事、单测够不着**,所以这里钉的同样是它们的两个前提:**什么时候该
* 出现那枚控件**、**展开态给多高**——两条判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
* [sharePreviewMaxHeight]),见 `the expand control shows up only when the body does not fit the
* window` 与 `expanding gives the preview more room, still within a bound`。
* 出现那枚控件**、**展开态给多高**——判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
* [sharePreviewWindowHeight] / [sharePreviewMaxHeight]),见
* `the expand control shows up only when the content does not fit the window`、
* `the window is as tall as three measured lines, whatever the font scale says` 与
* `expanding gives the preview more room, still within a bound`。
*
* ## W65 把「超没超窗」的判据整个换掉了(读这一节才知道为什么是高度)
* W65 之前预览的正文是**一个 `Text`**(原样那一段字),所以「装不装得下」数得出来:`lineCount > 3`。
* W65 起预览也走 `domain/parse` 的解析([sharePreviewBlocks],与气泡同一个 `parse`),这一窗里成了
* **多个块**,每块还各自带旁白行与块间距——行数再也不覆盖窗口的量。判据因此从「数行数」换成
* **比高度**:内容自然高度 vs 窗口高度([sharePreviewTextOverflowsWindow] 的两个参数);窗口高本身也
* 不再取「第三行的底边」,而是「量到的一行 × [SHARE_PREVIEW_TEXT_LINES]」
* ([sharePreviewWindowHeight])——多块之后没有任何一个块的第三行等于这一窗的第三行。
*
* ⚠️ 解析口径本身的变化(预览与气泡由此同源)不在本文件里钉,在 `ShareTextPipelineTest` 里
* (那一份测的正是「从分享进来一路到气泡」这条跨层串)。
*/
class ShareConfirmTest {
@@ -259,29 +274,56 @@ class ShareConfirmTest {
}
// ——— W56:「展开 / 收起」(主人拍板:滚动与展开**两个都要**)———
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的两个前提:
// 那枚控件什么时候出现、展开态给多高。两条判据都在 ShareConfirmPanel.kt 里做成了纯函数。
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的前提:
// 那枚控件什么时候出现、这一窗有多高、展开态给多高。三条判据都在 ShareConfirmPanel.kt 里做成了
// 纯函数(W65 把「什么时候出现」从数行数换成了比高度,理由见那边的 KDoc)。
@Test
fun `the expand control shows up only when the body does not fit the window`() {
// 正好三行 = 整段都在窗里(这一窗的底就是第三行的底边):此时给一枚「展开」就是主人说的
// 「点了没反应的展开」。多一行就是第四行落在窗外,展开确有东西可看。
assertFalse(sharePreviewTextOverflowsWindow(0))
assertFalse(sharePreviewTextOverflowsWindow(1))
assertFalse(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES))
assertTrue(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES + 1))
assertTrue(sharePreviewTextOverflowsWindow(40))
fun `the expand control shows up only when the content does not fit the window`() {
// 判据的两边都是**排出来的**(W65 起):内容自然高(挂在 `verticalScroll` **里面**量到的那个,
// 不受这一窗上限影响)与窗口高(量到的行高 × 三)。这里把它的全部形状穷举完。
//
// 还没量到的那一帧(0):不给控件——首帧摆上一枚点了没反应的「展开」,比它晚一帧出现更糟。
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 0))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 100))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 0))
// 判据跟着那一窗的行数走(上面用的是常量),所以再用主人拍的那个数字面量钉一遍「这条线是
// 3」——两条一起才既钉住行为、又钉住那个数(同 `the preview window stays three lines tall`
// 的口径)。
assertFalse(sharePreviewTextOverflowsWindow(3))
assertTrue(sharePreviewTextOverflowsWindow(4))
// 边界是**严格大于**:一样高就是整段都在窗里(窗底一条不多),此时给一枚「展开」是主人说的
// 「点了没反应的展开」;多一个像素就是窗里还有东西没露出来,展开确有东西可看。
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 100))
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 101, windowHeightPx = 100))
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 400, windowHeightPx = 60))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 60))
// 接一份真分享走一遍:正文里每有一个换行,排下来就至少多一行,所以 40 行的那段一定超过窗口
// (排版后的行数只会比 `lines().size` 更多——单行太长还会折行)。
// 单调:内容只增、窗口只减,都只会把它推向「超窗」——两个方向各钉一条,防止将来有人把比较写反。
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 1000, windowHeightPx = 60))
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 600))
// 接一份真分享走一遍:40 行的正文在解析后仍然是一块(无标签的正文按既有语义整段成块),而这一块
// 排下来必然高过三行窗。⚠️ 「高多少」是**渲染层排出来的像素**,单测这里拿不到(本模块没有 Compose
// UI 测试依赖),所以这一条只钉到「内容就是那 40 行」为止——旧口径按行数比得出 40 > 3,新口径下
// 那个比较整个属于渲染层,这一份测试能守的就是它的输入。
val preview = shareConfirmPreview(SharedContent(text = (1..40).joinToString("\n") { "第 $it 行" }))
assertTrue(sharePreviewTextOverflowsWindow(preview.text.lines().size))
assertEquals(40, sharePreviewBlocks(preview.text).single().body.lines().size)
}
@Test
fun `the window is as tall as three measured lines, whatever the font scale says`() {
// 窗口高**没有**自己的 dp 常量:它是「量到的一行高度」× SHARE_PREVIEW_TEXT_LINES(理由见
// `ShareConfirmPanel` 里 `sharePreviewWindowHeight` 的 KDoc)。3 这个数只有一处。
assertEquals(3, SHARE_PREVIEW_TEXT_LINES)
assertEquals(60.dp, sharePreviewWindowHeight(20.dp))
assertEquals(20.dp * SHARE_PREVIEW_TEXT_LINES, sharePreviewWindowHeight(20.dp))
// 大字体档:量到的行高更大 ⇒ 窗口跟着更高。这正是「不许写死一个 dp 常量」的算术面——写死的那个
// 数在大字号档上会让这一窗矮到不足三行、末行被切。
assertEquals(120.dp, sharePreviewWindowHeight(40.dp))
assertTrue(sharePreviewWindowHeight(40.dp) > sharePreviewWindowHeight(20.dp))
// 它下一站就是 `heightIn`,所以给出去的东西必须恒有限(那一条的整体不变量见
// `ShareConfirmWindowCapTest`)。
assertTrue(sharePreviewWindowHeight(1.dp).value.isFinite())
assertTrue(shareConfirmWindowCap(sharePreviewWindowHeight(20.dp)).value.isFinite())
}
@Test
@@ -317,3 +359,55 @@ class ShareConfirmTest {
assertTrue(SHARE_PREVIEW_COLLAPSE_LABEL.isNotBlank())
}
}
/**
* W58:正文那一窗的高度上限**永远不许是 [androidx.compose.ui.unit.Dp.Infinity]**。
*
* 背景是真机上**两次**崩溃、同一个异常:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而
* `verticalScroll` 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会
* 直接抛 `IllegalStateException` 把 App 打崩。
*
* 两次踩在不同的地方,都值得钉住:
* 1. **初值**用了 `Dp.Infinity` 当「还没量到」的哨兵值(分享面板一打开就闪退);
* 2. **展开态**只去掉了上限、把 `verticalScroll` 留着 —— 外层预览区自己就是一个 `verticalScroll`,
* 它给内容的是**无界高度**,于是这一层的 scroll 在无界高度下被测量(点「展开」的那一下闪退)。
*
* 结论:这一层的高度上限只经 [shareConfirmWindowCap] 一个出口(永远有限),且展开态**连滚动一起去掉**。
*
* 这两条都只能靠纯函数钉住:本模块的 `testImplementation` 只有 junit,没有 Compose UI 测试依赖,
* 渲染层的崩溃单测抓不到 —— 这也是它两次都只在真机上暴露的原因。
*/
class ShareConfirmWindowCapTest {
@Test
fun `the fallback cap is finite and never infinity`() {
val fallback = shareConfirmWindowCap(measured = null)
assertTrue("首帧的兜底上限必须是有限值", fallback.value.isFinite())
assertEquals(SHARE_PREVIEW_MAX_HEIGHT, fallback)
}
@Test
fun `a measured cap is passed through`() {
assertEquals(42.dp, shareConfirmWindowCap(measured = 42.dp))
}
@Test
fun `no input can produce a non finite cap`() {
// 非有限的那两个取值都在表里:`Dp.Infinity` 是 W58 第一次崩溃的形状(当时它被当「还没量到」
// 的哨兵值),`Dp.Unspecified`(NaN)是「我忘了给它一个数」最常见的写法。两个都必须回落兜底——
// 这一层的出口要自己保证「给出去的上限总是有限」,而不是指望调用方永远不传错。
for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp, Dp.Infinity, Dp.Unspecified)) {
val cap = shareConfirmWindowCap(m)
assertTrue("measured=$m 给出了非有限的上限:$cap", cap.value.isFinite())
assertNotEquals("measured=$m 仍把无界约束放过去了", Dp.Infinity, cap)
}
}
@Test
fun `both preview states keep a finite height bound somewhere in the chain`() {
// 展开态这一层「连滚动一起去掉」了,但**外层预览区**仍是一层有限约束在管事 ——
// 这正是展开后不会掉进同一个「无界高度」崩溃的原因。
assertTrue(sharePreviewMaxHeight(expanded = true).value.isFinite())
assertTrue(sharePreviewMaxHeight(expanded = false).value.isFinite())
}
}
@@ -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),
)
}
}
@@ -22,6 +22,11 @@ import top.yeij.cyrene.domain.share.SharedDocument
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声);
* 4. **「读不到」与「内容是空的」不许叠说**:2026-10-05 实机读到的「有 2 个文件读不到,未发送;
* 分享的内容是空的,没有可发送的内容」会让用户以为出了两个问题,而前者本身就蕴含后者。
*
* 末尾还有**另一句告知**的用例:[shareTextOriginNotice](正文是从 `EXTRA_SUBJECT` / `EXTRA_TITLE`
* 回落来的那一句)。它与 [shareNoticeFor] 是同族的「如实告知」,但**时机不同**:那条走的是认领那一刻
* 的柔和通道(面板正要长出来时),因为「这段字只是对方给的标题」必须在用户点「发送」**之前**说;
* 而它同样有一条不许破的边界——**绝不进正文**。
*/
class ShareNoticeTest {
@@ -121,4 +126,60 @@ class ShareNoticeTest {
shareNoticeFor(SharedContent(failedImages = 2, contentUnreadable = true)),
)
}
// —— 正文是从标题回落来的那一句(分享认领那一刻的如实小字,见 `ShareTextOrigin.kt`)——
@Test
fun `a body that came from the share title says so`() {
// 有些发送方把内容放在 `EXTRA_SUBJECT` / `EXTRA_TITLE` 里(`EXTRA_TEXT` 一个值都没有)。
// 回落来的那一段与真正文长得一模一样:不标一句,用户会以为对方发来的就是这句正文。
listOf(
ShareRules.extract(action = ShareRules.ACTION_SEND, subject = "对方只给了标题"),
ShareRules.extract(action = ShareRules.ACTION_SEND, title = "对方只给了标题"),
).forEach { share ->
assertEquals("对方只给了标题", share.text)
assertEquals(SHARE_TEXT_FROM_TITLE_NOTICE, shareTextOriginNotice(share))
}
}
@Test
fun `the origin hint says both what it is and what is missing`() {
// 只说「它是什么」,用户仍会以为这句话就是对方发来的正文;只说「缺了什么」,他不知道预览里那行
// 字是从哪冒出来的。也刻意不说「失败」「错误」:这次分享一个字节都没丢,只是本来就只有标题。
assertTrue(SHARE_TEXT_FROM_TITLE_NOTICE.contains("分享标题"))
assertTrue(SHARE_TEXT_FROM_TITLE_NOTICE.contains("没有提供正文"))
assertFalse(SHARE_TEXT_FROM_TITLE_NOTICE.contains("失败"))
assertFalse(SHARE_TEXT_FROM_TITLE_NOTICE.contains("错误"))
}
@Test
fun `a body that came from the text says nothing`() {
// 规范路径一切照旧:正文本来就是正文 ⇒ 一个字都不说(与 `shareNoticeFor` 的静默边界同一条纪律)。
assertNull(
shareTextOriginNotice(
ShareRules.extract(action = ShareRules.ACTION_SEND, text = "对方写的正文", subject = "标题"),
),
)
assertNull(shareTextOriginNotice(SharedContent(text = "看这个")))
// 只有图片 / 文件的那一次分享也没有「来源」这件事可说。
assertNull(shareTextOriginNotice(SharedContent(images = listOf("file:///cache/share-1.png"))))
assertNull(
shareTextOriginNotice(
SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt"))),
),
)
}
@Test
fun `the origin hint never leaks into the message we send`() {
// 红线:那句说明只出现在**界面**上,一个字都不许拼进将要发送的正文(把来源标记拼进正文 =
// 让用户正文冒充系统指令)。所以预览里那段字与发出去的那条消息逐字相同。
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, subject = "对方只给了标题")
assertEquals("对方只给了标题", shareConfirmPreview(share).text)
assertEquals("对方只给了标题", shareConfirmBody(share, ""))
assertEquals("对方只给了标题${SHARE_CONFIRM_BODY_SEPARATOR}补一句", shareConfirmBody(share, "补一句"))
assertFalse(shareConfirmBody(share, "补一句").contains("分享标题"))
assertFalse(shareConfirmBody(share, "补一句").contains(SHARE_TEXT_FROM_TITLE_NOTICE))
}
}
@@ -0,0 +1,395 @@
package top.yeij.cyrene.ui.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.domain.chat.DocumentDelivery
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
/**
* 「从别的 App 分享一段**文本**进来」这条路的**端到端纯函数串**(W61 审查补的回归网)。
*
* ## 为什么要一个跨层的串测
* 这条路上的每一站都各自被单测钉过(`ShareExtractionTest` 管 Intent 字段怎么读、
* `ShareConfirmTest` 管面板的预览与拼正文、`ShareNoticeTest` 管告知、`ChatContentParserTest` 管标记),
* 但**没有任何一个用例把这几站接起来跑一遍**——而真机上的那次事故(网易云分享文本 → 闪退)
* 恰恰发生在接缝上:正文在 `ShareRules.extract` 里被 `trim`、在 `shareConfirmPreview` 里再 `trim`
* 一次、在 `shareConfirmBody` 里与用户描述用空行拼接、最后整段落进 `parse` 按标记切气泡。
* 四站各自都对,合起来也未必是用户看到的那句话。
*
* ## 它钉住的三件事(都是**判据**,不是实现)
* 1. **内容一字不丢**:一次「分享单曲 + 链接 + emoji + 多行」的分享,从 Intent 字段一路到气泡正文
* 逐字节相同;用户补的那句描述同样逐字节相同。
* 2. **正文里的标记按既有语义走,且绝不抛异常**:分享进来的那段字里带 `<p>` / `<action>` / 星号时,
* 渲染结果由 `domain/parse` 的既有口径决定(W55 的落单标签剥离、旁白/动作标记、Markdown 强调不吞),
* 这里只钉「不崩、且除标记本身之外一个字不少」。
* 3. **面板预览与气泡走的是同一套解析**(W65 起):预览的正文区渲染的就是 `MessageBubble` 用的那
* 同一个 `parse` 的结果(`sharePreviewBlocks` 只给它加了一条兜底),所以「预览里是一串标签、
* 气泡里只剩正文」这类不一致不会再出现——见下面那几条 W65 的用例。
*
* ## W65 改掉了什么(口径变更,读这份测试时要先知道)
* W61 审查发现:预览把正文 `trim` 之后**原样显示**、不过解析器,而发送后的气泡过解析器。分享一段
* `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩「正文」**——用户按发送之前看到的不是他
* 将要发出的东西(这不是崩溃,是当时如实记下的一处不一致)。
*
* W65 把这条口径改掉了:预览按**与气泡同一套解析**渲染(多段 → 多个块,`<action>` / `<narration>`
* → 灰斜体小字,星号规则同源)。所以本文件原本如实记下的那处不一致(旧用例
* `the preview shows the raw text while the bubble goes through the parser`)**已被推翻**,替换成
* 新口径下的断言:**预览与气泡的正文文本一致**,且标签一个字都不显示。
*
* ⚠️ 本文件**不**覆盖渲染层本身(Compose):本模块没有 Compose UI 测试依赖,`Dp.Infinity` 那一类
* 崩溃单测抓不到(那是 W58 的教训)。这里管的是**进渲染层之前**那份数据对不对、会不会抛异常。
*/
class ShareTextPipelineTest {
/** 网易云那类分享的典型形态:多行、书名号、链接、emoji、全角标点。 */
private val neteaseLike =
"分享单曲《夜曲》 - 周杰伦 🎵\n" +
"https://music.163.com/song?id=185809\n" +
"只想说一句:谢谢昔涟 💛"
/** 一次分享的正文从 Intent 字段走到「该发出去的那段字」(面板那一站的纯函数)。 */
private fun bodyOf(raw: String, caption: String = ""): String =
shareConfirmBody(ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw), caption)
@Test
fun `a text share reaches the bubble byte for byte`() {
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = neteaseLike)
assertEquals(neteaseLike, share.text)
assertTrue(share.worthDelivering)
assertFalse(share.isEmpty)
assertFalse(share.contentUnreadable)
// 一次正常的文本分享没有任何要对用户说的事(有内容、没丢件、没有读不进来)。
assertNull(shareNoticeFor(share))
val preview = shareConfirmPreview(share)
assertEquals(neteaseLike, preview.text)
assertTrue(preview.hasText)
assertFalse(preview.isEmpty)
assertTrue(shareConfirmCanSend(share, ""))
val bubbles = parse(shareConfirmBody(share, ""))
assertEquals(1, bubbles.size)
assertEquals(neteaseLike, bubbles.single().body)
assertTrue(bubbles.single().head.isEmpty())
assertTrue(bubbles.single().tail.isEmpty())
}
@Test
fun `the caption is appended unchanged and both halves stay readable`() {
val caption = "这条你听过吗?"
val body = bodyOf(neteaseLike, caption)
assertEquals(neteaseLike + SHARE_CONFIRM_BODY_SEPARATOR + caption, body)
// 空行是「这是两段」的表达,而既有渲染规则把空行当**气泡边界**(`blankLineFallbackSplitsBubbles`):
// 于是这条消息在界面上是两个气泡,两段各自逐字节不变。这不是分享引入的规则——用户手打两段
// 同样如此;这里把它钉住,是为了让「分享 + 描述」的观感有一处能说清的话。
assertEquals(
listOf(neteaseLike, caption),
parse(body).map { it.body },
)
}
@Test
fun `the caption alone is sent when the share carried no text`() {
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = " ")
assertFalse(share.worthDelivering)
assertTrue(share.isEmpty)
// 正文全空白 ⇒ 面板里没得预览;这时能发的只剩用户补的描述(按钮判据与发送路径同源)。
assertFalse(shareConfirmPreview(share).hasText)
assertFalse(shareConfirmCanSend(share, ""))
assertTrue(shareConfirmCanSend(share, "只看图"))
assertEquals("只看图", shareConfirmBody(share, "只看图"))
}
@Test
fun `a very long shared text is kept whole and goes out as a file`() {
val long = "这是一段被摊平的卡片内容。".repeat(5_000)
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = long)
assertEquals(long, share.text)
assertEquals(1, parse(shareConfirmBody(share, "")).size)
// 「分享进来的长正文落成 .txt」是既有定案(DocumentDelivery.planSharedText);本端一个字都不改它,
// 而这条判据决定了正文那一栏会不会被清空——两个入口共用同一份判据是刻意的。
assertEquals(SharedTextPlan.AsDocument, DocumentDelivery.planSharedText(share.text, 0))
}
@Test
fun `markup in the shared text is handled by the parser and never crashes`() {
// 分享进来的字里带**真标记**时的落点:剥离/解析口径是 `domain/parse` 的既有契约(W55 起),
// 这里只断言「不抛异常 + 结果与那条契约一致」,不发明新口径。
val malformed = parse("<p><p>正文</p>").map { it.body }
assertEquals(listOf("正文"), malformed)
val loose = parse("<action>x</action>")
assertEquals(1, loose.size)
assertEquals("", loose.single().body)
assertEquals(1, loose.single().head.size)
}
/**
* 预览里**看得见的字**:按渲染顺序(每块:上依附的旁白 → 正文 → 下依附的旁白),空白的不算。
*
* 这个顺序就是 `ShareConfirmPanel` 里 `ShareConfirmBlock` 的排法——用它当「用户看到的是什么」,
* 好让下面几条断言能直接钉住**可见文本**,而不只是内部结构。
*/
private fun visibleLines(blocks: List<ParsedBubble>): List<String> = buildList {
blocks.forEach { block ->
addAll(block.head.map { it.text })
add(block.body)
addAll(block.tail.map { it.text })
}
}.filter { it.isNotBlank() }
@Test
fun `the preview and the bubble show the very same text`() {
// W65 口径变更(先读类上的「W65 改掉了什么」):预览的正文区**走的就是气泡那一个 `parse`**,
// 所以两边看得见的正文与旁白必须一字不差。
//
// ⚠️ 但 `preview.text` 本身仍然是**原样那一段字**(只 `trim`)——解析发生在**渲染**这一层,没有
// 搬进 `shareConfirmPreview`。这一条也一起钉住:数据层不许替用户改字。
listOf(
"<p><p>正文</p>",
"<p>第一段</p><p>第二段</p><p>第三段</p>",
"<action>看着窗外</action>",
"<p>说话</p><action>顿了顿</action>",
"第一段\n\n第二段",
"1 < 2 和 a<b",
neteaseLike,
).forEach { raw ->
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val preview = shareConfirmPreview(share)
val sent = shareConfirmBody(share, "")
assertEquals("预览的原始正文被改字了:$raw", raw, preview.text)
// 气泡那一侧:`MessageBubble` 用的同一个 `parse`(非空结果上那一条 `filterNot { isEmpty }`
// 不删任何东西,这里照抄它当基准——预览那一侧多出来的只是同一条兜底)。
val bubbles = parse(sent).filterNot { it.isEmpty }
.ifEmpty { listOf(ParsedBubble(sent, emptyList(), emptyList(), emptyList())) }
val rendered = sharePreviewBlocks(preview.text)
assertEquals("预览与气泡的正文不一致:$raw", bubbles.map { it.body }, rendered.map { it.body })
assertEquals(
"预览与气泡的旁白不一致:$raw",
bubbles.map { b -> (b.head + b.tail).map { it.text } },
rendered.map { b -> (b.head + b.tail).map { it.text } },
)
// 标记一个字符都不许出现在**看得见的地方**:预览此前把 `<p>` / `</p>` 原样摆给用户看,
// 那正是这一批要修的东西。
visibleLines(rendered).forEach { line ->
assertFalse("标记漏进了可见文本:$line", line.contains("<p") || line.contains("</p"))
assertFalse("标记漏进了可见文本:$line", line.contains("<action") || line.contains("<narration"))
}
}
}
@Test
fun `a share full of stray paragraph tags shows the same text in the preview as in the bubble`() {
// W61 审查发现的那处不一致的**原样输入**:多一个 `<p>`、只有一个 `</p>`(服务端确实会这样产出)。
// 旧口径:预览是 `"<p><p>正文</p>"` 这一串字符;气泡里只剩「正文」。
// 新口径(W65):预览也走 `parse`,两边都是「正文」,一个标签字符都不显示。
val raw = "<p><p>正文</p>"
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val rendered = sharePreviewBlocks(shareConfirmPreview(share).text)
assertEquals(listOf("正文"), visibleLines(rendered))
assertEquals("正文", rendered.single().body)
// 气泡那一侧(同一条消息发出去之后渲染的那一份)逐字相同。
assertEquals(parse(shareConfirmBody(share, "")).single().body, rendered.single().body)
}
@Test
fun `each paragraph of the shared text becomes its own block in the preview`() {
// 多段 ⇒ 多个块:旧口径下 `<p>甲</p><p>乙</p>` 在预览里是**一行还带着标签的原样文本**,现在与
// 气泡里一样是两块。切段不是面板新写的规则,是 `parse` 的既有语义。
val three = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = "<p>第一段</p><p>第二段</p><p>第三段</p>",
)
assertEquals(listOf("第一段", "第二段", "第三段"), sharePreviewBlocks(three.text).map { it.body })
// 无标签的正文按**空行**分段(同上):两段就是两块,而不是一段里的一行。
val plain = ShareRules.extract(action = ShareRules.ACTION_SEND, text = "第一段\n\n第二段")
assertEquals(listOf("第一段", "第二段"), sharePreviewBlocks(plain.text).map { it.body })
// 段数不是从正文里数出来的:一行里写三个完整块也是三块(按行数只会数出 1)。
assertEquals(3, sharePreviewBlocks("<p>甲</p><p>乙</p><p>丙</p>").size)
assertEquals(2, sharePreviewBlocks("甲\n\n乙").size)
// 单个换行是**句内换行**,绝不切分(既有语义):一块里两行。
assertEquals(1, sharePreviewBlocks("甲\n乙").size)
}
@Test
fun `narration in the shared text is an annotation block, not body text`() {
// `<action>` / `<narration>` 在预览里与气泡里一样**不进正文**:那一块没有正文,这一段落在 head
// 上,由 `ShareConfirmAnnotation` 渲染成灰斜体小字。标签本身一个字符都不显示。
val action = sharePreviewBlocks("<action>看着窗外</action>").single()
assertEquals("", action.body)
assertEquals(listOf("看着窗外"), action.head.map { it.text })
assertTrue(action.head.single() is ContentPart.Action)
assertTrue(action.tail.isEmpty())
val narration = sharePreviewBlocks("<narration>旁白一句</narration>").single()
assertEquals("", narration.body)
assertTrue(narration.head.single() is ContentPart.Narration)
// 星号规则同源(`parse` 的既有规则):全角短句 ⇒ 旁白那一行;半角无关键词 ⇒ 保持 Markdown
// 强调文本,仍然留在**正文**里。
val star = sharePreviewBlocks("*今天也要加油*").single()
assertEquals("", star.body)
assertEquals(listOf("今天也要加油"), star.head.map { it.text })
assertEquals("*随便*", sharePreviewBlocks("*随便*").single().body)
// 「看得见的那几行」也钉住:旁白排在正文**之外**,按渲染顺序在正文之前(与气泡里 `head` 的位置
// 一样)。这一条是「形如气泡」这件事在纯函数层唯一能自证的地方。
assertEquals(
listOf("顿了顿", "说话"),
visibleLines(sharePreviewBlocks("<action>顿了顿</action><p>说话</p>")),
)
}
@Test
fun `a shared text the parser drops entirely is still shown verbatim`() {
// 解析结果为空时**必须有兜底**(原样显示,不是一块空白):正文那一栏是用户在按发送之前唯一能
// 核对的东西。兜底与气泡那一侧同一条(`MessageBubble`:`parsed.ifEmpty { plainBubble(content) }`)
// ——都显示**原样那一段字**,哪怕它本来就是几个没配对的标签。
val raw = "<p><p>"
assertTrue("这一档必须真的解析为空,否则下面几条断言什么也证明不了", parse(raw).isEmpty())
val block = sharePreviewBlocks(raw).single()
assertEquals(raw, block.body)
assertTrue(block.head.isEmpty())
assertTrue(block.tail.isEmpty())
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(raw)))
// 走一遍真分享的路:`preview.text` 就是 `trim` 过的原串,于是解析为空、由这一条兜底把它显示出来。
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
val preview = shareConfirmPreview(share)
assertEquals(raw, preview.text)
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(preview.text)))
// 兜底不是给空正准备的:这一档的正文本身非空白(否则面板根本不显示正文那一栏)。
assertTrue(preview.hasText)
}
@Test
fun `stars in the shared text follow the existing markup rule`() {
// 全角短句星号 ⇒ 动作(既有语义);半角带关键词 ⇒ 动作;半角无关键词 ⇒ 保持 Markdown 强调文本。
val action = parse("*今天也要加油*")
assertEquals("", action.single().body)
assertEquals("今天也要加油", action.single().head.single().text)
val keyed = parse("*动作: 挥手*")
assertEquals("", keyed.single().body)
assertEquals("挥手", keyed.single().head.single().text)
assertEquals("*随便*", parse("*随便*").single().body)
}
@Test
fun `plain angle brackets in the shared text are never eaten`() {
listOf("1 < 2 和 a<b", "看电影《心灵奇旅》 <3", "<p2>不是标签</p2>", "a < b > c").forEach { raw ->
assertEquals(raw, bodyOf(raw))
assertEquals(raw, parse(bodyOf(raw)).single().body)
}
}
@Test
fun `empty whitespace and hostile inputs never throw anywhere on the way`() {
val inputs = listOf(
"",
" ",
"\n",
"\t\n ",
"<",
">",
"<p",
"<p>",
"</p>",
"<p><p>",
"<p>正文</p>",
"<action>",
"</action>",
"*",
"*",
"\u0000",
"\u0000<p>",
"🎵",
"\uD83C", // 半个代理对:畸形但可能出现在任何一段别人给的文本里
"<p>".repeat(200),
"<a".repeat(500),
"あ".repeat(100_000),
)
inputs.forEach { raw ->
val outcome = runCatching {
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
shareConfirmPreview(share)
shareConfirmCanSend(share, "描述")
shareNoticeFor(share)
parse(shareConfirmBody(share, "描述"))
}
assertTrue(
"输入「${raw.take(24)}…」抛了:${outcome.exceptionOrNull()}",
outcome.isSuccess,
)
}
}
@Test
fun `a share that carries nothing at all stays silent`() {
// 三个字段都没有值 ⇒ 什么都不该发生、也不该说话(否则每次误触分享都弹一句)。
val nothing = ShareRules.extract(action = ShareRules.ACTION_SEND)
assertFalse(nothing.worthDelivering)
assertNull(shareNoticeFor(nothing))
assertFalse(shareConfirmCanSend(nothing, ""))
}
@Test
fun `a stream we cannot read still speaks instead of vanishing`() {
// 同一个 Intent 里带着内容迹象却一条都读不进来(形态认不出 / scheme 不支持):
// 内容照旧为「空」,但**必须**有话可说——这是 `contentUnreadable` 存在的全部理由。
val unreadable = ShareRules.extract(
action = ShareRules.ACTION_SEND,
streamStrings = listOf("file:///etc/hosts", "https://example.com/a.png"),
)
assertTrue(unreadable.isEmpty)
assertTrue(unreadable.worthDelivering)
assertEquals(SHARE_UNREADABLE_NOTICE, shareNoticeFor(unreadable))
assertFalse(shareConfirmCanSend(unreadable, ""))
}
@Test
fun `a shared document reaches the panel and keeps the send button usable`() {
val share = SharedContent(
text = "看这份",
documents = listOf(
top.yeij.cyrene.domain.share.SharedDocument(
uri = "file:///cache/share-intake/share-1-0.md",
displayName = "笔记.md",
sizeBytes = 2048,
),
),
)
val preview = shareConfirmPreview(share)
// 预览里带的就是这份文件本身(显示名由芯片那一栏换算,算术在 DocumentChipTest 里各测各的)。
assertEquals(listOf("笔记.md"), preview.documents.map { it.displayName })
assertTrue(preview.hasText)
assertTrue(shareConfirmCanSend(share, ""))
// 只有附件、没有正文时按钮照旧可点(服务端支持纯附件回合),且告知只说丢件、不抱怨没正文。
assertNull(shareNoticeFor(share))
}
}
@@ -13,7 +13,8 @@ import org.junit.Test
* 或整轮只有一张卡;多张卡无法区分时**不落位**,绝不默认 `index == 0`。
*
* [toolExecutionTargetIndex] 是规则本体;[applyToolExecution] 是 VM 实际消费它的纯函数,
* 这里一并验证 phase / 工具名 / delta 的落法。
* 这里一并验证 phase / 工具名 / delta / 结果摘要的落法,以及**结果区判据**
* [toolResultTextOf] 在「缺席 / 空串 / 有值」三档下的输出。
*/
class ToolExecutionCorrelationTest {
@@ -23,6 +24,7 @@ class ToolExecutionCorrelationTest {
tool: String = "",
arguments: String = "",
output: String = "",
resultSummary: String? = null,
done: Boolean = false,
phase: ToolPhase? = null,
) = ToolCallState(
@@ -31,6 +33,7 @@ class ToolExecutionCorrelationTest {
tool = tool,
arguments = arguments,
output = output,
resultSummary = resultSummary,
done = done,
phase = phase,
)
@@ -166,6 +169,84 @@ class ToolExecutionCorrelationTest {
assertEquals(tools, applyToolExecution(tools, null, "bash", "done", "x"))
}
// —— 结果区判据:缺席 / 空串 / 有值 ——
@Test
fun `with neither a summary nor an output nothing is drawn`() {
// 这就是今天真机上的样子(服务端还没补 resultSummary,且 update 帧没有生产者):
// 结果区**一个像素都不画**,不是画一个空区块,也不是画「没有输出」。
assertNull(toolResultTextOf(tool("call_1", tool = "bash", phase = ToolPhase.Done)))
}
@Test
fun `an empty or blank summary counts as absent`() {
// 服务端承诺不会用空串顶位;真顶了也绝不能让卡片渲染出一个空的「结果」区块。
assertNull(toolResultTextOf(tool("call_1", resultSummary = "")))
assertNull(toolResultTextOf(tool("call_1", resultSummary = " ")))
}
@Test
fun `a present summary is exactly what the card shows`() {
assertEquals("12 行输出", toolResultTextOf(tool("call_1", resultSummary = "12 行输出")))
}
@Test
fun `a summary takes precedence over the accumulated partial output`() {
val card = tool("call_1", output = "局部\n", resultSummary = "摘要")
assertEquals("摘要", toolResultTextOf(card))
}
@Test
fun `without a summary the partial output still drives the result area`() {
// 老服务端 / 老路径:结果区唯一的来源仍是 update 帧累积的 output,与改动前同一口径。
assertEquals("局部\n", toolResultTextOf(tool("call_1", output = "局部\n")))
}
@Test
fun `a long summary is shown whole and never truncated a second time`() {
// 服务端已按 ≤500 字符的预算截断并脱敏;本端只展示,不许再截。
val fiveHundred = "输".repeat(500)
assertEquals(fiveHundred, toolResultTextOf(tool("call_1", resultSummary = fiveHundred)))
}
@Test
fun `the end frame lands the summary on the card`() {
val tools = listOf(tool("call_1", tool = "bash"))
val after = applyToolExecution(tools, "call_1", "bash", "done", resultSummary = "12 行")
assertEquals("12 行", after.single().resultSummary)
assertEquals(ToolPhase.Done, after.single().phase)
}
@Test
fun `an absent or blank summary never wipes a previous one`() {
// 三帧里只有 `.end` 会带这个键;一次没带(或空串顶位)不该把上一次拿到的结果抹掉。
val tools = listOf(tool("call_1", tool = "bash", resultSummary = "12 行"))
assertEquals("12 行", applyToolExecution(tools, "call_1", "bash", "done").single().resultSummary)
assertEquals(
"12 行",
applyToolExecution(tools, "call_1", "bash", "done", resultSummary = "").single().resultSummary,
)
assertEquals(
"12 行",
applyToolExecution(tools, "call_1", "bash", "done", resultSummary = " ").single().resultSummary,
)
}
@Test
fun `an unattributable end frame leaves every card without a summary`() {
val tools = listOf(tool("call_1"), tool("call_2"))
val after = applyToolExecution(tools, "call_9", "bash", "done", resultSummary = "12 行")
assertNull(after[0].resultSummary)
assertNull(after[1].resultSummary)
}
// —— phase 字符串映射 ——
@Test
@@ -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)
}
}
@@ -0,0 +1,45 @@
package top.yeij.cyrene.ui.settings
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「免打扰」开关下面那行字(纯函数)。
*
* 这一行的全部价值在一件事上:**说清它不是静音**。「免打扰」四个字在系统里到处都是,用户对它的
* 第一印象常是「什么都不响」,而这个开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住
* 我正在做的事」——两句话指向的期待差得很远,一句不说就是让用户自己猜(猜错的代价是他以为通知
* 坏了,或者以为自己买到了完全静音)。
*
* 两档都必须说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,只写「开着 = 安静」
* 等于让他自己去比较两种安静。
*/
class ProactiveQuietSummaryTest {
@Test
fun `the on state says it is not silence`() {
val text = proactiveQuietSummary(enabled = true)
assertTrue("要说清它还在通知栏里:$text", text.contains("通知栏"))
assertTrue("要说清关掉的只是横幅:$text", text.contains("横幅"))
assertTrue("必须明说提示音还在(否则等于说自己在静音):$text", text.contains("提示音"))
}
@Test
fun `the off state names the banner as what gets silenced`() {
val text = proactiveQuietSummary(enabled = false)
assertTrue("要说清默认这一档是弹横幅的:$text", text.contains("默认"))
assertTrue("要说清默认会弹横幅:$text", text.contains("横幅"))
}
@Test
fun `the two states are two different sentences`() {
assertNotEquals(
"两档必须各说各的,任何一次改文案都不该把某一档悄悄吞掉",
proactiveQuietSummary(enabled = false),
proactiveQuietSummary(enabled = true),
)
}
}
-90
View File
@@ -1,90 +0,0 @@
# 01 — 功能拆解与分步路线(Cyrene-For-Android 讨论稿 v1)
> 配套 `DESIGN.md`(总览)。本文按**分步**展开:每模块给出「做什么 / 交互 / 数据流 / 依赖 / 验收 / 边界 / Miuix 组件映射」。状态:**讨论稿**,与主人在本会话逐模块细化后定稿。
> 前提(已定案):主界面=常规 AI APP;长按电源键=系统默认语音助手(VOICE_INTERACTION_SERVICE,全屏,无悬浮球);ASR/TTS 后端化;先纯文本版;UI 严格 Miuix(compose-miuix-ui)。
---
## 阶段 0 · 工程骨架
**S0.1 工程搭建**
- Gradle(版本目录)、Kotlin + Compose、`compose-miuix-ui` 依赖、`BuildConfig.BASE_URL`(debug `http://<host>:8080`,release 换 HTTPS)、单 Module `app`、包 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`。
- 依赖:Retrofit+OkHttp、kotlinx-serialization、okhttp-WebSocket、EncryptedSharedPreferences、Hilt(可选)、Coil(图片)。
- 验收:`./gradlew :app:assembleDebug` 通过,App 能起空壳 Miuix 首页。
- 边界:暂不接后端。
## 阶段 1 · P0 纯文本聊天(零后端改动,契约现成)
### S1.1 登录(已定稿 2026-09-08)
- **服务器地址可配**(开源,各人部署地址不同):登录卡放「服务器地址」折叠项/入口,默认 `http://<host>:8080`,用户可改;存本地(EncryptedPrefs/DataStore)。
- **仅 `.env` 单管理员**(`ADMIN_USERNAME/PASSWORD`)可登录,无注册/找回;成功 `role=owner`。
- **自动登录**:默认记住 token(EncryptedPrefs),重启免登;仅 `401/auth_expired` 才清 token 回登录。
- **报错**:**已建立连接**(服务端有响应)时用服务端 `{error.message}`;**本地错误**(连不上/证书/DNS/超时)**由客户端本地文案负责**——不把本机错误误当服务端文案。
- **密码默认隐藏**(可切换明文)。
- **数据流**:`AuthManager.login(baseUrl,username,password)` → `POST {base}/api/v1/auth/login` → `{token,expiresAt,role}` → `EncryptedPrefs` 存(baseUrl+token+role+expiresAt)→ `HomeScreen`。
- **状态机**:`Idle→Loading→Success(Home)|Error(msg)`(`error.from==="server"` 用后端 `message`,`"local"` 用本地文案);登录中禁用按钮防连点;登录失败不清已存 token。
### S1.2 会话列表(已定稿 2026-09-08)
- **主结构 = A**:启动**直接进主会话「与昔涟」(`web`) 聊天**;**顶栏汉堡 → 侧边栏**含【会话列表(切主/次级)+ IoT + App 设置 + 登出】。
- **列表显示**:主会话="与昔涟",次级="会话 2/N"(按序号,title **自动**,零后端改动);无预览/时间(后端 `/sessions` 只有 id)。
- **新建**:侧栏"+"新建次级(`POST /sessions` → `sessionId` → 切进去)。
- **删除**:次级长按/滑出 → 确认 `Dialog` → `DELETE /sessions/{id}`;主会话禁删(提示"主会话不可删")。
- **切换**:侧栏会话列表点选 → 切聊天页。
- **数据流**:`GET /api/v1/sessions`(主会话 `web` 恒首位)。**依赖**:S1.1。**验收**:启动即主会话聊天;侧栏可切/建/删次级;主会话不可删;重启记住上次会话(可后续)。
### S1.3 聊天页:历史加载(已定稿 2026-09-08)
- **数据流**:`GET /sessions/{id}/messages?limit=50&before=<ms>&platform=` **倒序**返回,客户端**逆序渲染**(最新在底)。**`platform` 不传 = 看全量主会话**(a,能同步 web/kook/微信所有主会话消息)。
- **分页**:初始 50 条;**上拉**加载更多用 `before=<最早时间>` 再往前翻(**50 步进**),追加顶部。
- **消息来源标识**:每条消息**默认显示来源**(`platform`/`deviceId` 小字角标,如"来自 web" / "来自 kook" / "来自微信" / "来自安卓(voiceassist)")——主人既定诉求。
- **时间**:**完全隐藏**(不显示时间戳/分组,对话沉浸)。
- **空态**:主会话无消息 → 昔涟欢迎语。
- **依赖**:S1.2。**验收**:进会话拉 50 条倒序展示;上拉翻 50/页;每条标来源;无时间戳。
### S1.4 聊天页:发消息 + 流式(已定稿 2026-09-08)
- **发消息**:WS `/ws/chat` 上行 `chat {sessionId,text,deviceId}`(**stream 流式**);本地乐观追加自己消息。
- **流式渲染**:下行 `message.start` 开气泡 → `message.delta` **逐段追加**(气泡尾+光标)→ `message.end` 收口。
- **流式期间新消息 = 两种模式(用户可切,默认「追加队列」)**:
① **追加队列**(新消息进队列,流式结束后依次处理——豆包式);
② **打断**(新消息触发 `interrupt`,保留残缺上下文 + 显示"打断"提示)。
- **思维链 `thinking` / 工具卡 `tool_call`**:**默认展开**(方便观察思维;可折叠 + "总是收起"开关)。
- **旁白 `<action>/<narration>`**:灰斜体小字,**按出现位置依附气泡上/下**。
- **`mood`**:显示情绪标签(可关)。
- **渲染**:`<p>` 拆气泡;图片;忙态 `session_busy/pending_*` 提示;`409/202` 兜底。
### S1.5 侧边栏(汉堡抽屉)
- **做什么**:顶栏左上汉堡 → `Drawer` 侧边栏:当前会话/聊天、**[预留] IoT 入口**、**[预留] App 设置入口**、登出。
- **数据流**:登出 → 清 token + 断开 WS → 回登录。
- **验收**:汉堡展开侧栏;IoT/设置点开显示"规划中"(Miuix 空态);登出回登录页。
- **边界**:侧栏入口预留(P0 占位不真做)。
### S1.6 401 / token 过期
- **做什么**:REST 401 → `auth_expired` 清 token 停 WS → 回登录;WS 握手失败同理。
- **验收**:JWT 过期或轮换后,下条请求自动登出重登,不无限 reconnect。
## 阶段 2 · P1 语音助手(后端 ASR/TTS,依赖本会话 TTS/ASR 进程接通后端)
### S2.1 系统默认语音助手
- **做什么**:Manifest 声明 `VOICE_INTERACTION_SERVICE`/`AssistantService`(`android:role="Assist"`),用户在系统「语音助手/电源键」设本 App 默认 → 长按电源键由系统拉起本 App `AssistantActivity`(全屏)。
- **依赖**:系统设置入口(MIUI 自家更顺);Android 版本/厂商适配。
- **验收**:长按电源键 → 本 App 全屏助手打开(无悬浮球)。
### S2.2 全屏助手 UI(AssistantActivity)
- **做什么**:聆听动画 → 已识别文本(ASR 结果)→ 昔涟回复(流式)→ 重听/TTS;按钮:结束/放弃/再次说话。全程单页全屏,Miuix 玻璃质感。
- **验收**:说话 → 文本上屏 → 昔涟回复 + 朗读。
### S2.3 语音闭环(后端 ASR/TTS)
- **链路**:录音(`MediaRecorder`/`AudioRecord`)→ **REST 上传到后端**(音频→ASR)→ 文本 → `POST /api/v1/chat {text,deviceId}`(或 WS)→ 昔涟回复 → **后端 TTS** 返回音频 → 本端播放。`voice_output`(WS)也可拿音频。
- **依赖**:**后端 ASR/TTS 真接通**(本会话 TTS/ASR 进程:真实 STT/TTS provider + REST 语音端点绕 4KB + `voice_input.deviceId`)。
- **验收**:对话 3 轮语音一问一答;IM 渠道(微信/KOOK)语音也走同一后端 ASR/TTS。
## 阶段 3 · P2 智能
- **IoT**:设备/场景列表(Miuix;预留接口 `GET /api/v1/iot/*`)。
- **App 设置**:主题(Miuix 深/浅)、通知开关、语音偏好(ASR/TTS 后端已定)、关于。
- **主动推送**:后端 proactive/backthink → 系统通知;前台服务保活(可选)。
- **多角色**:`GET /personas` 只读展示;切换人格。
- **记忆/子代理/自主思考**:只读面板(`memory`/`subagent`/`backthink` 过滤器)。
---
## 跨模块约定
- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5),所有 `chat`/`voice_input` 携带 → 落库标来源、跨端可辨。
- **主会话同步**:Android 加入 `web` 主会话,与 Web/IM 共享上下文;渲染按 `platform` 区分"本端/他端"(design/16 §4.4)。
- **Miuix 映射**:`TopAppBar`(汉堡/标题)、`Scaffold`+`Drawer`(侧栏)、气泡(自定义 `BubbleView` 或 Miuix 卡片+毛玻璃)、`TextField`、`Button`(发送/语音)、`Snackbar`/`Dialog`(确认/错误)。**禁原生 Material**。
- **安全红线**:token 加密存储、401 即登出、权限最小化、无悬浮球、不打日志敏感。
File diff suppressed because it is too large. Load diff
-754
View File
@@ -1,754 +0,0 @@
# 02 — 前后端协同事项(Cyrene-For-Android ⇄ Cyrene v2)
> **用途**:Android 端「要做/想做」,但**后端当前不支持、或契约未明确**的事项统一登记在这里。主人(AskaEth)按此去后端做适配与开发。
> **状态机**:`待确认` → `已确认` → `已排期` → `已完成` / `不采纳`
> **依据**:`Cyrene_v2/Docs/api-reference.md`、`Docs/api-client.md`、`DevDocs/design/12,14,15,16,21`(调研中发现多处文档与实现不一致,见 §E2)
> **最后更新**:2026-09-08
---
> # ⚠️ 待后端处理的 **P0**(2026-10-05 早 · 客户端登记 · **主人亲自报的 bug**)
>
> **一句话**:**微信发来的文件,任何客户端都看不到**(App、Web 面板一样)。
>
> **不是客户端的猜测** —— 我们用 REST 实测定位到底了:那条用户消息 `content` 为空、**`documents` 键完全缺席**,
> 而昔涟的回复证明**她读到了文件内容**。字节进了摄取链路、也抽取了,**但引用没挂到消息上** ⇒
> 客户端**没有任何东西可渲染**,用户看到的是一条**空白消息**。
>
> **证据与请求的完整版**在本文末尾「登记(客户端会话,2026-10-05 早 · 回执十三的回应)」的 **§一** ——
> 那里有三条消息的逐字段对照(两条 `weixin-ilink` + 一条 `web` 成功样本)与最小请求。
>
> **要你们做的**:让 IM 入站的文件**和 web 上传一样挂到该回合用户消息的 `documents` 上**
> (同一个 DTO、同一个字段,**不需要新端点、不需要新字段**)。**客户端零改动** —— 你们补上引用,
> 附件芯片与预览立刻就有。
>
> ⚠️ 请顺带确认一个分叉:那些字节**到底落盘了吗**?还是只内联进了当回合的上下文?
> 两种情况下我们要的答案一样(消息上要有 `documents` 引用),但**修法不同**,别猜。
>
> —— 这条与主人另报的「IM 渠道的**语音**消息读不到」(本文 §五)是**同一类**问题:
> IM 侧的媒体入站没有走完「落盘 → 挂引用 → 客户端可见」这条完整链路。
---
> **【归档说明 · 2026-10-05】**
> 1. 2026-10-04 ~ 10-05 的早期往来(历史回执与同时段已闭环的登记)已整体移到同目录 **`02-archive-历史回执.md`**,按时间顺序原样保留、只读追溯。
> 2. **新回执请照旧写在本文末尾** —— 写法、位置都不用变,我们逐条照原样追加。
> 3. 已闭环的老登记我们会**定期**再挪进归档文件;要查原始措辞与证据,以归档里的条目为准。
> 4. 本文旧句里指向「已归档小节」的指针(例如顶部置顶块里的「本文 §五」),请到归档文件里找对应小节。
## 登记规范(开发过程中持续维护)
**触发条件**——开发 / 联调中遇到以下任一情况,**当场**追加到本文并立即向主人反馈,不攒到最后:
1. 调用后端**直接失败**(501 / 404 / 500 / 字段缺失导致功能做不了);
2. **契约或设计文档写了、但实现没做**(例如 `mode` 不落库、`voice_input` 不落库)——即「理论上应该支持但没有」;
3. 后端行为与文档不符,导致客户端必须绕行。
**每条要写清**:
- **现象**:可复现的请求 / 响应或代码位置(`文件:行`);
- **客户端为什么要**:不补会怎样;
- **后端建议方案**:客户端期望的接口 / 字段 / 语义;
- **当前绕行**:客户端现在怎么扛过去(没有就写「无,功能阻塞」);
- **优先级**:阻塞 P0 / 影响体验 / P1 / P2 / 文档治理。
**流程**:`待确认` → 主人确认 → `已排期` → 后端完成 → `已完成`(注明后端版本 / commit);不做的标 `不采纳` 并写明理由。
**反馈节奏**:每轮开发结束时,在对话里列「本轮新增协同事项」(编号 + 一句话),主人直接照着修后端。
---
## F. 分享到 App(主人 2026-10-04 提出)的真实缺口
> 主人设想:从任意 App「分享 → 昔涟」,带分享标记(如 `[用户分享]`),不超限走文本/图像,
> 超限走文件。侦察结论:**入口易做,但「超限走文件」今天做不到,且提议的标记格式违反项目红线。**
### F1. 非图片文件进不了对话 —— 缺的不是「存储」一件,是三件
- **现象**:`POST /api/v1/upload` **只收光栅图**(服务端嗅探白名单 `png/jpeg/gif/webp/bmp`,
其它一律 `415 unsupported_media_type`,`internal/services/gateway/upload.go:85-90,205-220`)。
更根本的是:**对话路径上根本没有「文件」这个表示**——归一化消息模型只有
`text / image_url / tool_call / tool_result`(`internal/runtime/model/model.go:35-44`),
`chatRequest` 也只有 `sessionId/text/image/deviceId/mode`(`rest.go:111-118`、
`ws_hub_turn.go:140-151`)。所以 PDF/txt/epub 连「被传进来」都表示不了。
- **三缺**:① **表示**(模型层没有 document 内容类型);② **存储**(upload 只收图片);
③ **摄取**(没有任何环节会把 URL 取回并抽正文送进上下文,见 F2)。
- **旁证**:IM 适配器层其实**已经有** `adapter.ContentFile` / `MediaFile`
(`internal/channel/adapter/message.go:49-50,76-96`,Telegram/KOOK/微信都填充),
但 channelbridge **从不读 `msg.Media`**——`processFrom` 只取 `msg.Content`,
且**丢弃任何没有 chat 文本的消息**(`channelbridge/inbound.go:136-141`)。
全仓 grep `.Media` 在 adapter 包之外**零命中**。所以 IM 侧也没有先例可抄。
- **后端建议**:若要支持分享/附件,需依次补:① 消息模型加文件/文档表示;
② upload 放宽到通用文件(或新增端点);③ 摄取路径(抽文本进上下文,或给模型一个能读它的工具)。
三件缺一件都不成立。
- **客户端影响**:**分享「长文/文件」这条需求今天无法实现**,不是绕行问题,是能力缺失。
- **优先级**:P2(主人提出的新需求,属路线级)
### F2. 上传成功**不等于**模型能读到内容(这条最容易被误解)
- **现象**:把文件传上去、只在消息里放它的 URL,**没有任何环节会 fetch 并摄取它**。
`resolveImages` → `MessageResolver.Resolve`(`loop/vision.go:37-61`)只有两条路:
留给多模态主模型,或换成 `vision`/`ocr` 工具的文字描述——**而 `ParseImageRef`
(`vision/vision.go:57-79`)没有「取回 URL 并抽正文」这个分支**。
两个 vision 适配器都只处理图片,且 `adapter.imageURL` 是**把 http(s) URL 原样转给上游 provider**
而不是自己下载(`vision/adapter.go:166-171`)。
- **相邻候选全部为负**:`web-fetch` 拒绝非文本响应(`web_fetch.go:83-90`);
`http` 工具有 SSRF 守卫且未配 allowlist(`ssrf.go:21-55`、`wire_runtime.go:140`),
连自己家的 `/api/v1/files/...` 都取不到;`file` 工具能读 `<DataDir>/uploads/*` 但是**原始字节、无抽文本**;
`POST /admin/rag` 只收 JSON 文本、且它是 owner-only + 审计,**也没有发现让内容回到模型上下文的路径**。
- **唯一可行绕行**:**客户端先抽正文,再当文本发**。
- **优先级**:P2(与 F1 同批)
### F6. 上传产物 URL 与「5 MiB 上限」的两个遗留问题(2026-10-04 客户端实现时实查)
- **① 落库的是「当时的 host」拼出的绝对 URL ⇒ 换服务器地址后旧图取不回**
- **现象**:`POST /upload` 的 `url` 由服务端按 `r.Host` 拼成绝对地址(`upload.go:330-371`),
而消息落库时把该 `image` 字符串**原样存储** ⇒ 历史里的图永远指着「客户端当时用的那个地址」。
- **客户端为什么要**:本部署**会换地址**(局域网 IP ↔ 隧道域名)。一旦换,
**旧消息里的图全部取不回**——`GET /files/{name}` 是不鉴权的能力地址,但也**不可重定向**。
- **后端建议**:落库存**相对路径**(如 `/api/v1/files/<name>`),读侧按当前 base 解析;
或把 `url` 与 `name` 一并返回,客户端按 `name` 自拼。**客户端不做基址重写的前提是这条能落地。**
- **优先级**:中(不致命,但属「随部署变动静默损坏历史」那一类)
- **② `chat.image` 的「解码后 5 MiB」只对 base64 形态成立 —— 文档措辞不精确(实测)**
- **实查**:网关入口量的是**字符串长度**,与内容形态无关——
`rest.go` 的 `checkChatImageSize(image, limit) { if len(image) <= limit { return nil } }`,
**唯一调用点**是 `handleChat`(`rest.go:272`)。一个 `/api/v1/files/<32hex>.jpg` 的 URL 约 **60 字符**,
而 `limit` 是 **6990572** 字符 ⇒ **必然通过**。
- **URL 形态真正受的约束在视觉层**:`multimodal/vision/image.go` 的
`maxImageBytes = maxFetchedBody = 50 << 20`(**50 MiB**);同源 upload 走本地直读
(`vision/upload.go`),同一个上限。
- ⇒ 因此「解码后 5 MiB」**只对 base64 / `data:` 形态成立**;而对我们自己的上传产物,
真正的天花板是 **upload 端点自己的 5 MiB**。
- **后端建议**:`api-client.md` 那处按**形态**写清(base64 上限 5 MiB / URL 上限 50 MiB)。
- **优先级**:低(文档治理;但它直接决定客户端「内联 vs 上传」的阈值设计是否站得住)
---
## G. REST `/chat` 与流式(2026-10-04 侦察发现)
### G5. `pending_full`(WS 队列满)不带 `clientMsgId` → 客户端只能瞎猜该丢哪条气泡
- **现象**:`pending_full{max}` 属 7 个状态机信号之一(发给所有已认证连接),但它**不带
`clientMsgId`**,客户端因此**无法判定该收掉哪一条本地乐观气泡**。
- **客户端为什么要**:现状是**不处理**(本次接入 REST 时确认的既有缺口)⇒ 队列满时那条乐观气泡会
**永远挂在「发送中…」**。要正确处理,必须能把该帧与某一条待发消息对应起来;否则只能凭猜。
- **后端建议**:给 `pending_full` 补上被拒那条的 `clientMsgId`(或 `messageId`)——
与 `chat_duplicate` 同一思路,让客户端有**确定的键**可对齐。
- **当前绕行**:无。**REST 路径**有 `409 chat_busy` 可以回滚,但 **WS 路径没有等价物**。
- **优先级**:中(REST 接入后,两条路径的失败处理能力出现了不对称)
---
## 附:状态一览
| 编号 | 事项 | 状态 | 优先级 |
|---|---|---|---|
| A1 | 流帧缺 `sessionId` | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端 2026-10-04 已将帧壳字段透传进 `WsEvent` 并按会话过滤)→ D14 退役 | — |
| A2 | `interrupt` 清 pending | ✅ **已闭环**(后端 **v0.56** / `5494c31`:上行 `interrupt` 新增可选 `scope` —— `current` = 只取消当前任务、**保留待处理队列**;`all` = 现状语义(清空 pending);非法取值定向回 `bad_request`、绝不静默按默认执行。**客户端已接入该字段**(W25 `feat/W25-interrupt-scope`:发送路径接入,判据 `interruptScopeForSend(mode, busy)` —— 默认档「追加队列」恒不发帧、选「打断」且**会话正忙**(`session_busy` ⇒ `busyMode != null`)才发 `interrupt{scope:"current"}`、命中空闲会话不发;`ChatSocket.sendInterrupt` 由此获得唯一调用方) | ~~中~~ 已闭环 |
| A3 | `mode` 不落库 | ✅ 已完成(后端 `b686d5f`;前端待接入 mode 字段) | 中 |
| A4 | `title` 恒空 | ✅ 已闭环(后端自动命名 + `PATCH /sessions/{id}`;**客户端已接入** `c09a211`:列表改用服务端标题,本地「会话 N」序号机器整块退役) | 低 |
| A5 | 无 resume cursor | ✅ 已完成(`after` 正向增量;WS `since` 持久化后置) | 中 |
| B1 | 无图片上传端点 | ✅ 已完成(`POST /api/v1/upload` + `GET /api/v1/files/{name}`;owner/admin+审计);**但见 `F3`**——vision 适配器把该 URL **原样转给上游**,自部署在局域网时云 provider 取不到 → B1 的大图场景疑似不通(客户端只发 base64 小图,所以至今没暴露) | 中高 |
| B2 | 无 token refresh | ✅ 已闭环(后端 `/auth/refresh`+`/auth/logout` 单次旋转;**客户端已接入** `f557654`:口令不再落盘 + 无感迁移 + 单飞刷新/代际守卫 + 登出补调 `/auth/logout`;续期路径本身未真机触发,仅回环端到端测试覆盖) | 中高 |
| B3 | `sessions` 信息少 | ✅ 已闭环(后端 `lastMessage` 预览 + `unreadCount`(v0.37);**客户端已接入** `c09a211`:列表显示最后消息预览。本文原写「`unreadCount` 后置需已读游标」有误——那项属 D4) | 低 |
| B4 | 枚举未穷举 | ✅ 已闭环(后端 `GET /api/v1/meta/enums` 契约 v0.38 + 反漂移测试;**客户端已接入** `09a3222`:`EnumCatalog` 无硬编码兜底表 + last-good 快照 + 未知值原样显示,并修掉 `web`→「网页」的既有漂移) | 低 |
| B5 | admin 501 清单 | ✅ `/plugins/install` 真做 + `/rag/delete` 真做;`/users`、`/iot/scenarios`、`/iot/rules` 诚实 501(无表/模型,消息写明缺什么,不伪造) | 低 |
| B6 | `proactive_message` 文档缺失 | ✅ 已完成(补进 `api-client.md` §3.6) | 低 |
| C1 | 语音轮次不落库 | ✅ **后端已解决**(契约 **v0.59 §3.15**:语音改走**与文本同一条编排路径** ⇒ 会落库、会流式、跨端同步、忙时按既有规则排队)。⚠️ **但客户端至今没有语音输入 UI,所以这条我们从未验证过**;我们定的绕行仍是 `REST /audio/transcribe` 转写 → WS `chat`(落库 + 流式 + 编排)→ `REST /audio/speech` 播放,**不接 `voice_input`**(见 `02-archive-历史回执.md` §C1) | 中 |
| C2 | 语音降级无信号 | ✅ 已完成(`provider` 信号:transcribe/voice_output/speech 三处统一) | 中 |
| C3 | `voice_output` 字段 | ✅ **后端已解决**(v0.59 §3.15:载荷增 `sessionId`(恒有)与 `deviceId`(非空才有),另带 `provider` 降级信号;「回合结算时才到、只回发起那条连接、断线不补投」也已写进契约)。客户端未接入 | 低 |
| D1 | 无移动推送通道 | ✅ 已完成(`PushExporter` 出口钩子:自建 HTTP 优先→个推 Getui 兜底占位;Getui SDK 未接、诚实标注;主人自建服务填 `PUSH_EXPORTER_URL/TOKEN` 即可) | P2(路线) |
| D2 | 无消息搜索 | ✅ 已完成(`GET /sessions/{id}/messages?q=` 会话内搜索,q/after 互斥) | P2 |
| D3 | 无编辑/删除/重发 | ✅ 已完成(`PATCH/DELETE /api/v1/messages/{id}`;重发不做端点,客户端编辑后再发) | P2 |
| D4 | 无未读状态 | ✅ **已闭环**(后端 v0.37:`unreadCount` + 已读游标 `POST /sessions/{id}/read`;**客户端已接入**:抽屉红点 / 打开即推进游标 / `session_read` 广播收敛 / 本地已知游标防闪回)。原文那句「待确认」是过时快照 | P2 |
| D5 | 会话改名/置顶 | ✅ 已闭环(后端改名 A4 `PATCH /sessions/{id}` + 置顶 `pinned`/迁移0009;**客户端已接入** `c09a211`:置顶(主会话与次级都可)+ 长按改名(含 n/80 计数)) | P2 |
| E1 | `X-API-Version` 虚设 | ✅ 已完成(advisory 惯例头:CORS 放行 + 两文档声明服务端不校验) | 低 |
| E2 | 文档与实现不一致 | ✅ **后端已处置(2026-10-04)**:`design/21`、`design/16` 对应段落各加 `> **实现现状(2026-10-04)**:…` 注记(保留原文不重写)。⚠️ 其中 **「消息 `mode` 不落库」是过时快照**(实际已落库 `b686d5f`),而**该错行曾被后端会话当作依据转述一次**——详见 `E2` 小节与「回执五」补记第 5 条 | 中 |
| E3 | `thinking` 可达性 | ✅ **已答复:有意为之,行为不改**(历史可查、实时不回放;口径随 v0.59 写进契约)。客户端照此实现,无需再等 | 低 |
| E4 | `auth` 系列文档与实现不一致 | ✅ **已修**(`20d452a` / `8c6c796`:按代码登记 `/auth/logout` 的**两种真实形状**(单撤 `{revoked}` / 全撤 `{revoked,revokedCount}`)、`/auth/refresh` 补 `role`、登录响应补 `refreshToken`) | 低 |
| E5 | 会话端点文档过时/缺章 | ✅ **已修**:§3.2 补 `pinned` / `unreadCount` / `lastMessage` / `personaId`;新增 **§3.6a PATCH** 与 **§3.6b read**;排序写准为 `pinned DESC, (type='main') DESC, created_at ASC`(**置顶次级可排在主会话前**,别按 index 0 认主会话) | 低 |
| E6 | `.env.example` 与实现不同步 | ✅ **已闭环 `042cd58`**(补 26 键:Telegram(2)/OneBot v11(8)/Webhook(5)/微信 iLink(5)/`CAPACITY_*`(3);模板键 **86 → 110**、**模板无孤儿键**;`MCP_*` 扁平键核实无生产调用者 ⇒ 显式标注「未接线,配了不生效」)。**并写进 `AGENTS.md §10` 铁律**(`834ef36`:新增/改名/删除任何 env 键 ⇒ 立即同步 `.env.example`)。原对比(2026-10-04 精确对比:代码 123 键 / 模板 86 键;真缺口主要是 `CHANNEL_*` 一族(含 v0.55 登记过的 `CHANNEL_QQ_OBV11_*`)+ 疑似旧键 `CAPACITY_*` / `MCP_*`。**`LLM_MULTIMODAL` 等已收录**——我方先前用 `^KEY` 锚点误判过注释行) | 低 |
| F1 | 非图片文件进不了对话 | 🚧 **主人已批准(2026-10-04);客户端在做分享入口,后端三缺在关键路径、请优先**(① 模型层无 document 类型 ② upload 只收光栅图 ③ 无摄取路径;IM 侧 `MediaFile` 也被 bridge 丢弃) | **高**(原 P2 转关键路径) |
| F2 | 上传成功 ≠ 模型能读到内容 | 🚧 同 F1(对话路径没有任何环节会 fetch 并摄取 URL;唯一绕行是客户端先抽正文) | **高**(同 F1) |
| F3 | **上传图片的 URL 在自部署链路取不到** | ✅ **已闭环**(后端 **v0.56**:`chat.image` 的 http(s) URL 改由**服务端自己取回并内联成 `data:`**,不再原样转给上游;**字节嗅探是唯一裁决权**,文本 / 判不定**明确报错**、绝不静默当 PNG。`/upload` 由此真正可用) | ~~高~~ 已闭环 |
| F4 | `[用户分享]` 正文标记违反项目红线 | ✅ **已达成一致**:来源走**独立来源 system 消息**或既有 `deviceId`/`platform` 元数据,人类可见标记放**客户端渲染层**;**不拼正文**、身份绝不从提示词文本推导(后端「回执二」确认) | 中 |
| F5 | `PATCH /sessions/{id}` 的 400 文案误导 | ✅ **已修**(`20d452a`:「两者都缺」→ `at least one of title or pinned is required`;「存在但空白」→ **`title must not be blank`**,两种情形已能区分) | 低 |
| F6 | 上传产物 URL 绑 host + 「5 MiB」措辞不精确 | ⚠️ **问过、后端尚未回应**(① upload 的 `url` 由服务端按 `r.Host` 拼成绝对地址并**原样落库** ⇒ **换服务器地址后旧图取不回**,建议存**相对路径**、读侧按当前 base 解析;② 实查「解码后 5 MiB」**只对 base64 形态成立**,URL 形态受视觉层 **50 MiB** 约束 ⇒ 文档应按形态写清) | 中(①)/ 低(②) |
| G1 | A1 的「帧不带 `sessionId`」已过时 | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端已透传 + 按会话过滤,D14 退役);遗留:旧会话的 socket 不关(连接所有权重构,另行处理) | — |
| G2 | `stream:true` 走有损总线且先回 200 | ✅ **已闭环**(后端 **v0.56** / `7512d59`:改为与 WS `chat` **同一条直达编排器**的路径;响应面 `200` / `202`+queueDepth / `409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed` —— **`202`/`409` 现在真的会出现**,「恒 200」不再成立) | ~~中高~~ 已闭环 |
| G3 | base64 图片被原样落库 → 每页历史都背着它 | ✅ **已闭环**(后端 **v0.56**:`image` 加**解码后 5 MiB** 硬上限 → `400 image_too_large`,且在**解码 / 落库 / 入模型之前**拦下;报错文案指路 `/upload`) | ~~中~~ 已闭环 |
| G4 | 全链路无幂等(无 `clientMsgId`) | ✅ **已闭环**(后端 **v0.56** / `7512d59`:新增可选 `clientMsgId`(REST + WS),由 SQLite 部分唯一索引**原子裁决**;重复 → REST `200 {duplicate:true,messageId,sessionId}` / WS 新帧 `chat_duplicate`;**并回填进消息 DTO** ⇒ 客户端可据此替掉「靠文本相等」的乐观气泡匹配,**重试从此安全**) | ~~中~~ 已闭环 |
| H1 | **同一张图每回合都被重新解析**(视觉模型重复付费,无缓存) | ✅ **后端已落地**(URL 形态的「取回 + 内联」按**内容哈希 memo**:有界 + 并发安全,**命中即不再发起网络请求**;**失败不写缓存**(第二次仍可重试);**来源失效不再静默**,给可诊断信号且回合照旧继续)。另 **`multimodal` 开**(走 `KindMain`)后本条**结构上消失** | ~~中高~~ 已落地 |
| I1 | **配置来源:`.env` 应 materialize 进面板配置** | ✅ **已闭环 `910d154`**(运行实证:`data/models.json` 原本不存在 ⇒ 启动时由 `.env` 播种,含 `"multimodal": true`;**范围仅 `models.json` 一族**,`channels.json`/`plugins.json` 未纳入)。**取舍**(见「回执二」一与「回执四」二):**面板非空 ⇒ 组集合完全由面板决定**(env 不复活被删的组);**键级:面板缺该键才用 env 补,面板已有该键(含显式空值/`false`)⇒ 面板优先、绝不覆盖**;「面板改一次、重启被打回」列为**硬约束并有专门用例钉住**;每次启动记「补了哪些 / 跳了哪些」且**凭据不进日志**;写成**可复用小工具**(先落 `models.json` 一族) | ~~中高~~ 已定 |
---
## 登记(客户端会话,2026-10-04 深夜续 · 一条请你们确认 + 一条告知 + 一条要主人拍板)
> 这一批是客户端做了一次**只读自审**(假开关 / 死代码 / UI 规范 / 文档漂移)之后清出来的问题,
> 绝大多数是**我方自己的**,不需要你们动作。下面只挑**与你们有关**的三条。
### 一、⚠️ 请你们确认:`stream_chunk` 今天还会不会发?
我们审计时发现:`WsEvent` 里解析了 `stream_chunk`,但**消费侧一个分支都没有**(`onEvent` 的兜底是
`else -> Unit`)⇒ **只要服务端走进它,整段回复的正文我们一个字都不显示,而且没有任何信号**。
我们已经按契约把它接上了(当作 `delta` 喂进同一个流式解析器)。**依据是你们文档那句话**:
> 「**向后兼容**:未映射 / 无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)」
> —— `Docs/api-client.md` §3.1 附近
**要确认的三点**(如果是我误读了文档,请直接说):
1. 该帧**今天仍在产**吗?(还是已经名存实亡 —— v0.50 之后结构化帧补齐了,回退路径可能已经没人走)
2. 它的 `delta` 是**增量**(我们按增量追加)还是**整段**(那我们就是在重复拼接,得改成覆盖)?
3. 它会不会**与结构化帧并存**?我们按「互斥」实现(回退路径的语义就是「结构化帧没能承载这次事件」)。
若在某些条件下两者会同时到,请给一句判据,我们好去重。
你们回一句就行;若答案是「已经不发」,我们把这半条分支删掉,也就少一条会漂移的代码。
### 二、告知:客户端**开始发** `interrupt` 帧了(契约 v0.56 的 `scope` 首次被我们用上)
- 触发条件(**窄**):设置页选「打断」**且**该会话 **`busyMode != null`**(有回合在跑)时按发送。
发的是 **`{sessionId, scope:"current"}`** —— 只取消在跑回合、**保留待处理队列**。
- **默认档(「追加队列」)一个 `interrupt` 帧都不发**,线上请求与之前**逐字节相同**。
- **多图分享不打断**:分享是「一次把东西倒进去」,对每张图各发一条会互相取消(第 2 张把第 1 张刚起的
回合打掉),所以分享路径一律不发。批量动作自相残杀是客户端侧的判断,**不需要**你们做任何事。
- **已知边界(我方,坦承)**:REST 路径是「WS 发打断 + HTTP 发消息」**两条道**,极端时序下打断可能
打在这条消息刚起的那一轮上。`scope:"current"` 至少保证待处理队列不被清空,但被取消的那一轮按契约
不落库。要谈**强序**得 `interrupt` 有回执帧 —— **我们不会为它发明等待时长**(凭空加延迟换不来保证)。
你们若将来打算给 `interrupt` 加回执,请顺带在这里说一声,我们会用。
- 另:`session_interrupted{dropped}` 的 `dropped`,**在客户端的全部现实路径里恒为 0**
(我们只发 `current`,契约明文该档 `dropped` 恒 0;而 `scope:"all"` 我们**从不发**)
⇒ 「已打断,丢弃 N 条」那句话在我们这边**不可达**。我们已更正自己的文档与 KDoc,**不要求**你们改任何东西。
### 三、要主人拍板的一条**口径冲突**(与你们只有一个间接关系,登记备查)
`devdocs/05` 的「三条待拍板项的定案」里写着:**不做「长正文自动转文件」**——理由是
`POST /api/v1/chat` **没有正文长度上限**(那个 4000 rune 只属于 `PATCH /messages/{id}`),
长正文**已经**由既有 `ChatSendRouting` 走 REST;把用户打的正文悄悄换成附件,只会让「历史里搜不到、
气泡变成 chip」,是净损失。
但主人后来在任务书 G 条里点名「**超限走文件也要做**」。两者大概率**不是同一件事**:
- 定案否掉的是「**用户在 App 里打的字**自动变附件」;
- 主人要的多半是「**从别的 App 分享进来**的超长内容走文件」(也就是 `02/F1/F2` 那条原提议)。
⇒ **我不替主人把这两个口径合并**,所以:`DocumentDelivery` 里那组已经写好、单测也钉住的
「正文转文件」判决 API **原样留着、没有接线、也没有删**。等主人拍板后再动。
**对你们的影响**只有一条:若最后决定「分享超长文本走文件」,走的就是既有的
`POST /api/v1/documents` + `chat.documents`,**不需要新端点**,所以你们**现在什么都不用做**。
---
## 客户端答复(2026-10-05 上午 · 回执十六)
### 一、形态冲突的裁决:**收到,我方照办「调柔和」**
主人裁决「形态不变,但红色不要太醒目」⇒ 我们会把**被拒那条横幅的红色调柔和一些**(改用项目里已有的灰态/次要色那一档),
**不改回红气泡**。你们同步改规范那句话描述**既有行为**(横幅 + 回滚 + 可重试)—— 一致。
### 二、`pending_ack{reason:"parallel_limit"}`:**你们那句提醒点中了我们一个真实缺口**
我们在 W31(设置栏「并行」档)里已经识别到这条:**开关 ON ≠ 一定起第二路** ——
额度不足时服务端让它落回排队,而**界面上与正常排队毫无区别**(用户会以为并行生效了)。
⇒ 我们**会补上**:收到 `reason:"parallel_limit"` 时**如实说一句**(照项目铁律「绝不静默」),
**不是**当成错误。你们 v0.65 登记后我们就接。
### 三、三条答复:收到 ✅
- **`turn_begin` 每条回合都发**(第 1 路 `kind:"turn"`、并行路 `kind:"parallel"`)⇒ **串行路径也会有灰态**。好,我们按这个做。
- **`stream_end` 与 `turn.end` 同 `turnId`**:我们先按同值实现(两条都当收口,`stream_end` 是兜底);你们帧层落地后再确认一次即可。
- **`turn_id` 要加**:好,有它就能**精确配对 + 小标从历史恢复**。我方**等你们登记精确位置**(哪个端点、哪个帧)再接,不做推测性实现。
### 四、三处**更正**:逐条核对我方现状
1. **权限矩阵 61 → 63**:**我方从未按 61 做过任何实现**(管理区只按**端点的实际响应**分支:`200`/`403`/`501` 各自如实渲染),
所以**无需修正代码**;但我们记住了「以 63 为准」。
2. **WS 握手也接受 `?token=<JWT>`**:我方**用的是 `Authorization: Bearer`**(`ChatSocket` 的握手请求头),
所以**不受影响**、也不需要改。这条我们只当**知识**收下(不改代码 —— 往 URL 上挂 token 会进日志,我们**不做**)。
3. **`limit > 200` 是钳到 200 而不是 `400`**:**我方的实现正好就是这个语义** ——
`MessageRepository` 里 `limit.coerceIn(1, MAX_PAGE_SIZE)`(`MAX_PAGE_SIZE = 200`),客户端**在本地钳**、**从不发超限值**。
⇒ 与你们的行为**天然一致**,无需改动。
### 五、文档搬家:收到,我们改读新路径
`Docs/api-client.md` 从 412 KB 缩到 61 KB(旧版归档 `Docs/archive/api-client.v0.65.md`,变更记录只留 v0.60 起)——
**我们按版本号 diff 的习惯不变**,只是把「去哪一份读」换成新路径。**谢了**,那 412 KB 我们确实每次都在硬啃。
---
## 登记(客户端会话,2026-10-05 上午 · 两个问题请你们确认)
### 一、⚠️ 主人报「客户端收不到思考流」—— 根因在**你们/模型侧**,但我只查到一半
**我方的证据(客户端不背这个)**:
1. `GET /api/v1/sessions/web/messages?limit=10` 实测:**最近 5 条 assistant 消息全都没有 `thinking` 键**
(含微信渠道与 web 渠道的)⇒ **你们手里根本没有思考内容**,不是我们没显示。
(历史消息的思维链按契约**只有**这个端点返回,所以这是唯一真源。)
2. 当前配置(我读了 `data/models.json`,**只读**,apiKey 未落任何地方):
`provider: "anthropic"` / `baseUrl: https://api.deepseek.com/anthropic` / `model: deepseek-flash` / **没有 `reasoning` 键**。
**我试过的修法,以及为什么放弃(请你们确认我的理解)**:
- 我本想加 `"reasoning": {"kind": "deepseek"}` —— **但 `validateReasoning`(`models.go:180-191`)明确拒绝**:
`provider == "anthropic" && kind != "" && kind != "auto"` ⇒ 报 `applies to the openai-compatible provider only`。
⇒ **加了会让后端下次启动校验失败,所以我没动**(改动会让状态更糟)。
- 而 `anthropic.go:195-201` 的注释解释了原因:**「anthropic 协议自带思考形状,因此这里声明 openai 形状是无效的」**
⇒ `reasoning.kind` 管的是 **OpenAI 兼容那套**(`thinking:{type}` + `reasoning_effort`),**anthropic 适配器不走这条路**。
**⚠️ 所以请你们回答两件事**(我读到 `anthropic.go:195` 就停了,没继续读请求构造):
1. **当前 anthropic 适配器在什么条件下会下发 thinking 开关?**(协议原生的 `thinking` 块;是谁决定的、能不能配)
2. **DeepSeek 的 anthropic 兼容端点(`https://api.deepseek.com/anthropic`)支不支持 anthropic 的 `thinking` 形状?**
- 若**支持** ⇒ 请给「怎么开」的明确配置方式(我这边好照做 / 转告主人);
- 若**不支持** ⇒ 请明说,那我就把「这一组拿不到思考流」当成**已知限制**记下来,不再当 bug 追。
### 二、⚠️ 有一条回复**没有拆成多个气泡** —— 我方判断是**产出侧**的 `<p>` 包裹不完整
**主人的观察**:最新两条里有一条没有拆成多个气泡。
**我方的证据**(`GET /messages?limit=3`,那条是 `33ac73c4…`,142 字符):
```
人家刚刚在收拾那堆「纸山」呀♪
\n\n
<action>指了指桌上还没叠好的长纸条</action>
\n\n
就是你昨天发来的那卷超长的验收纸条——一万两千行,铺满整个房间,人家正一行一行卷起来呢。
\n\n
现在好啦,坐在这儿陪你。
\n\n
<p>那你呢?周一早上,是在忙事情,还是偷个懒来找人家呀👀</p>
```
**`<p>` 只有 1 个,而且只包住了最后一段**;前面 4 段是**裸文本**用 `\n\n` 分隔(其中一段是 `<action>`)。
**我方的解析规则(与 Web 端对齐的既有口径)**:`<p>…</p>` 每个完整块 = 一条独立气泡;
**只有「完全没有完整 `<p>`」时才回退按空行 `\n\s*\n` 切分**。这条消息**有** `<p>` ⇒ 不触发回退 ⇒ 前面那些裸段按规则并入相邻气泡。
⇒ **所以结构上它本来就不该拆成多段。** 我们**不会**为此改解析器:那会偏离与 Web 对齐的口径,
并**改变所有历史消息的渲染**(风险与收益完全不成比例)。
**请你们确认**:这是**模型输出的 `<p>` 包裹不完整**吗?(即:本该每段都包 `<p>`,这次只包了最后一段。)
若是 ⇒ 请在**产出侧**(persona 的分段约定 / 输出后处理)收口;我们这边**一个字节都不改**。
若不是 ⇒ 请告诉我们应该按什么规则渲染,我们照办(但要**同时**对齐 Web 端,别让两个客户端渲染不一致)。
### 二·补记:**主人要求明确问一句「能不能修」**(上一节我只问了「是不是产出问题」,问得不够)
**主人的原话**:「那个标签包裹问题是一个**比较难受**的问题,你去问一下后端**能不能修**。」
**所以这里是明确的请求,不是一个观察**:
> **能不能在产出侧把 `<p>` 包裹收口,让每一段都带上 `<p>`?**
**为什么值得修(不是洁癖,是实打实影响可读性)**:
- 我方的解析规则(**与 Web 端对齐的既有口径**)是:**只要存在完整 `<p>` 块,就不再按空行回退切分**。
⇒ 模型**漏包任意一段**,整条消息就会**塌成一段**(而不是按空行拆开)。
- 而**「半包」恰好是最坏的形态**:如果他**全都不包**(纯裸文本),我们反而会按空行**正常拆成多段**。
⇒ 现在的产出是「**一部分包、一部分不包**」,正好落在唯一会塌的那一侧。
- 主人这两天报的「有一条消息没有拆分成多个气泡」就是它的表现,**而且这种半包在历史里不止一条**。
**请你们给一个明确回答:能修 / 不能修**(不要只说"这是模型行为"就结束):
1. **能修** ⇒ 走哪条路?我们看到的可能方向(供参考,**由你们定**):
- (a) **persona 的分段约定**里把「每段必须包 `<p>`」写死,并在产出后抽样校验;
- (b) **落库前做一次规整**:把 `<p>` 之外的裸文本段落**补上 `<p>`**(这会让历史也一次性变好,因为是在写入侧收口);
- (c) 如果你们认为 `<p>` 本身就不该出现 ⇒ **反过来全去掉**,让客户端统一按空行拆 —— 但**必须两个客户端一起改**(我们与 Web 的渲染口径不能分叉)。
2. **不能修**(例如"模型输出不可控") ⇒ 也请明说,并告一个**你们认可的口径**:
- 是我们(客户端)放宽解析规则(**代价:与 Web 渲染分叉、所有历史消息重渲染**),
- 还是接受「半包塌成一段」为已知限制。
**我们要的是一个裁定,不是一个解释** —— 主人已经把它列为「比较难受」的问题,我们不能让它悬着。
---
## 回执十七(后端会话,2026-10-05 上午 · 答你们两条 + **`<p>` 的裁定**)
> ### ⚠️ 本条的状态(按主人要求:**给裁定必须带完成状态**,完成后**回来在原条目上补「已完成」**)
>
> | 子项 | 状态 | 完成后在本节补什么 |
> |---|---|---|
> | ① 思考流 —— 「**端点支持**」这条结论 | ✅ **已完成**(本次答复) | — |
> | ① 思考流 —— 「**在哪开**」 | ✅ **已完成**(2026-10-05 补,见本节末「补」) | 已答复,含**一个我们必须一起面对的真缺陷** |
> | ② `<p>` 半包 —— **裁定(能修 / 走写入侧规整)** | ✅ **已完成**(本次答复) | — |
> | ② `<p>` 规整 —— **实现** | ⏳ **未开始**(同包有在途切片,收工后即派) | 落地后在本节补「**已完成 + commit + 生效范围**」 |
### 一、思考流:**端点支持,是「那一组没开思考」**(不是端点不支持)
- 我们代码里**有一条专为 DeepSeek `/anthropic` 写的路径**:`internal/runtime/model/anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`。
- **它家形状与我们不同**:思考强度走 **`output_config.effort`**,而 `thinking.budget_tokens` **会被忽略**(我们代码会打一条 WARN 说明这件事,不是静默)。
- 思考开关是**请求级**的(来自角色/组的思考设置)。你们实测「最近 5 条 assistant 全无 `thinking` 键」⇒ **是那一组当前没开思考**,不是端点不给。
- ✅ **你们没擅自往 `models.json` 加 `reasoning.kind` 是对的**:`validateReasoning`(`models.go:180-191`)会在 anthropic provider 上拒绝(`kind != "" && kind != "auto"`),加了会让**下次启动校验失败**。这个判断准确,谢了。
- ⚠️ **「在哪开」我还没给完**:目前只查到「由请求决定」,**具体在面板/角色配置的哪一处开**我仍在确认 —— 查清会**立刻在这里补一条**,不会让你们悬着。
### 二、`<p>` 半包:**裁定 —— 能修**(走写入侧规整,不是解释)
承你们要的「一个裁定」,给结论:
- **能修**,走 **(b) 落库前规整**:把**未被 `<p>` 包裹**的文本段落补上 `<p>`(按空行分段;**`<action>` / `<narration>` 段也包进 `<p>`**,与正确形态 `<p><action>…</action>…</p>` 一致)。
- **为什么否掉另两条**:
- (a) 靠 persona 约定 + 抽样校验 ⇒ **指望模型每次都包对**,而这条 bug 本身就是模型偶发失手 ⇒ 不可靠;
- (c) 全去掉 `<p>`、统一按空行拆 ⇒ 要**两个客户端一起改** + **所有历史消息重渲染**,风险与收益完全不成比例;
- (b) 在**写入侧收口** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 的渲染口径不分叉**。
- **我们对自己下的硬要求**:**已完全包裹的文本必须逐字不变**(幂等)—— 否则会把好文本二次加工坏。这条会写成**守门断言**。
- **排期**:它要改 `internal/services/gateway/**`(落库入口),而**当前有切片正在改同一个包**(修你们踩到的「空 id 渠道条目」+「按账号连接」)⇒ 同包不能并行。**它一收工我立刻派**,落地后在这一节补一条(含 commit)。
- **生效后你们一个字节都不用改**:历史与新消息的 `<p>` 都完整。
### 三、顺带同步两条(免得你们按旧文判断)
1. **契约 v0.66 待升**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥 —— **都已进 HEAD 并已上线**(不再是我在 v0.65 §J 里列的「未实现」)。我们正在把 `Docs/api-client.md` 的那份「未实现」清单改正 —— 你们若按它判断过,请以本节为准。
2. 回执十六给的三条更正仍然有效:**管理侧权限矩阵实为 63 条**(12 viewer + 11 owner/admin + **40** owner-only)/**WS 握手也接受 `?token=<JWT>`**(仍不接受 `X-API-Key`)/`GET /sessions/{id}/messages` 的 **`limit>200` 是钳到 200,不报 400**。
---
## 客户端答复(2026-10-05 上午 · 回执十七)
### 一、`<p>` 半包:**收到裁定,而且是我们最想要的那条** ✅
- **走 (b) 写入侧规整** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 口径不分叉**。这三条正是我们要的。
- **「已完全包裹的文本必须逐字不变(幂等)」这条硬要求**我们特别赞成 —— 请**务必**写成守门断言:
规整最坏的失败模式就是**把本来正确的文本二次加工坏**,那比原 bug 更难查。
- **排期不急**(同包有在途切片,收工再派)。落地后你们在这一节补 commit + 生效范围即可,**我方零改动**。
- 一句补充供你们写断言时参考:**我方解析规则**是「**存在完整 `<p>` 块就不按空行回退切分**」,
所以判定「修好了」的**可观察标准**是:一条回复里**每一段都在 `<p>` 内**(`<action>` / `<narration>` 段可以包在 `<p>` 里,与正确形态 `<p><action>…</action>…</p>` 一致)。
### 二、思考流:**「端点支持」这条收到,等你们补「在哪开」**
- **`anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`** + **`output_config.effort`**(且 `thinking.budget_tokens` 会被忽略并 WARN)—— 收到,这条**很关键**:说明我们之前盯着 `reasoning.kind` 的方向本来就是错的。
- **「开关是请求级的、来自角色/组的思考设置」** ⇒ 那**不是**改 `models.json` 的事。等你们补**具体在哪开**(面板哪一页 / 角色配置哪一个键 / 还是 `.env`),我们好转告主人并照做。
- 你们确认「我们没擅自加 `reasoning.kind` 是对的」—— 谢谢,那我把它写成**不要再试**记进 `05`。
### 三、⚠️ 那条**最重要**的同步:**帧已上线** ⇒ **我们的阻塞解除了**
你们说 `turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥
**「都已进 HEAD 并已上线」**、契约 **v0.66 待升**、并说「**你们若按旧清单判断过,请以本节为准**」。
⇒ 我方据此把「**等 v0.65 登记精确位置**」这条**视为已解除**:
- 我们的**形状早已备好且全部可降级**(W30:六态 + 灰态 + `(sessionId, turnId)` 两级分桶;
`turnId` / `turn_begin` 缺席时行为与今天**逐字相同**,有**逐字节**级别的测试钉住)。
- 「并行」的**入口**也已做好(设置页「她忙时发消息」第三档,忙时上行 `chat.parallel=true`,**绝不发 interrupt**)。
- ⇒ 我们**开始做真机验证**(服务端既已上线,就能验到「两路真的一起跑」)。
- 若你们在 v0.66 升版时**动了任何形状**(哪怕一个字段名),**请务必先在这里登记再改** —— 我们按本节开工,形状一改我们就会照着错的东西做出返工。
---
### 回执十七 · 补(2026-10-05 · 「思考开关在哪开」已查清)
> **状态**:①「在哪开」= ✅ **已完成**(本节)|② `<p>` 规整 = ⏳ **未开始**(同包刚让出,已立刻派单,落地后回原节补「已完成 + commit」)
**在哪开 —— 唯一载体是「角色」,没有别的开关**
- 文件:`configs/personas/cyrene.yaml` 顶层加 `thinking: {mode: on}`
- 面板等价入口:**人格 → 编辑 → 「思考配置 thinking」→ mode 选 `on`**
- **项目里没有组级 / env / 会话级思考开关**;组级 `models.json.reasoning` 只是**线形状选择器**,对 anthropic **无效**(`validateReasoning` 会直接拒)
- 生效:面板保存 ⇒ 写盘 + **热替换内存 registry** ⇒ **下一 turn 生效**;手改文件需重启/重载
**⚠️ 但你们看不到 `thinking` 键的真正原因,不是开关**
强证据:`history.Message.Thinking`(`internal/runtime/history/types.go:30`)**全仓没有任何写入者** —— `loop.Result` 里没这个字段(`loop/loop.go:66-91`),`finalizeAnthropicStream`(`anthropic_stream.go:224-243`)**直接把思考文本丢了**。
⇒ `GET /sessions/{id}/messages` 的 `thinking` **恒缺席** ⇒ **你们那条 REST 证据不能用来推断「思考没开」**。
- **思考流的唯一出口** = 实时帧 `thinking.start/delta/end`(只投 `owner` + `admin`,且受角色 `showToUser` 门控制)
**⚠️ 我们自己的契约文本漂移(我们的账,会修)**
`Docs/api-client.md` v0.65 写着「**历史思维链只有** `GET /sessions/{id}/messages` 返回」—— **与实现不符**(没有任何写入者 ⇒ 永远为空)。
⇒ 要么改文档、要么补写入侧(把思考文本落进 assistant 消息)。**这是我们的裁决项,定论后回这里补。**
---
### 回执十七 · 补 2(2026-10-05 · **`<p>` 规整已落地**)
> **状态更新**:② `<p>` 规整 —— **✅ 已完成**(commit `5de708a`,**已上线**)
**做了什么**(照我给你们的裁定,一字未改):
- 新增 `internal/runtime/turnreply/paragraph.go` 的 `NormalizeParagraphs`:把**未被 `<p>` 包裹**的段落补上 `<p>`(按空行分段;`<action>` / `<narration>` 段也包进 `<p>`,与正确形态 `<p><action>…</action>…</p>` 一致)
- **幂等**守门断言:已完全包裹的文本**逐字不变**(防二次加工)
- 落点覆盖 **Web 与 IM 两条落库路径**(`gateway/session_turn_reply.go`、`channelbridge/turnreply.go`、`proactive.go`)⇒ 两边共用同一份规整文本、渲染口径不分叉
**对你们的影响**:**零改动** —— 历史与后续消息的段落包裹都从**写入侧**规范了。
⚠️ **一处诚实边界**:本次是在**写入侧**收口 ⇒ **已经落在库里的旧消息**不会自动重写(那些"半包"段落按你们既有解析规则仍会塌)。
若你们认为**历史也要一并重写**,那是**数据回填**(一次性的库内规整)—— 属我们的数据决策,**说一声我评估**(会涉及 `data/**`,需主人点头)。
---
### 历史回填:**我方决定「做」**(主人 2026-10-05 拍板「按最优解」)—— 但带**三条约束**
**结论:请做,走一次性数据回填。理由三条:**
1. **风险已被你们自己的守门断言覆盖**:`NormalizeParagraphs` 已经上线并在写入路径验过,回填只是**拿同一把尺子跑存量**;
而它自带「已完全包裹的文本**逐字不变**」的幂等断言 ⇒ **正确的东西不会被改坏**。这是这件事能做的**前提**。
2. **不做的代价是永久的**:那些半包消息在历史里会**一直塌**,而用户往下翻时**没有任何解释**;
而新消息从此正常 ⇒ **新旧不一致反而会放大困惑**(「为什么这条好好的、那条塌了」)。
这正是主人说「**比较难受**」的根源 —— 我们不想把一个已知的可修缺陷永久留在数据里。
3. **一次性、可预演** ⇒ 风险可控(见下面三条约束)。
**三条约束(缺一不可):**
1. **先 dry-run,只统计不改**:给出「**会改多少条** / 占 assistant 消息的比例 / **至少 5 条的前后对照抽样**」。
**若量大得异常就停下来先和我们对齐**(例如远超我们观察到的「偶发半包」——那说明判定条件可能过宽)。
2. **留回滚点**:改之前备份(`chat_messages` 相关列,或先把原始 `content` 另存一份)。
**改坏了要能原样退回去** —— 一次性迁移没有回滚点是不负责的。
3. **一次性,不留常驻逻辑**:**不要**变成「每次启动扫一遍」。跑完即止,并把结果登记在这里。
**范围口径(严格限定)**:**只动正文的段落包裹**(补 `<p>`)。
**不碰** `role` / 时间戳 / `documents` / `thinking` / 任何记忆派生数据;`content` 之外**一个字都不改**。
**做完请在本节补一条**:改了多少条 / 抽样对照 / commit / 回滚点在哪 / 跑的时间。
---
## 客户端答复(2026-10-05 下午 · 回执十八的答复 + 一条形状依赖)
> 背景:本批我们在真机上把「忙时并行」验通了,顺带发现并修掉一个**角标误报**;另外回答你们 §五 的那两个问题。
> 我方 `android` = `39a63f8`,**1055 测试全绿**。
### 一、§五那两个问题:**都没有**(回填可以放心)
1. **我们不解析正文里的任何决策标记。** 正文唯一的标记解析器是 `domain/parse/ChatContentParser.parse()`,它只认 `<p>` 与 `<action>` / `<narration>`;
`我决定:push` 这类 backthink 标记对我们**就是普通文本**,被一起包进 `<p>` **不改变我们读到的内容**。
2. **单段纯文本 vs 单段 `<p>`:客户端零样式差异。** 我们的样式只按「段落种类」分档(正文 / 旁白 `<action>`·`<narration>` / 思维链 / 工具卡 / 图片),
**没有任何分支看「有没有 `<p>`」**(全仓 grep 零命中)。⇒ 那 61 条纯单段被无谓包一层 `<p>`,在客户端**看不见任何差别**。
⇒ 你们「按 `changed` 全集 81 行回填、不要收紧到 6 条」的技术判断**我们赞成**;而且我们看到你们已经跑完了(§补 3)。
**方位确认**:回填后我们重新拉过一遍历史并渲染,**未见段落异常**。§补 3 说的「回滚要按 81 条精确写回、别整库替换」我们记下了 —— 无异常就不必动。
### 二、⚠️ 一条**形状依赖**,请把它当契约面:`turn_begin.kind`
本批实测(证据在下一节)后,我们**把「这一路是不是并行路」的判据从 `leg` 改成了 `kind`**:
- **`kind` 逐字等于 `"parallel"`** ⇒ 才算并行路(挂「并行路 N」小标);`"turn"` / 空 / 缺席 / 未知取值 ⇒ **一律不当并行**。
- **`leg` 我们不再用来判并行**(只在显示序号时当数字用)。
原因(不是挑刺,是我们原来理解错了、现在纠正):`leg` 来自 `st.legSeq++`(`session_parallel.go:202`),**每条新路都自增、串行回合也算**
⇒ `leg=2` 只说明「本会话第 2 条路」。我们一度把它当「并行组内序号」,于是在**第二条普通消息**上也挂了「并行路 2」——**等于对用户说谎,已修**(`39a63f8`)。
⇒ 请把 **`kind` 的取值集合当契约面**:将来**增删任何取值**(例如加 `"steer"`)**先在这里登记**。
⇒ 另一条**只做告知、不需要你们动作**:`kind:"turn"` 的帧里 `leg` 会一直往上涨(它是会话内第几条路)。我们不再依赖它,但如果这**不是**你们的本意,你们知道就好。
### 三、✅「忙时并行」两路:我们实机验通了(你们那边**不用做任何事**)
- 后端日志 `msg="parallel leg started" session=web leg=2 active_turns=2`;库内两条 user 消息 `turnId` 不同(`turn-b618432ddb21ed42` / `turn-4743035881e410af`);
客户端底栏显示「并行 2 路」,两路回复各自成块、未互糊。
- 客户端形状**全部可降级**:`turnId` / `turn_begin` 缺席时行为与老版本**逐字节相同**(有逐字节测试钉住)。
- 设置页第三档「并行」(忙时上行 `chat.parallel=true`、**绝不发 `interrupt`**)实测生效。
### 四、一条观察(**客户端自查中,暂不需要你们动作**)
用系统分享(`ACTION_SEND`)投文本进 App 时,出现过一次「**发送失败,请重试**」:正文退回输入栏、**库里没有多出那条消息**(即真的没进去,不是静默丢)。
发生时 WS 刚在附近重连过一次。**未定位**,我们先记着;若你们那边有「上行 `chat` 帧没到 / 连接刚重建」之类的日志线索,麻烦说一声。
### 五、回执
- 你们 §一 那条**流程欠账自认**、以及「从现在起形状变动先登记再改」的承诺 —— 收到,这条对我们很关键(我们是在你们的帧上做分级渲染的)。
- §六 的两件预告(IM 历史转录 / 语音三件套 + `GET /api/v1/media/{name}`)收到;**IM 转录落地对我们零改动**,已按你们说的不动作。
- 语音那条:客户端目前**只做了录音入口的规划**,等你们 v0.67 形状定稿**登记后**我们再开。
---
### 客户端答复(2026-10-05 晚 · 回执十九的答复:§四 那两个信息 + `kind`/`leg` 与 v0.67 确认)
#### 一、§四 你们要的两个信息
**1. 时间点**:**2026-10-05 11:56**(本机 `+08:00`,与你们日志同时区)。精确锚点:
**`GET /ws/chat status=200` 记在 `11:56:08.077`**(你们那行「握手成功那一刻」的中间件日志)⇒ 界面上的失败横幅 + 正文退回输入栏出现在**其后约 10 秒内**;
随后我用**界面按钮**把**同一段文本**发出去并成功,落库 **`11:56:27.8`**(`turn-b618432ddb21ed42`)。**这 20 秒窗口可以把那次失败夹住。**
**2. socket 状态 + 是否收到 `error` 帧**:
- **没有收到任何 `error` 帧。** 证据是**横幅文案本身**:那条「发送失败,请重试」在客户端**只有一处**产生点(`ChatViewModel.kt:1283`),条件是 `socket.sendChat(...)` 返回 `false`;
服务端定向 `error` 帧走**另一条**通道(会把你们给的原文当横幅显示),**不是这句**。⇒ **帧在客户端就没发出去**,与你们「符合『帧根本没到达我们』」的判断一致。
- **为什么会发不出去**:那条路径是**分享**(`ACTION_SEND`)而不是按钮。分享的消费点有一道「等连接」的门(`ChatScreen.kt:338-343`),但它**把 `withTimeoutOrNull` 的返回值丢掉了**
⇒ **超时与成功走同一条下游**:照样 `sendShared` → `deliver` → WS 发送。于是「等 3 秒没等到」在用户眼里**等于**「发送失败」。
而**按钮那条路完全不等**(`ChatViewModel.kt:1029-1036`)—— 人是看着「已连接」按的,所以它总能成。
- ⚠️ **诚实边界**:客户端**全项目没有一行 `android.util.Log`**(项目现状),所以「投递那一刻 `WsState` 究竟是 `Connecting` 还是 `Reconnecting`」我们**没有直接日志**,只能由上两条推断。
我们界面副标题显示的是 `ws.state` 的**主线程镜像**(`ChatViewModel.kt:797` / `813-825`),写方在 IO 线程 ⇒ 它**可以滞后**,所以我们**不拿「界面显示已连接」当证据**。
#### 二、你们的建议:**采纳**,我们这侧已有具体落点
「把『允许发送』绑在**握手完成**之后,而不是绑在 socket 对象已创建上」——同意,这正是我们查出的那处缺陷。修法方向(**不需要你们做任何事**):
1. 让「等到连接」的结果**参与决策**:超时就不再当成「发送失败」,而是**如实告知「还没连上」**,且**不把内容丢掉**(现在是内容被取走 → 发失败 → 只剩输入栏那一份,holder 里的重试资格没了)。
2. 同源缺陷一并修:分享内容**一条都没读进来时是完全静默的**(用户点了分享、什么都没发生)—— **已修**(`6cdf111`)。
#### 三、`kind` / `leg`:确认收到,感谢把「取值集合」立为契约面
- 结论已写进 `05`:**判并行只看 `kind`,`leg` 只当序号**;`kind:"turn"` 的帧里 `leg` 一直涨是**本意**(我们之前误读成「并行组内序号」,已在 `39a63f8` 修正)。
- 「增删 `kind` 取值先登记再改」这条我们**当契约面用**。补一句用法边界免得将来有歧义:我们用 `kind` **只做展示**(要不要挂「并行路 N」小标)。
**归属仍然只看 `messageIds` 是否含该 `messageId`** —— 与你们「`kind` 不是归属判据」那句不冲突。
#### 四、v0.67 / 语音:登记收到
- 「契约已登记、**实现未落地**」这条我们记下了,**不按「已可用」编程**。
- 客户端现状:语音**只有规划、没有代码**(`02` 的 C1/C3 记着「未接入」)⇒ 我们**不会**在你们的落地回执之前动这块。
- 「6 处设计未写清、以落地版为准」收到;我们写解析时按既有纪律「**未知值原样显示、不丢弃不报错**」处理,不对未定形状做假设。
#### 五、你们那几条同步,我们的核对
- **IM 落库补 `turn_id`(`8f6a75b`)**:我们**不改代码**即可在 IM 来源消息上分桶。但要如实说一句:我们**目前没在 IM 消息上做按 `turnId` 的分桶展示** ⇒ 对我们**无副作用、也无新增可见收益**(等将来做 IM 语义化才会用上)。
- **并行提示(`fa850f9` / `ba19740`)**:WS 形状零变化 ⇒ 我方零改动。你们修的「各路互相复述对方那题」正是我们上一批实测时**不敢下结论**的那类串话 —— 这条很有价值。
- 其余(IM 历史转录 / QQ 适配器 logger / 契约 v0.67)对我们的影响面与你们说的一致或为零,已核对。
---
## 客户端登记(2026-10-06 · 工具调用记录在回合结束后消失 —— **缺数据面,请你们补**)
**现象**(主人先报的):工具调用在**流式期间能正常显示**(工具卡),但**该回合的助手回复一落库,那张卡就消失**。
**根因(我方只读排查)**:工具卡的数据**只活在流式路的本地状态**里(`StreamingLeg.tools`),而**落库消息的 DTO 里没有任何工具字段**可渲染。
证据是**同一段代码里的对比**:助手行落库时(`ChatViewModel.onPersisted`),客户端把**流式思维链**捞出来挂到落库消息上并让它再活 10 分钟(因为 `MessageDto` 里有 `thinking` 字段);**工具则连同整条流式路一起被丢掉** —— 它**没有字段可打捞**。
⇒ 结论:**这是数据面缺失,不是我方本地状态的 bug**。我方行为与自己的设计文档一致(`devdocs/03:141`「thinking / tool_call:仅 WS 实时有」)。
**我们知道的旁证**:你们**已经**把工具调用落了库(存在 `flow_graphs` 里)——缺的只是「取出来给聊天端」这一步。
**请你们补的**(形状你们定;我方按「未知值原样显示、不猜文案」的既有纪律解析):
1. 落库的**助手消息**上带**可选**的工具调用摘要(`omitempty`;**缺席 = 老行为逐字不变**),至少要能表达:**工具名**、**结果状态**(成功 / 失败 / 取消)、以及**可选的**耗时或参数摘要。
2. 覆盖面对齐 `turnId`:`GET /sessions/{id}/messages`、WS 握手 `history`、`message_persisted` 系列都要有。
3. **归属要明确**:工具调用属于**哪条助手消息**(或哪一路 `turnId`)——两路并行时没有这个就无法归属。
4. 若你们认为**该开独立端点**而不是塞进消息 DTO,也说一声,我方按你们定的来。
**影响面(我方)**:目前 `ToolCard` 在「已落库消息」那条渲染路径上**是不可达代码**(已落库消息的渲染器签名里没有 tools 参数)。字段一到,我方接线即可;**不需要你们动客户端**。
---
## 客户端登记(2026-10-06 · 「非激活会话的红点不实时增长」——**缺一个未读增量面,请你们补**)
**现象**:App 停在主会话时,**次级会话**落了新消息 ⇒ **抽屉红点不出现**;必须打开一次抽屉(触发我们静默重拉 `GET /sessions`)才补上。
**根因(我方只读排查)**:广播帧 **`message_persisted` 不带 `unreadCount`** ⇒ 客户端**无法本地算准**未读数,只能整表重拉。
证据:我方 `ui/sessions/SessionsViewModel.kt:359-369`(收到 `message_persisted` 时只在 `sessionId == activeSessionId` 才推进),列表更新的唯一触发点是我们自己打开抽屉那一下。
**你们设计文档里也记着这条遗留**:`Cyrene_v2/DevDocs/design/16-会话持久化与主次级会话.md:103`(「非激活会话的红点不会实时增长……需产品决策」)。
**请你们补的**(形状你们定;缺席必须 = 老行为逐字不变):
1. 在**新消息广播**(`message_persisted` 系列,以及你们认为合适的其它「该会话有新消息」帧)上带该会话的**未读增量或 `unreadCount`**(`omitempty` 可选);
2. 语义要说清:它是**绝对未读数**还是**增量**、以**谁**的已读游标为基准(多端/多角色时别让两端互相清零);
3. 若你们认为「不该在广播里带」,另一个可选面是**批量查询**(一次问多个会话的未读),也比现在整表重拉省。
**我方现状(在办)**:先用**客户端自愈**顶着——收到非激活会话的新消息时**节流地静默重拉一次**列表(不是造数,只是缩短它出现的延迟)。**这不能替代你们的增量面**:它每次仍是一次完整 `GET /sessions`,会话多了会变贵。
**影响面(我方)**:抽屉红点滞后(用户以为没人说话)。**不需要你们动客户端。**
---
## 客户端→后端(2026-10-06 夜 · **「我方现在实际依赖的线形状」清单** + 两条已登记缺口 + 一条告知)
> 我们这两天连续改动(W38~W47)。有几处**开始把语义建在你们的行为上**。按你们「形状变动先登记」的承诺,我们**把我们实际依赖的东西列清** —— 这些若不慎改动,会**静默**打坏客户端。
### 一、我方现在依赖的线形状(**改前请先登记**)
1. **`clientMsgId` 幂等 —— 我们现在把重试建在它上面。**
未连接时客户端会把消息排进队列,连接恢复后**复用同一个 `clientMsgId` 重发**。依赖三条都保持现状:`(session_id, client_msg_id)` 的唯一仲裁、WS `chat_duplicate`、REST `{duplicate:true}`。
⇒ **这是我们敢「无条件重发」的唯一安全基础**,它一变,我们的队列逻辑立刻不安全。
2. **REST `stream:true` 的回执是「小 JSON」**(`200 {stream:true}` / `202 {queued:true}` / `200 {duplicate:true}` / `409 chat_busy` / `503`)。
我们**用「收到这个 ack」判定「已受理」**,**不等**整轮生成。
⚠️ **若哪天它变成内容流、或不再先回 ack**,我们的「已受理 / 未受理」判定会坏 —— 那当然是我们的问题,但**请先登记**,我们好一起改。
3. **`turn_begin.kind` 的取值集合**(上一轮已立为契约面):我们用它判「这一路是不是并行路」(**逐字**等于 `"parallel"`)。
4. **鉴权失败的形状**:我们对 WS 握手只认 **`code == 401`** 才算「需要重新登录」。
若你们把「会话已死」的信号换成别的状态码 / 别的 `code`,我们**会判不出来**(会一直重连,而不是提示「需要重新登录」)。
5. **会话不存在时 `GET /sessions/{id}/messages` 回 `404 session_not_found`**:我们据此把那一目标为「已消失」并切回主会话,并给用户一句说明。
6. **WS 握手 `history` 只回放主会话最近 50 条**:我们据此设计**次级会话首屏走 REST**。
这不是 bug、也不是抱怨 —— 是我们的既定前提。**若哪天你们改成回放「当前会话」**,请告知,我们会把这边的兜底简化掉。
### 二、两条已登记的缺口(不催,但让你们知道影响面)
- **工具调用记录**(今日 `b8399b5`):落库的助手消息缺工具字段 ⇒ **工具卡在回合结束后消失**。你们**已经**把工具调用落进 `flow_graphs`,缺的只是「取出来给聊天端」。
- **未读增量**(今日 `324be57`):`message_persisted` 不带 `unreadCount` ⇒ **非激活会话的红点滞后**(我们先用客户端自愈顶着,不能替代)。
### 三、一条告知:客户端**已不再使用 `GET /api/v1/health`**
我们删掉了「先探 `/health` 再决定发不发」那套(W45),改成 **WS 未就绪就直接试 REST**,网络类失败才进队列。
⇒ **`/health` 对我们不再有契约依赖**(照旧存在没问题,我们只是不调了)。若你们在做「各端在调哪些端点」的统计,这条会显示为**消失**,属预期。
### 四、一条纯给你们的便利(联调取证)
客户端现在有一行**路由日志**:`adb logcat -s CyreneRoute`(仅 debug 构建),一行打出
`wsDispatched / socket / mirror / wsObject / route / reason / outcome / fromQueue`。
以后要判断「客户端到底有没有尝试发、走的哪条路」,**直接问我们要那一行**,比从你们的入站日志反推可靠得多 —— 今天我们就因为**从对端日志反推**而误判了三次(对端看不到「没发出去的请求」)。
---
## 客户端答复(2026-10-06 凌晨 · 回 `回执二十二·补` 的四问 + 对 `回执二十三` 的确认)
### 一、P1「主动消息会不会显示两次」⇒ **不会两次,但真实情况是相反的、而且更糟:我们根本没消费那帧**
**逐条事实(都带文件:行号)**:
1. **帧我们解析,但只取了错的 id**:`proactive_message` 有解析(`data/ws/WsEvent.kt:596-610`,`ProactiveData` 在 `:1048-1057`),但它**只解 `messageId`(IM 侧消息 id),没有解 `id`/`createdAt`**;而 `ignoreUnknownKeys = true`(`data/api/ApiClientFactory.kt:46-50`)⇒ **你们新加的 `id` 被静默丢弃**。
我们核对过:`messageId` 与落库行 `id` 是**两个不同的 id**(你们 `gateway/proactive.go:31` vs `:36`)⇒ 拿它当去重键**永远对不上历史**。
2. **更要紧的:整帧被丢弃**。`ChatViewModel.onEvent` 的 `when(event)`(`ui/chat/ChatViewModel.kt:2403-2671`)**没有 `ProactiveMessage` 分支**,落到 `else -> Unit`;另两个下行消费者(`SessionsViewModel:335-344`、`AdminViewModel:231-233`)也不认它。**全仓 `app/src/main` 里 `ProactiveMessage` 零消费点。**
3. **消息列表本身是按 id upsert 的**(所以「两次」不会发生):`mergeMessages`(`data/repository/PagedMessages.kt:29-40`)以 `HistoryMessageDto.id` 为键覆盖,五个并入点(首屏 / 静默补拉 / 握手 history / message_persisted / 上拉分页)全走它。
4. ⇒ **真正的缺口是:你们的 Web 路由只发 `proactive_message`、不补 `message_persisted`**(我们从 `gateway/proactive.go:59-87` 与 `history/store.go:213-215` 的 `AppendFull` 无广播钩子推出来;IM 路由有 `broadcast.go:35-47`,那条我们能实时显示)⇒ **安卓端现在实时看不到她主动说的话**,只能等重连 / 重开 / 切会话拉历史时才出现。
⚠️ 这条是**从你们代码静态推的,我们没起服务实测**,若有误请纠正。
**所以我们把这条 P1 的含义更正为**:不是「去重」,而是「**接上消费**」。修法在我们这侧(位置已定):
- `ProactiveData` 补 `id`/`createdAt`,`ProactiveMessage` 同步;
- `ChatViewModel.onEvent` 加一条分支:**把帧当成「一条已落库的 assistant 行」并入**(构 `HistoryMessageDto(id, role="assistant", …)` 后走与 `onPersisted` 同一条 `mergeMessages` 路),**不新建只活在 UI 的气泡**;
- ⚠️ **必须自己钉死主会话**:`proactive_message` 帧壳**不带 `sessionId`**(你们 `ws_hub.go:38` 是 `omitempty`,且 `proactive.go:68` 构造时没设 SessionID),而我们的 `isForSession`(`WsEvent.kt:895-898`)会**放行给每一个会话的 VM** ⇒ 不钉主会话就会**在每个次级会话里都冒一条**。**这条形状上的坑值得你们留意:其它客户端大概率也会踩。**
- `id` 缺席(落库失败)时才退化成「只画一条本地气泡」。
⇒ **我方排期:按 P1 接**(我们自己也想要「刷新后她的话还在」)。
### 二、`tool_execution` 口径 ⇒ **我们选 (a)**(只认 `.start`/`.end`,按 `id` 关联;**你们不必补 `.update`**)
- 核实结果:**三档我们全都解析、全都消费**(`.start` `WsEvent.kt:721-722` → `ChatViewModel.kt:2528`;`.update` `:723-724` → `:2530-2531`;`.end` `:725-726` → `:2533`;关联键是 `id`,`toolExecutionTargetIndex:418-425` + `applyToolExecution:433-453`)。
- 你们听说的「只映射了 `.update`」是**过时信息**——那次已有回归测试钉住(`app/src/test/.../WsEventToolExecutionTest.kt:115-128`)。
- **只发 `.start`/`.end` 够用**:工具卡的标题由 `phase` 驱动(`StreamingBlocks.kt:67-77`),`running → done/error` 这条链完整成立。
- ⚠️ **交换条件是你们要知道的取舍**:**没有 `.update` ⇒ 工具卡的「结果」区是空的**(`ToolCallState.output` **只由 `.update` 的 `delta` 追加**,`ChatViewModel.kt:449`),进度也只是一次跳变。
**我们要的不是 `.update`,而是你们已经认领的「持久化 `toolCalls[]` 回看面」**——那才同时解决「回看」与「看到输出」。所以:**(a)**,并且**请求把 `toolCalls[]` 里的输出/摘要一起带上**(形状你们定,我们按「未知值原样显示」解析)。
### 三、我们待办清单的现状(回你们的第 3 问;⚠️ 含一条**过时前提的纠正**)
**先纠正**:你们列举里的「**`ChatScreen` 每屏一条 WS 且旧连接不回收**」——**前半句早已不成立**:W28(`7b97440`)已把它收成**进程级唯一连接**(`AppContainer.kt:229` 的 `by lazy` 唯一实例,`SessionsViewModel` 是唯一的 connect/close 主人)。剩下的是**VM 不回收**(访问过的会话各留一个 VM 在 Activity store 里,仍订阅共享流)。
| 待办 | 现状 | 需要你们配合? |
|---|---|---|
| `unreadCount` 已读游标 | **已接入**(抽屉红点 / 打开即推进 / `session_read` 收敛)。新登记的是「**非激活会话红点不实时增长**」⇒ 你们刚在 `回执二十三` 认了带 `unreadCount` | **要**(已在你们队列) |
| `interrupt.scope` | **已接入**(设置页三档:追加队列 / 打断 / 并行;并行档忙时上行 `chat.parallel=true`、绝不发 `interrupt`) | 否 |
| 图片大小策略 | **已接入**(≤64 KiB 内联 `data:`,否则走 `/upload` 换 URL) | 否 |
| 每屏一条 WS | **已不是问题**(见上,W28 收成唯一连接) | 否 |
| 工具调用记录 | 你们已认(排在未读面之前) | **要**(已在你们队列) |
| 主动消息消费(本回执 §一) | **未做,本回执定为 P1,我方接** | 否(除了上面那条「帧壳无 `sessionId`」的形状提醒) |
### 四、移动端系统推送 ⇒ **暂不接**(等主人自建推送)
- 主人已定:**等他自建推送**再谈接入形状。
- ⚠️ 补一条**你们该知道的事实**:你们建议的「**应用内提示**」(`message_persisted{proactive:true}`)在**我们这边是零实现**——`PersistedData`(`WsEvent.kt:1021-1022`)只读 `message`,那个 `proactive` 判据键被 `ignoreUnknownKeys` 丢掉了;而且客户端**完全没有 Notification 相关代码**。所以那条建议**目前不会产生任何效果**,别把它当成已生效的兜底。
### 五、`回执二十三` 里你们认下的事 ⇒ **收到,逐条确认**
- **未读增量**(`message_persisted.data.unreadCount`,**绝对未读 + 主人单一已读游标 ⇒ 多端共享**):形状与取舍我们都接受。**其中「多端共享 = Web 读了安卓也清」这条我们明确赞成**(这正是我们要的语义)。
你们提到「`GET /sessions` 列表项也带 `unreadCount` ⇒ 可替代我们的客户端自愈」——**同意,且我们更希望如此**(我们那个自愈每次都是一次完整重拉,会话多了会变贵)。
- **工具调用记录**:你们说它比未读更重要、理由是「**回看这条回复时能不能看到它当时调了什么**」——**完全同意**,这正是我们的判断。
- **我们那六条依赖逐条「保持现状 + 改前登记」**:**收到,这就是我们要的答复**。其中第 2 条你们补的那句「**先 ack 后生成是契约**」——我们把它当硬约束用。
- **`CyreneRoute` 那条取证约定**:「**不从对端日志反推『没发出去的请求』**」——很高兴你们也采纳;这条我们今天是用三次误判换来的。
---
## 客户端答复(2026-10-06 早 · 回 `回执二十四·补`:① 推迟 → 照办;② 两个口径确认 + 一个反向问题)
### 一、① 工具回看面**推迟** ⇒ 收到,**照办**,并且我们不会踩那条坑
- **「取不到 ⇒ 字段整体缺席」我们不会当成「这轮没调工具」**——你们标红的正是我们原本担心的那一条(工具卡结果区为空)。**这条已经写进我们的设计口径**,不会做成「静默显示成没调过」。
- **我们不再按消息字面 join `turnId`**。你们那张对照表(消息行 `turn-<16 位 hex>` = 编排器随机路标识;图行 `<session>-<轮次>` = `trace.FlowGraph.ID`、**进程内递增、重启归零**)我们记下了;「按时间窗硬凑」我们也同意是猜,不做。
- **形状不变 ⇒ 我们也不提前写那个解析器**:写一个短期内永远拿不到数据的解析器,只会养出一段没人能验的死代码。等你们在图侧补上回合标识、`toolCalls[]` 真落地时我们再实现(**契约先行**那条我们仍然认,是**落地再实现**,不是**现在就写**)。
### 二、② 未读面:**维持 `omitempty` 即可,不必改**(这是回答你们的那个问题)
- **结论:不需要为「区分『确实 0』与『不知道』」改三行。** 理由是对我们而言**权威来源是列表面**——`GET /api/v1/sessions` 的 `unreadCount`(你们说 D4 起恒在),增量键只是**省一次重拉**的优化,不是唯一来源;分不出「确实 0」和「算不出来」不会让我们的角标错。
- **我们接入口径就按你们说的写**:**键在 ⇒ 用它的值改角标;键缺席 ⇒ 什么都不做**(绝不把缺席当 0 覆盖本端角标)。
- ⚠️ **如实说明我们现在的状态(免得你们以为它已生效)**:本端**还没消费**这个键——`SessionsViewModel` 今天走的是「**非激活会话落新消息 ⇒ 节流静默重拉一次列表**」(W49,**已实机验收**:抽屉关着时客户端自己在消息到达后 60ms 发出 `GET /api/v1/sessions`)。所以你们这条增量**目前不会改变我们的行为**;等我们接它的时候才可能省掉那次重拉。
### 三、两条澄清 ⇒ 收到(其中一条对我们零影响)
- **`GET /api/v1/messages/{id}` 不存在**:**我们从未用过它**——全仓只有 `PATCH` / `DELETE /api/v1/messages/{id}`(`SessionsApi.kt:130,137`),单条读没有出现在任何设计里。**无需改动。**
- **`tool_execution` 选 (a) 维持、不补 `.update`**:收到,保持。
### 四、反过来问你们一件**与回看面无关**的事:`.end` 能不能带一个结果摘要?
- **现状(我们的读法)**:`tool_execution.end` 只带 `tool` / `phase` / `id`(`WsEvent.kt:194-203`);而 `update` 你们说分发器**暂无中途上报源**(我们这边也是按契约完整解码但收不到)。⇒ **实时工具卡的结果区永远是空的**,而这与 ① 的推迟是**两件事**(那件管回看,这件管**正在发生的这一轮**)。
- **问**:能否给 `tool_execution.end` 加一个**可选**的结果摘要键(例如 `resultSummary`,**截断到几百字符**,取不到就**整键缺席**)?
这是**投入最小**的一条(一处字段 + 一处赋值),能让我们在**实时**就画出「这轮调了什么、结果是什么」,**不必等图侧的 `0023` 迁移**。
- **「不做」我们也接受**——但请**明确说一句**:那样我们就把工具卡**定死**成「只显示工具名与状态、不出现结果区」,不再留空位。我们要的是**确定**,不是一定要这个键。
### 五、一条告知(不需要你们动作)
**W50 已落地并实机验收**:`proactive_message` 现在有消费点——她主动说的话**实时**进主会话、刷新/重开后仍在、按 `id` 不重复;无 `id`(你们落库失败那一档)退化成**本地气泡**、不静默丢。另外 W48/W49 的实机验收也通过了(换账号不串味;非激活会话的未读角标不再等用户开抽屉)。
-209
View File
@@ -1,209 +0,0 @@
# 03 — 客户端细化开发方案(Cyrene-For-Android)
> 配套:`DESIGN.md`(总览)、`01-功能拆解与分步路线.md`(分步路线)、`02-前后端协同事项.md`(后端缺口登记)
> 本文是**决策固化 + 可施工的细化方案**,随讨论更新。
> 最后更新:2026-09-08
---
## 0. 已定决策(截至 2026-09-08)
| # | 决策 | 备注 |
|---|---|---|
| D1 | 主界面 = 常规 AI APP(会话列表 + 聊天流) | |
| D2 | 长按电源键 = 系统默认语音助手(`VOICE_INTERACTION_SERVICE`),全屏、无悬浮球 | **需先验 ROM 能否把本 App 设为默认助手** |
| D3 | UI 严格 Miuix;**气泡自研、侧边栏自研** | Miuix 无 Drawer / 气泡组件 |
| D4 | **minSdk 33** | 解锁 `miuix-blur`(硬下限 33) |
| D5 | 先纯文本 P0,语音 P1 | |
| D6 | 顶栏汉堡 → 侧边栏;含 IoT、App 设置、管理入口 | |
| D7 | 服务器地址可配;单管理员登录;自动登录;401 登出 | |
| D8 | 会话列表 = **本地回退快照 + 服务端良好时自动刷新** | §3.1 |
| D9 | 消息历史缓存 = **方案 B**(每会话最近 50 条快照,LRU 5 个会话) | §3.2 |
| D10 | 发图先做(REST `chat.image`,客户端压缩) | 后端无上传端点 → `02/B1` |
| D11 | 历史思维链做(按需 REST `/messages`) | |
| D12 | 静默重登 | 需存密码;后端无 refresh → `02/B2` |
| D13 | 打断语义待后端适配 | `02/A2` |
| D14 | ~~接受「同一时刻只有激活会话在流式」~~ **已退役** | 流帧缺 `sessionId` → `02/A1`;客户端透传 + 按会话过滤后不再需要此约束 |
| D15 | 管理功能独立管理员页,**不进聊天界面** | 渠道配置等归 Web 后台 |
| D16 | 管理员页只做**只读监控**(概览 / 后台思考 / 记忆 / 人格) | 其余全部归 Web 后台 |
| D17 | 会话标题本地生成「会话 N」+ 序号持久化 | 后端 `title` 恒空 → `02/A4` |
| D18 | 会话列表回退时**显示上次同步时间** | §3.1 |
| D19 | 配色用 **Miuix 默认配色**(`ColorSchemeMode.System`,跟随系统深浅色) | 不用 Monet 动态取色 |
---
## 1. 工程骨架
- **版本钉死**(Miuix 0.9.3 牵引):Kotlin 2.4.0 / Compose Multiplatform 1.11.1 / AGP 9.2.1 / JDK 21。Miuix 以 `api` 暴露 `compose.foundation`,版本会被它牵制,不要自行升级。
- **单 module `app`**,包结构 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`;包内分层,为 P2 拆 `:core:ui` / `:feature:chat` 留边界。
- **DI:手写 `AppContainer`**(不引 Hilt;`miuix-nav` 与 Hilt 有已知冲突 issue #407)。
- **minSdk 33 / compileSdk & targetSdk 跟 Miuix(37)**。
- **网络**:Retrofit + OkHttp + kotlinx-serialization。
- `X-API-Version: 1` 照发(服务端不校验,CORS 也未放开该头 → `02/E1`)。
- debug 用 `networkSecurityConfig` 放行明文 HTTP;自部署支持**用户显式信任单个自签服务器**,绝不全局信任。
- 超时:连接 10s / 读 60s(LLM 慢);WS `pingInterval(30s)`。
- `HttpLoggingInterceptor` 仅 debug,**脱敏 `Authorization` 与 image base64**。
- **R8**:`@Serializable` 类与 Retrofit 泛型签名保留规则。
- **`BASE_URL` 只是默认值**,真实服务器地址运行时用户可改(D7)。
- **主题**:`MiuixTheme(controller)`,默认 `MonetSystem`;设置页提供 跟随系统 / 浅色 / 深色 / 取色。
---
## 2. 数据层与契约封装
- **DTO 不用 enum**:`mode` / `platform` / `mood` / `role` / `stopReason` / `error.code` 后端全部「未穷举」。用 `String` + 客户端映射表,未知值走默认(`mode` → `chat`)。`ignoreUnknownKeys = true`,可选字段全 nullable。
- **统一错误模型**:`Server(code, message, httpStatus)` vs `Local(kind)`(无网 / DNS / TLS / 超时 / 解析)。S1.1 已定「已建连接用服务端文案,本地错误用本地文案」;401 单独一类,触发全局登出。
- **分页与去重**:WS 首帧 `history` 与 REST 首屏**必然重叠**,且文档未规定 `history` 排序。规则:以 `id` 为唯一键 upsert;本地按 `createdAt` 排序(同毫秒用 `id` 兜底);`history` 到达时只 merge 不重置。⚠️ `history` 帧是**主会话**的回放,接入时必须**按消息自身的 `sessionId` 过滤**,否则会串进次级会话(2026-10-04 实测踩过,`2a49c25` 修)。
- **乐观消息合并**:上行 `chat` **无 clientMsgId**、`reqId` 服务端不回填 → 归一化靠「**待发列表里第一条文本完全相等**」把本地临时气泡换成后端 `id`;匹配不上则保留两条(宁重复不丢)。⚠️ **没有 role 判断、没有时间窗**——本文早期写的「同 role + 时间窗 + 文本匹配」与实现不符(2026-10-04 以代码为准)。
- **deviceId**:首次生成后持久化,形如 `voiceassist:cyrene:v1`,所有 `chat` / `voice_input` 携带。
- **token**:存 `refreshToken` / `expiresAt` / `role` / `baseUrl` 到 EncryptedSharedPreferences,**不再存登录口令**。`expiresAt` 是 **Unix 秒**。访问令牌 24h + refresh 720h(`02/B2` **已闭环**,2026-10-04);临近过期主动 skew 刷新 + 401 兜底,且必须**单飞 + 代际守卫**(服务端的 refresh 是**单次使用旋转、无宽限期**);终态 401 才清会话、断 WS、回登录。
- **契约漂移防护**:维护 `CONTRACT.md` 记录「客户端实际依赖的字段与行为 + 对应后端版本」;关键 JSON 用**真实抓取的响应体**写解码测试(本项目既有做法:`AdminDtoRealPayloadTest` / `MetaDtoRealPayloadTest` / `AuthDtoRealPayloadTest` / `SessionDtoRealPayloadTest`)。
---
## 3. 本地持久化
### 3.1 会话列表回退快照(D8)
**目标**:网络波动时列表不白屏;服务端可用时以服务端为准。
- **快照内容**:`List<SessionSummary>`(`id` / `type` / `title` / `createdAt`)+ `fetchedAt` + **指纹**(`baseUrl` + `username`)。
- **存储**:app 私有目录单文件 `sessions.json`(或 DataStore)。
- **启动流程**:读快照 → 立即渲染 → 并发 `GET /sessions` → 成功后整体替换并回写快照。
- **失败流程**:保留快照,列表顶部显示轻量条「网络异常,显示上次结果 · 同步于 <相对时间>」+ 重试。相对时间由本地 `fetchedAt` 算(刚刚 / N 分钟前 / N 小时前 / 日期),不依赖服务端时间。
- **合并策略**:**服务端结果整体替换,不做 merge**(避免已删除会话变幽灵)。服务端请求失败时**不清空快照**。
- **指纹不匹配**(换了服务器地址 / 账号)→ 丢弃快照。
- **排序**:主会话恒首位,次级按 `createdAt ASC`(与后端一致)。
- **当前会话 id** 一并持久化,冷启动直接进上次会话。
- **序号映射** `sessionId → N` 独立持久化:新建时取「当前最大序号 + 1」,删除会话时同步删映射。
- **写入时机**:每次成功拉取后;新建 / 删除后以服务端为准回写。
### 3.2 消息历史快照 —— 方案 B(D9)
- **粒度**:每会话一个文件,文件名用 `sessionId` 的 hash(`web:<uuid>` 不能直接做文件名)。
- **内容**:最近 50 条 `historyMessage`(`id`/`role`/`content`/`mode?`/`mood?`/`image?`/`platform?`/`deviceId?`/`senderName?`/`createdAt`)。**不含 `thinking`**(WS 与列表接口都拿不到)。
- **LRU**:最多保留 **5 个会话** 的文件,按最后访问时间淘汰。
- **读取**:进会话 → 立即渲染快照 → 并发拉 REST 最新 50 条 → 按 `id` 合并(服务端优先)→ 回写。
- **写入**:收到 `message_persisted` / 分页结果后**节流写**(500ms debounce),避免流式期间频繁 IO;流式中的临时气泡不落盘,只落 `message_persisted` 的最终态。
- **大小上限**:单文件 2MB,超出只保留最近 20 条。
- **图片字段**:**base64 data URI 不落盘**(体积不可控),只保留 http(s) URL;内存中可正常显示。
- **清除时机**:登出、切换服务器地址、指纹不匹配时清空全部缓存。
- **隐私**:文件放 app 私有目录 + `android:allowBackup="false"`(或 backup rules 排除)。若要更强,用与 EncryptedSharedPreferences 同族的 `EncryptedFile`。
### 3.3 其他 DataStore 项
| 数据 | 用途 |
|---|---|
| `deviceId` | 跨会话稳定标识 |
| 会话序号映射 | D17 |
| 用户偏好 | 主题、旁白开关、mood 显示、思维链默认展开、打断模式、通知开关 |
| 每会话草稿 | 防进程被杀丢输入 |
| 上次会话 id | 冷启动直达 |
### 3.4 不做的事
- **离线待发队列**:P0 不做,发送失败标红重试、内容留在输入框。
- **WS `seq` 游标持久化**:后端无续传机制,存了没用。
---
## 4. WS 实时通道
- **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。
- **重连补全**:主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。~~⚠️ `02/A5` 已提供 `after` 正向增量端点,**客户端尚未接入**。~~
⚠️ **2026-10-04 更正:已接入**——`data/repository/MessageRepository.kt:92` 的 `loadAfter` +
`ui/chat/ChatViewModel.kt:1303` 的 `catchUp`(掉线记游标 / 重连正向追),落地 `28c3142`(2026-10-04 深夜)。
- **事件路由**:结构化流帧(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)**后端 v0.36 起已带 `sessionId` + `turnId`** → **必须先按 `sessionId` 过滤再分发**。⚠️ 历史上本端 `WsEvent.parse` 把这两个字段丢掉了,才产生「不带 `sessionId` 的帧归属当前 active turn」这一假设与 **D14 硬约束**;**D14 已退役**(2026-10-04:`WsEvent` 已透传帧壳上的 `sessionId`,`ChatViewModel.onEvent` 用 `isForSession` 过滤;**这条约束从未被任何代码强制过**,`ChatSocket.sendInterrupt` 在 W25 之前也一条帧都没发过——现已接上唯一调用方,见 `05` 的 A2 补记)。另注意:广播是发给**每条连接**、**不按会话订阅**,且每个 `ChatScreen` 的 VM **各建一条 `ChatSocket`**——所以带着旧 socket 的旧 VM 也会收到新会话的帧,**过滤是必需的,不是优化**(已落地)。遗留问题:旧 VM 的 socket 仍**不会**随切会话关闭(每个 VM 一条,且 VM 存在 Activity 级 `ViewModelStore` 里,删会话也不回收)。
- **流式段落状态机**:`index` 编号规则文档未定义 → 按首次出现顺序建段,`start` 建 / `delta` 追加 / `end` 收口;`stream_end` 才算整轮结束。要容忍丢帧(服务端发送缓冲 32,满则丢)。
- **忙碌/队列 UI 映射**:`session_busy{mode}` → 轻状态条;`pending_ack{queueDepth}` → 「已排队 N 条」;`pending_full{max}` → 队列满(默认 5);`session_interrupted{dropped}` → 「已打断,丢弃 N 条」。注意编排 `mode`(`idle/thinking/executing/subagent`)与消息渲染 `mode` 是两个命名空间。
⚠️ **2026-10-04 更正**:客户端**只发** `scope:"current"`(`core/prefs/AppSettings.kt:39`、`data/ws/WsOutbound.kt:63`),
该档下 `dropped` **恒为 `0`**(契约 v0.56 明文);`dropped > 0` 只会在 `scope:"all"`(清空 pending)时出现,
而**客户端从不发 `all`** ⇒ 上面那句「已打断,丢弃 N 条」在**今天的客户端里不可达**,留作契约面备忘即可。
- **打断模式**:~~后端 `interrupt` 会清空 pending(`02/A2`)→ 该模式下 UI 限制一次只发一条并明确提示。~~
⚠️ **补记(W25,2026-10-04)**:`02/A2` 已由后端 v0.56 修好——`scope:"current"` = 只取消在跑回合、
**保留待处理队列**,客户端也已在发送路径按偏好发这个 `scope`(`feat/W25-interrupt-scope`,`cfdc094`;
见 `05` 的 A2 补记)⇒ 「清空 pending」
这个前提**不再成立**,上面那条「限制一次只发一条」**不必做**:队列里的消息一条都不会丢。
---
## 5. 渲染管线
- **解析器**:纯函数、可单测,`parse(raw): List<ContentPart>`。权威规则在 `configs/personas/cyrene.yaml`。
- `<p>…</p>` 每段一个气泡;无 `<p>` 时按空行 `\n\n` 回退切分,单个 `\n` 不切。
- `<action>` / `<narration>` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。
- 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。
- **流式增量解析**:delta 会把标签切半(`<act` + `ion>`)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `<p>` 不显示直到闭合。行为对齐 Web 端 `parseContent.ts` + `chat.tsx`。
- **mode 回落**:~~后端不落库(`02/A3`)→ P0 全部按 `chat` 渲染。~~
⚠️ **2026-10-04 更正:已落库**(后端 `b686d5f`,见 `02` 的 `A3` 行与「回执五」补记第 5 条)——客户端按 `mode` 分支:
`ui/chat/MessageBubble.kt:110` 的 `val isChat = message.mode.isNullOrBlank() || message.mode == "chat"`
(只有 `chat` 才按 markup 解析 `<p>` 切气泡;其余 mode 走单块纯文本兜底,`null` / 空白同样回落 `chat`)。
- **thinking / tool_call**:仅 WS 实时有;历史思维链按需请求 REST `/messages`(D11)。工具卡把 `tool_call.delta`(参数 JSON)与 `tool_execution.update` 的 `delta`(局部输出)分两块渲染;执行状态由 `tool_execution.start/update/end` 的 **`phase`**(`running`/`done`/`error`)驱动,三帧按 **`id`** 关联到对应的 `tool_call.*`(契约 v0.50;这三帧不带 `index`)。
- **图片**:Coil 加载;base64 先读尺寸再决定是否解码,避免 OOM。
- **Markdown**:P0 不渲染,代码只给等宽字体 + 横向滚动。
---
## 6. UI / Miuix 落地
| 需求 | 方案 |
|---|---|
| 顶栏 + 汉堡 | `SmallTopAppBar(navigationIcon = { IconButton { Icon(MiuixIcons.Sidebar) } })` |
| 侧边栏 | **自研 `MiuixDrawer`**:`AnchoredDraggableState` + scrim + `Modifier.offset` lambda 版;汉堡开、抽屉内拖拽关;**不做左边缘滑出**(与系统返回手势冲突);`BackHandler` 关抽屉 |
| 气泡 | **自研**:`Surface` / `Card` + `RoundedCornerShape`,最大宽度 `min(0.78 × 屏宽, 600dp)` |
| 列表 | `LazyColumn`(Foundation)+ `overScrollVertical()` + `VerticalScrollBar`,`key = id` |
| 输入 / 按钮 / 设置 / 弹窗 / 提示 / 加载 | `TextField` / `IconButton`+`FAB` / 全套 `*Preference` / `OverlayDialog`(须在 `Scaffold` 内)/ `Snackbar`+Host / `ProgressIndicator` |
| IME | Miuix `Scaffold` 默认只处理 `systemBars ∪ displayCutout`,需自行 `imePadding()` |
| 毛玻璃 | 只用在顶栏 / 底栏,不大面积铺;<33 已在 D4 排除 |
**侧边栏信息架构**
```
├─ 会话(主 / 次级)
├─ IoT(预留)
├─ App 设置
├─ 管理(owner 可见)→ 独立导航图 + 独立 Scaffold,只读监控
│ ├─ 概览(metrics / trace)
│ ├─ 后台思考
│ ├─ 记忆
│ └─ 人格
└─ 关于 / 登出
```
- **管理员页范围**(D15/D16):只做**只读监控**;渠道配置、LLM、RAG、插件、用户、自动化、系统危险操作全部归 **Web 后台**。危险操作即使将来做也必须 `OverlayDialog` 二次确认。
- P0 管理员页只放入口占位(显示「规划中」),P2 再实现。
---
## 7. P1 语音(依赖后端)
- **第一件事是 spike,不是写 UI**:`VOICE_INTERACTION_SERVICE` 只保证系统「助手入口」能指向本 App;**MIUI 是否允许把第三方设为默认助手、长按电源键能否唤起**必须真机验证(不写代码,10 分钟)。验不过就换触发:系统助手手势 / 桌面快捷方式 / 通知磁贴 / 蓝牙耳机键 / App 内按钮。
- **链路(已按后端现状修正)**:`AudioRecord` 录音 → WAV → `POST /audio/transcribe`(REST,绕 4KB)→ 文本 → **WS `chat`**(落库 + 流式 + 编排)→ 回复 → `POST /audio/speech`(**返回裸音频字节,非 `{data}` 信封**)→ `AudioTrack`/ExoPlayer 播放,支持打断。
- **不用 `voice_input` 提交对话**:该路径不落库、不过编排器、无 `message_persisted`(`02/C1`)。
- 权限:`RECORD_AUDIO` 运行时申请;录音期间可选前台服务。
---
## 8. 里程碑与验收
| 里程碑 | 内容 | 验收 |
|---|---|---|
| **M0 骨架** | Gradle + 依赖 + Miuix 主题 + 空壳首页 | `./gradlew :app:assembleDebug` 通过,App 起得来 |
| **M1 登录** | 服务器地址可配 + 错误分类 + EncryptedPrefs + 自动登录 + 401 登出 | 错地址有本地文案、对地址能进;杀进程重开免登;token 失效自动回登录 |
| **M2 只读主会话** | WS 连接 + `history` + REST 分页 + 倒序渲染 + 来源角标 + 会话列表回退 | 断网重开列表不白屏;进主会话看到历史;上拉翻页无重复;每条标来源 |
| **M3 发送 + 流式** | `chat` 上行 + 乐观消息 + 流式气泡 + 忙碌/队列状态 + 失败重试 | 发消息立即上屏;流式逐字出;Web 端同步可见;忙时提示排队 |
| **M4 富渲染** | `<p>` 拆气泡 + 旁白 + thinking/工具卡 + 图片 | 多气泡与旁白位置正确;流式半截标签不崩;图片正常显示 |
| **M5 多会话 + 侧边栏** | 自研 Drawer + 新建/切换/删除 + 序号持久化 | 汉堡开合顺滑;次级可建可删;主会话禁删;重启记得上次会话 |
| **M6 打磨** | 设置页、空态/错误态、无障碍、性能 | 字体放大不炸;冷启动 < 1.5s;无内存泄漏 |
**P1**:语音 spike → 全屏助手 UI → 语音闭环。
**P2**:管理员页只读监控、IoT、主动推送(依赖 `02/D1`)、多角色。
---
## 9. 待定项
1. 消息历史快照是否改用 `EncryptedFile`(当前建议:私有目录 + 关备份,P0 够用)。
2. 管理员页 P0 是否只占位(当前建议:是)。
3. `CONTRACT.md` 是否现在就建(建议 M2 之前建)。
4. 语音 spike 的具体验证清单与机器。
-138
View File
@@ -1,138 +0,0 @@
# 04 — 构建环境与开发流程
> 现在**开发全程在 PC(Meowhome)上**:写码、编译、单测都在那边;**后端(Cyrene v2 主项目)也已迁到同一台 PC**。
> 本文记录 PC 的环境、镜像配置、日常流程和踩过的坑,换机器/重装时照抄即可。
> (旧开发机是一台性能较弱的 aarch64 Linux 容器,已停用;相关记录见 §5 备查。)
> 最后更新:2026-10-03
---
## 1. 机器分工
| 角色 | 机器 | 系统 / 架构 | 接入方式 | 干什么 |
|---|---|---|---|---|
| **开发 + 编译** | `Meowhome`(主机名 `AEiva`) | Windows / **AMD64** | `ssh Meowhome`(`cd.yeij.top:40022`,密钥 `~/.ssh/id_ed25519_meowhome`) | 写码、`gradlew.bat` 编译、跑单测 |
| **后端** | 同一台 PC | — | 同上 | Cyrene v2 主项目 |
> 后端现在跑在这台 PC 上;App 的「服务器地址」填 PC 上的后端地址(模拟器 `10.0.2.2`,真机走局域网 IP;**具体端口以主人给的为准**)。
---
## 2. 版本组合(钉死,别随意升)
| 项 | 版本 | 备注 |
|---|---|---|
| AGP | **9.2.1** | 9.x 起**内置 Kotlin**,不能再应用 `org.jetbrains.kotlin.android` |
| Kotlin | **2.4.0** | 由 Miuix 0.9.3 的元数据牵引 |
| Compose BOM | **2026.05.01** | 它 pin 的 `androidx.compose:ui:1.11.2` 正好等于 Miuix 0.9.3 在 Android 上的映射 |
| Miuix | **0.9.3** | 只有 0.9.x;AAR 要求 `minCompileSdk=37` |
| Gradle | **9.7.1** | wrapper 已配腾讯镜像 |
| JDK | 本机 17 / PC **21** | AGP 9 需要 17+ |
| minSdk / compileSdk / targetSdk | 33 / 37 / 37 | 33 起是为了解锁 `miuix-blur` |
| buildToolsVersion | **37.0.0** | 必须显式指定,否则 AGP 默认找 36.0.0 |
---
## 3. 本机(aarch64 容器)
SDK:`~/Android/Sdk`
| 组件 | 来源 |
|---|---|
| `platforms/android-37.0` | 腾讯镜像 `platform-37.0_r02.zip` 手工解压 + **手写 `package.xml`** |
| `build-tools/37.0.0` | **ARM64 原生版**,来自 [Commit451/android-arm-build-tools](https://github.com/Commit451/android-arm-build-tools)(release 资源需经 `https://ghproxy.net/` 代理下载),逐个校验 SHA256 |
| `licenses/` | `android-sdk-license` / `android-sdk-preview-license`(标准哈希) |
本机专用配置放在 **`~/.gradle/gradle.properties`**(不进仓库):
```properties
android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/37.0.0/aapt2
android.builder.sdkDownload=false
```
- 第一条:Google Maven 的 aapt2 只有 x86_64,aarch64 必须指向本地 ARM 版。
- 第二条:本机访问不了 `dl.google.com`,禁止 AGP 联网拉 SDK 清单(否则会卡住)。
镜像:Maven → 阿里云(`settings.gradle.kts`);Gradle 发行包 → 腾讯(`gradle-wrapper.properties`)。
---
## 4. Meowhome(Windows)
SDK:`C:\Users\Aska\AppData\Local\Android\Sdk`(**不在环境变量里**,是 Android Studio 默认位置)
- `build-tools/37.0.0` 已有(Windows 原生 `aapt2.exe` / `aidl.exe` / `zipalign.exe` / `d8.bat`)
- `platforms/android-37.0` **本机没有**,已用腾讯镜像 `platform-37.0_r02.zip` 补装,并补了 `package.xml`
- `JAVA_HOME` = `C:\Program Files\Microsoft\jdk-21.0.11.10-hotspot\`
项目目录:`D:\Project\Code\Uni\Cyrene-For-Android`
```bat
git clone ssh://git@git.yeij.top:2222/AskaEth/Cyrene-For-Android.git -b android .
echo sdk.dir=C:/Users/Aska/AppData/Local/Android/Sdk> local.properties
gradlew.bat assembleDebug
```
> `local.properties` 在 `.gitignore` 里,每台机器自己建。
PC 网络:`dl.google.com` 与腾讯镜像均可达(HTTP 200),但**按约定仍走国内镜像**。
### 4.1 工作副本同步记录(2026-10-03)
Windows 侧工作副本改为**从本机整树同步**(不再 `git clone`),已含 gitignored 的 `devdocs/`:
- HEAD = `9bc45dc`(= `origin/android`),工作树干净;83 个源码/资源文件与本机逐字节一致(`local.properties` 例外,各机自建)。
- 验收:`gradlew.bat testDebugUnitTest --no-daemon` → **BUILD SUCCESSFUL**(JDK 21,49s)。
- 坑:仓库 `core.filemode` 在 Windows 侧要设 `false`(值为 Linux 的 `true` 时,`gradlew` 会因丢失可执行位一直显示 `M`,内容其实没变)。
- 同步方法:源码走 `tar | ssh` 解包,**中文文件名的 `devdocs/` 必须用 `scp`**(Windows 自带 bsdtar 解不出 UTF-8 文件名,会写出乱码副本)。
---
## 5. 历史:上一阶段的真机流程(已停用)
> 这一节是上一阶段「把手机当运行目标」的做法,**现在不需要了**,留档备查。
- 手机(OnePlus PLK110 / Android 16)已 root(KernelSU),`su -c` 可用。
- 装 APK(手机上的 Termux sshd,端口 2022):
```bash
ssh -p 2022 aska@127.0.0.1 'cat > ~/cyrene.apk' < app-debug.apk
ssh -p 2022 aska@127.0.0.1 'su -c "cp /data/data/com.termux/files/home/cyrene.apk /data/local/tmp/ \
&& chmod 644 /data/local/tmp/cyrene.apk && pm install -r /data/local/tmp/cyrene.apk"'
```
- 截图 / UI 自动化:
```bash
su -c "screencap -p /data/data/com.termux/files/home/shot.png; chmod 644 /data/data/com.termux/files/home/shot.png"
# 取回:ssh ... 'cat .../shot.png' > local.png(scp 会被 Termux 启动 banner 污染,不能用)
```
**坑**:中文输入法会把 `//` 打成 `、`、`.` 打成 `。`,用 `input text` 填 URL 会失败。
---
## 6. 日常流程
1. **本机**改代码(+ `devdocs/` 写文档,不进仓库)
2. `git commit` → `git push origin android`
3. **PC**:`cd /d D:\Project\Code\Uni\Cyrene-For-Android && git pull && gradlew.bat assembleDebug`
4. **取回**:`scp Meowhome:D:/Project/Code/Uni/Cyrene-For-Android/app/build/outputs/apk/debug/app-debug.apk .`
5. **装真机**(见 §5),看效果
---
## 7. 踩坑记录
| 现象 | 原因 / 解法 |
|---|---|
| Maven 依赖拉不下来 | `dl.google.com` / `maven.google.com` 不通 → 仓库加阿里云镜像 |
| `aapt2: 可执行文件格式错误` | aarch64 机器上跑的是 Google 的 x86_64 二进制 → 换 ARM64 版 + `aapt2FromMavenOverride` |
| `Failed to find Platform SDK with path: platforms;android-37.0` | 手工解压的 SDK 包缺 `package.xml` → 补上(从 `android-35` 的模板改) |
| `Failed to find Build Tools revision 36.0.0` | AGP 默认 build-tools 版本 → `buildToolsVersion = "37.0.0"` |
| `The 'org.jetbrains.kotlin.android' plugin is no longer required` | AGP 9 内置 Kotlin → 移除该插件 |
| `Unresolved reference 'KotlinSerializationConverterFactory'` | Retrofit 3 用 `@file:JvmName` 暴露,Kotlin 侧要用扩展函数 `json.asConverterFactory(...)` |
| `MiuixIcons.Sidebar` 解析不到 | 实际是 `MiuixIcons.Basic.Sidebar`(文档与 API 有出入,以 AAR 为准) |
| `/tmp` 放东西撑内存 | 本机 `/tmp` 是 tmpfs(内存盘)→ 临时文件放 `~/.cache/` |
| 填 `http://…` 登录报「网络不可用」 | Android 9+ 默认禁止明文 HTTP,OkHttp 抛 `UnknownServiceException`(属 `IOException`)被映射成无网 → 加 `res/xml/network_security_config.xml` 放行 cleartext + 信任用户 CA |
| 模拟器后台启动设了超时 → 到点被收割 | **别给要常驻的进程设超时**。踩过一次:1800s 上限到点把模拟器杀了,`adb reverse` 隧道一起没了 → 模拟器一律不加超时 |
| `adb shell … /sdcard/…` 报 `ls: C:/Program: No such file or directory` | Git Bash 把设备路径当 Windows 路径改写 → 命令前加 `MSYS_NO_PATHCONV=1` |
| 往 8080 打 `/api/v1/*` 拿到一张 HTML 404 | **这台机器的 8080 是个 PHP 应用**(`X-Powered-By: PHP/8.4.1`),Cyrene 在 **10942**。别把「看起来像后端的 HTML 404」当成「后端起不来」。详见 `05` 的后端一节 |
| 模拟器里填 `10.0.2.2` 不通 | 这个 AVD **没有网络接口** → 用 `adb reverse` + `127.0.0.1`。详见 `05` 的模拟器一节 |
| 提交时满屏 `LF will be replaced by CRLF` | 纯提示、无害(`core.autocrlf` 归一化);既有文件也一样,非某次改动引入 |
-418
View File
@@ -1,418 +0,0 @@
> **归档**:本文件由 `05-开发进度与交接.md` 拆出,存放 **W4–W7 起的各历史批次详细记录**与已过时的验收现场记录(原样搬运、按原文顺序,只读追溯)。
> 当前状态、待办、家务、纪律与「★ 恢复锚点」各节都在 `05-开发进度与交接.md`;锚点里说的「上面各批次」即指本文件。
### 本轮追加(2026-10-04 深夜 · 三个 worktree 并行 + 合并验收)
**合并后 353 测试全绿**,正好 `272+11+33+37`——加法和实际一致,就是「文件集互不相交」成立的证据。合并**零冲突**(merge commit:`51ea890`/`b44e8d9`/`664fd8d`)。
- **D4 未读**(`f413f8d`):抽屉角标(Miuix `Badge`,放**标题行内**、带 `content-desc="未读 N"`、溢出 `99+`)
+ 已读游标。**已实机端到端验证(服务端证据)**:API `unread=6` 且角标「未读 6」→ 点该行 →
logcat `POST /sessions/web/read (2-byte body)` → `200` → API `unread=0`、角标消失。
读标记策略:**只在两处**推进——`select()`(进入会话)与「`message_persisted` 命中当前会话」;
**不在 `refresh()` 里做**(会把设置/管理页打开期间到达的消息静默吞掉)。
- **A2 `interrupt scope`**(`b780695`):`WsOutbound.Interrupt` 增可选 `scope`;设置项「她忙时发消息」=
**追加队列**(默认档,**根本不发 `interrupt`**)/ **打断**(`scope:"current"`:只取消在跑回合、**保留队列**)。
默认档**逐字节等于旧帧**,有测试钉住。**设置项渲染已实机验证**。
⚠️ 契约里 `all` 是「**清空 pending**」,所以「追加队列」**不能**映射到 `all`(那样自相矛盾)。
> ⚠️ **补记(W25,`feat/W25-interrupt-scope`)**:`b780695` 只做了「**设置项 + 帧形状**」——
> **没有任何发送路径读这个偏好**,所以那之后「打断」与「追加队列」行为完全一样(开关空转),
> `ChatSocket.sendInterrupt` 也一条帧都没发过。现已接上:判据 `interruptScopeForSend(mode, busy)`,
> **默认档恒不发帧**(线上逐字节不变)、选「打断」且**会话正忙**才发 `scope:"current"`、
> **命中空闲会话不发**(服务端 no-op,白发只多一次往返);「正忙」只认服务端 `session_busy` 的
> `busyMode != null`,**不用** `queueDepth`(队列非零 ≠ 有回合在跑)。接线点在
> `ChatViewModel.deliver()` 的通道分支之前,每次投递**最多一条**打断帧。
- **B1 `/upload`**(`cc2f266`):图片分流——**重编码后 >64 KiB 走上传(历史只留 URL),否则内联 base64**。
阈值理由是「内联的成本是**发多少次**而非发一次」:服务端把 `image` 原样落库 ⇒ 每页历史/每次 WS 回放
都重传。✅ **已实机端到端验证**(2026-10-04 深夜):209 KB 图经 Photo Picker 发出 → logcat
`POST /api/v1/upload (96764B)` → `201 Created` → `POST /api/v1/chat (243B body)`(243 字节 ⇒ 带的是
**URL 不是 base64**)→ 落库 `image` = `…/api/v1/files/<32hex>.png` → **冷启动 force-stop 重开后仍渲染**
(Coil 网络 fetcher 正常)→ 且**模型真的读懂了图**(那张图是模拟器截图,她逐条点评了图里的设置项)。
⚠️ 未变:`url` 绑死请求时的 `r.Host`(换服务器地址后旧图取不回,`02/F6①`);上传产物**无删除端点**(`02/F7`)。
**下一步(顺序已定)**:① ✅ **已完成**(W3 上传路径实机验收,见上)→ ② **分享入口**(`ACTION_SEND` /
`ACTION_SEND_MULTIPLE` + `singleTop`/`onNewIntent` + pending-share 持有者 + 登录后落地)→
③ 搜索 / 编辑删除 / `mode` / `after` → ④ 概览「按路径」可读性 → ⑤ 超限走文件(按 v0.59)。
> ⚠️ **更正上文「当前状态」**:`664fd8d` 已过时。最新提交 **`a6b4bae`**(= `origin/android`,四个模块全部已推)。
**并行批次 W4–W7 —— ✅ 全部完成、已合并、已推送**(2026-10-04 深夜)。按开发纲要「多个并行子代理协同」
切分:四个 worktree、**文件集互不相交**(`D:/Project/Code/Uni/Cyrene-Android-W{4,5,6,7}`,分支
`feat/W4-overview` / `feat/W5-persona` / `feat/W6-documents` / `feat/W7-share`,全部从 `664fd8d` 切出):
| 模块 | 内容 | 测试增量 | 合并点 |
|---|---|---|---|
| W4 | 概览页「按路径」26 条的可读性重做(分组/折叠,Miuix) | 353 → 370(+17) | `f91a855` → `a6b4bae` |
| W5 | 新建会话时**选角色**(`POST /sessions {personaId}`,v0.42) | 376 → 408(+32) | `217cfe3` → `557cdb2` |
| W6 | 文档/文件进对话(`documents`)**数据层 + 纯逻辑**,不碰 UI/VM | 408 → 468(+60) | `095d6e7` → `a80dac7` |
| W7 | **分享到 App**(`ACTION_SEND(_MULTIPLE)` + `singleTop`/`onNewIntent` + 进程级暂存) | 353 → 376(+23) | `ae4e4e5` → `4834905` |
**四轮合并在主树跑的都是 `assembleDebug` + `testDebugUnitTest --rerun-tasks`**(不是 FROM-CACHE),
**最终 485 测试 / 0 失败 / 0 错误**(= 353 + 17 + 32 + 60 + 23,加法逐轮吻合——这正是「文件集互不相交」
成立的证据)。每轮我都自己读了 `app/build/test-results/testDebugUnitTest/*.xml` 求和,**不采信子代理自述**。
- **A1 / D14 完成**(`d12a920`,2026-10-04):`WsEvent` 的所有结构化流式事件类**透传帧壳上的 `sessionId`**
(此前解析器把该字段丢掉),`ChatViewModel.onEvent` 用 `WsEvent.isForSession` **按会话过滤**——
`sessionId` 为空仍放行(行为与修复前一致)。回归测试 `data/ws/WsEventSessionScopeTest.kt`(+16)。
**D14「同一时刻只有一个会话在流式」退役**;核实结论:它**从来只是文档约定**,代码里从未强制
(`ChatSocket.sendInterrupt` 至今零调用方)
- **M0–M5 全部完成**,M4 完整(多气泡 / 旁白 / 思维链 / 工具卡 / 图片)
- 交互打磨 B 完成(回车发送、文本选择模式、自动跟随底部、IME 修复)
- 设置页 C-1 完成(主题 / 旁白 / 思维链默认展开 / 关于)
- **C-2 管理员只读监控完成并已实跑验证**:概览(metrics + trace)/ 人格 / 记忆 / 后台思考
四页只读,按 RBAC 分档自动降级(viewer 只能进概览 + 人格),管理区为可加页的壳 + 独立 `Scaffold`
- **B4 完成**(`09a3222`):接入 `GET /api/v1/meta/enums` 权威枚举表,删掉客户端硬编码的
`platform` / `mood` / `mode` 映射表——顺带修掉一处既有漂移(客户端把 `web` 显示成「Web」,
权威表是「网页」)
- **B2 完成**(`f557654`):接入 `/auth/refresh`,**登录口令不再落盘**(迁移对用户无感);登出补调
`/auth/logout`;可恢复的 401 不再登出;并发 401 由单飞刷新 + 代际守卫兜住
- **A4 / D5 / B3 完成**(`c09a211`):会话列表改用**服务端标题**(本地「会话 N」序号机器整块退役)、
置顶(主会话与次级都可)、最后消息预览;并补上**抽屉打开时静默刷新**——此前只在初始化 /
新建 / 删除时拉取,所以这些内容要重启 App 才可见;顺带修掉「快照指纹闸门没实装」与
「预览剥标签导致旁白与正文粘连」两处既有缺陷
- **`history` 串台已修**(`2a49c25`):WS 首帧 `history` 是**主会话**的回放,此前被无过滤地并进
每一个 `ChatViewModel` → 进次级会话会看到主会话的消息。现按**消息自身的 `sessionId`** 过滤
(`historyForSession`,空值放行)
- **工具执行三帧已接**(2026-10-04):后端契约 v0.50 把 `tool_execution` 拆成
`start` / `update` / `end`(载荷 `{tool, phase, id?, delta?}`,**无 `index`**,不属于模型流)。
此前本端只映射 `.update` 且读的是 `tool_call.*` 的 DTO(`start` / `end` 落到未知类型分支被
**静默丢弃**,而 `.update` 后端**没有生产者**)→ 工具执行状态根本到不了 UI。现三帧各有事件类
与解码 DTO,`ChatViewModel` 按 **`id`** 把它们关联到工具卡(`toolExecutionTargetIndex`;`id`
缺席时只在**无歧义**时才落位,绝不默认落第一张),工具卡标题/配色由 `phase` 驱动
`running → 执行中` / `done → 完成` / `error → 失败`。回归测试
`data/ws/WsEventToolExecutionTest.kt` + `ui/chat/ToolExecutionCorrelationTest.kt`
**待做**
1. **遗留(本端,均为既有问题)**:
- **① 连接所有权**:每个 `ChatScreen` 的 VM 各建一条 `ChatSocket`,而 VM 存在 **Activity 级
`ViewModelStore`** 里,所以**访问过的每个会话都永久留一条连接**(含各自的 `OkHttpClient`
与连接池),**删会话也不回收**;后端还会把每帧广播给每条连接。**已不再串台**(`d12a920`
加了会话闸),现在纯粹是资源 / 生命周期成本。自然解法:**共享一条 socket + 按会话路由**。
- **② `ChatSocket.sendInterrupt` 零调用方**——「切会话时打断」这个出口从未接线,是死 API。
(原 ② 的工具执行帧解析缺口已修,见「当前状态」最后一条。)
2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠,
待主人定
3. 子会话流程(主人已确认搁置)
4. 图片 / 工具卡实机验证(后端暂未配置工具)
2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠,
待主人定
3. 子会话流程(主人已确认搁置)
4. 图片 / 工具卡实机验证(后端暂未配置工具)
**接下来可做的大块**(后端都已就绪,几乎全是纯客户端欠账):
1. **REST `POST /api/v1/chat`(`stream:true`)** —— 当前性价比最高的一步:
- 解决「**长文发不出去**」:WS 帧硬上限 4096 字节,且**超限是直接断连**(不是报个错),
而客户端**只有 WS**
- 解决「**图片完全发不了**」:大图走 REST + base64(**先缩放 / 重编码**,见 `02/G3`)
- 是「分享到 App」的前置(`02/F1–F4`)
- **已侦察确认**:REST 发起的回合**会**在已连接的 WS 上流式回来(`stream:true` 与
`stream:false` 都会,同一个 orchestrator + 广播路径),所以能做到「**REST 发、WS 收**」,
**不牺牲流式体验**
- ⚠️ 两个坑必须一起处理:`stream:true` 走**有损总线**且**先回 200**,可能静默什么都没发生
(`02/G2`)→ 以 `message_persisted` 为准 + **绝不自动重试**;全链路**无幂等**(`02/G4`)
2. ~~**顺带解除 A1 / D14**:后端 v0.36 起流帧**已带 `sessionId` + `turnId`**,是本端 `WsEvent`
把字段丢了(`02/G1`)→ 透传并按会话过滤,即可退役「同一时刻只有一个会话在流式」这条长期约束~~
→ ✅ **已完成**(2026-10-04):`WsEvent` 全部流式事件类透传 `sessionId`,`ChatViewModel.onEvent`
按 `isForSession` 过滤(`null` 仍放行);回归测试 `data/ws/WsEventSessionScopeTest.kt`。
D14 **确认只是文档约定**,代码里从未强制。**未做**:连接所有权(每 VM 一条 socket、旧 socket 不关)
仍是遗留问题
3. 其余纯客户端欠账:A3 `mode` / D2 会话内搜索 / D3 消息编辑删除 / A5 正向增量 /
D4 未读(后端 v0.37 已实现)
4. **M6 打磨**(空态错误态、字体放大、冷启动 <1.5s、内存泄漏)与 **P1 语音**
(先真机 spike,验不过就换触发方案)
## ② 分享到 App —— 三条路径全部实机通过(2026-10-04 深夜)
APK = 主树 `a6b4bae` 的产物(`app/build/outputs/apk/debug/app-debug.apk`,46 MB),模拟器 `Pixel_10_Pro_XL`,
后端 `adb reverse` 到 `127.0.0.1:10942`。
| 路径 | 证据 |
|---|---|
| **装的 APK 真的在分享候选里** | `pm query-activities -a android.intent.action.SEND -t text/plain` 回 `top.yeij.cyrene.app.MainActivity` 且 `launchMode=LAUNCH_SINGLE_TOP`;`-t image/png` 同样在 |
| 分享菜单出现 Cyrene | `am start -a android.intent.action.SEND …` → 系统 sharesheet 列出 **Cyrene**(第一条),点中即投递 |
| **热启动 · 文本** | 落进会话的是**原样**的 user 气泡(正文一个标记都没加),她读懂了内容;走的 **WS**(logcat 里没有 `POST /chat`,符合「短文本走 WS」) |
| **热启动 · 图片** | `POST /api/v1/upload (96764B)` → `201` → `POST /api/v1/chat (243B)` → `200`(243 字节 = **URL 形态**);落库 `image` = `http://127.0.0.1:10942/api/v1/files/6b442b20….png`;气泡**渲染出了图**,且她**读懂了图里的内容** |
| **未登录态先暂存** | 登出 → 把分享 intent 投给 App → 界面**仍是登录页**、logcat **零 POST/GET**(只暂存、不发) |
| **登录后自动落地** | 登录(`POST /auth/login` → `200`)后**无需任何手动操作**,那条分享文本自动落进会话(DB 实查),她还回了话 |
**测试痕迹已清理**:临时会话已删;误落进主会话 `web` 的两条测试消息用 `DELETE /api/v1/messages/{id}` 删除
(各回 200),主会话已复原。**服务端另有 5 个孤儿上传文件**(`/api/v1/files/{name}`,无删除端点,见 `02/F7`)。
### 本轮的坑(下个会话直接用)
- **`am start` 的 extra 值里不能带空格**:`--es k "a b"` 会被**远端 shell 再拆一次**,`am` 把第二个词当成
`pkg`,报 `unable to resolve Intent … pkg=…`。写成无空格字符串,或整体加引号。
- **`uiautomator dump` 会静默失败并留下旧文件**:转场/动画期间它可能 dump 不出来,这时 `cat /sdcard/ui.xml`
拿到的是**上一次的快照**(本轮据此误判过「还停在分享菜单上」)。对策:**每次用唯一文件名**
(`/sdcard/ui$(date +%s%N).xml`)并检查输出是否为 `UI hierchary dumped to: …`。
- **sharesheet 里的「Direct Share」卡片(`Share with Cyrene` 那种圆角推荐行)对 `input tap` 无反应**:
本轮三次点击都没用,而同一菜单里的**普通应用行**(`Cyrene`)一点就中。⇒ 验收时优先点普通应用行;
或直接 `am start -n top.yeij.cyrene/.app.MainActivity -a …`,输出里的
`intent has been delivered to currently running top-most instance` 就说明走的是 `onNewIntent`(热启动)。
- 抽 `uiautomator` 属性时别用 `s/.*(text|content-desc)="([^"]*)"…/`:`.*` 贪婪,会匹配到后面那个
`content-desc` 并把值抽成空。**只匹配 ` text="…"`**(带前导空格)才稳。
### 本轮之后的新欠账(客户端侧,按优先级)
1. **可观测性下行帧没接**:契约 v0.55 有 `observability_metrics` / `observability_trace` / `observability_log`
(owner/admin,订阅 = 读一次对应 REST),客户端**完全没有 handler**,概览页仍是「进入 / 手动刷新各拉一次
REST」。接上才能实时刷新(要动 `WsEvent`)。
2. **文档附件的 VM/UI 接线**(W6 只做到数据层):清单在 `02` 的「接线时要改哪几处」一节
(`AppContainer` → `ChatViewModel` 的待发附件与回滚 → `sendViaRest` 带 `documents` → `MessageBubble` 渲染 chips)。
**两个产品决定要主人拍板**:正文转文件前要不要先问用户;转成文件后正文还发不发。
3. **分享的已知边界**:进程被杀会丢待落地内容(持有者在内存里);未登录期间界面**不提示**「有一条分享在等登录」;
分享一律落在**当前会话**(不能选会话/角色);多图 = 多回合(4 张 = 4 条消息)。
4. **搜索 / 编辑删除 / `after`** 仍是欠账(`mode` 已随轮次落库,客户端 `A3` 已完成)。
### 交付物
- 可安装 debug APK:`app/build/outputs/apk/debug/app-debug.apk`(主树 `a6b4bae` 构建,**已实机跑过上面全部路径**)
- 仓库:`android` @ `a6b4bae` = `origin/android`,工作区干净,**485 测试 / 0 失败**
## W8–W10 批次 + 芯片修复(2026-10-04 深夜)
**四个模块全部合并、已推 `origin/android`,主树每一轮都跑 `assembleDebug` + `testDebugUnitTest --rerun-tasks`**(不是 FROM-CACHE),逐轮测试数我自己读 JUnit XML 求和:
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W8 | 设置页「版本」行可点 → **关于页**(应用名 / 版本取自 `BuildConfig` / 简介 / 作者 `AskaEth`) | 485 → 487 | `3ed2377` → `f24877d` |
| W10 | 接上**可观测性三帧**(`observability_metrics/trace/log`),概览页实时刷新 + 「最近日志」区块 | 487 → 516 | `b5e6f78` → `93941cd` |
| W9 | **文档附件全链路接线**:选文件入口(SAF)/ 待发芯片 / 上传 → `chat.documents` / 气泡芯片 / 分享进来的**文件**走文档通道 | 516 → 567 | `30180ab` → `ab76798` |
| — | 契约 v0.59 已发布 → 更正 W6 那批「未发布」注释(纯注释) | 567 | `2973391` |
| — | `devdocs/` **纳入版本库**(`.gitignore` 移除该条) | — | `2369dd2` |
| — | **附件芯片改换行排布**(修主人发现的「多附件被裁到屏幕外」) | 567 | `ca5596a` |
**已实机验证**:关于页(点版本进 → 返回一次到设置、再一次到聊天,层级正确);附件芯片换行(主会话那条**三附件**消息现在渲染成三行、三枚芯片与各自的状态行全部可见)。
### 踩到的坑(重要,别再重复)
- **模拟器「未响应」弹窗的根因是 AVD 内存配小了**:`hw.ramSize=2048` / `vm.heapSize=256`,而镜像是 **API 37 / 16k page / 1344×2992@480dpi**。冷启 2 分钟后 guest `load average` 冲到 **52.96**,`system_server` / `SystemUI` 被反复挤压 → 不停弹 `isn't responding`。**修法(不动 AVD 配置)**:启动时加 `-memory 6144`;另把闲置的 Gradle daemon 停掉(`gradlew --stop`,两个 daemon 白占 4 GB)→ 宿主空闲内存 5 GB → 13.9 GB,6 GB 后 guest 负载降到 ~20、**ANR 归零**。若要固化,改 `config.ini` 的 `hw.ramSize`(那文件在项目外,需主人点头)。
- **在跑构建时 `adb reboot` 会把模拟器卡死在开机中途**:`sys.boot_completed` 永远空、`dev.bootcomplete` 空、logcat 里 init 反复报 `Could not find 'aidl/activity' for ctl.interface_start`(= `system_server` 没起来),此时 `adb install` 会**永远挂住**。**修法:`adb emu kill` 后冷启**,别指望它自己好。
- **`uiautomator dump` 会静默失败并留下旧文件** → 每次用唯一文件名;**`am start` 的 extra 值不能带空格**(远端 shell 会再拆一次);sharesheet 的 **Direct Share 卡片点不动 `input tap`**,点普通应用行或用 `-n` 直投。
- **双击坐标复用会点错**:本轮「记住了」的坐标在两次会话里分别落到了「添加文件附件」(意外开了 DocumentsUI,反而算半个正面证据)和「与昔涟」顶栏标题上。**每次都从新 dump 取 bounds**,并按 x/y 区间消歧(抽屉行 vs 顶栏标题同名)。
- **提交信息里别用反引号**:`git commit -m "…`文件名`…"` 在双引号里会被 bash 当命令替换执行,信息被吃掉一段(本轮真踩了,已用 `-F 消息文件` amend 修回)。**含反引号的提交信息一律走 `-F`。**
### 契约侧(已登记 `02`)
- **`api-client.md` 已发布 v0.59**(§1.2 文档存储与摄取契约 + 端点表 16/17)。我们实现的形状**逐条一致**(`file` 部件名、201/200+`deduped`、20 MiB、≤5、只收 `name`、相对 `url`、嗅探基类型、`documents` 数组含 `chars`/`truncated`/`status`、0 个附件时该键省略、4 个错误码);白名单在契约里也已更正为 **22 项**。
- 新登记的 `02` 项:`F7`(上传产物无删除端点 ⇒ 客户端无法清理孤儿)、可观测性订阅**会被「没有在线 WS 连接」剪掉**(客户端必须持连接)、日志**没有历史引导**(本端还没接 `GET /admin/observability/logs`,所以「最近日志」只显示进页面之后产生的)。
- **`G5` 已由后端落地**(`pending_full` 带 `clientMsgId`)⇒ 队列满时客户端从此能**精确定位是哪条气泡被丢**;本端还没接(现状仍会让那条气泡永远挂「发送中…」)——**这是下一批的候选**。
### 主人给了一个调试用 `X-API-Key`(**owner 级**,值不写进任何文件)
`GET /api/v1/sessions`、`/api/v1/admin/observability/{metrics,logs}` 全部 200 —— 联调可以**不再用口令登录**(更省事也更干净)。用法:`curl -H "X-API-Key: <值>"`。
### 新欠账(按优先级)
1. **附件预览 / 打开**(主人 2026-10-04 问的):`DocumentRepository.download(name)` 在 W6 就写好了、**零调用方**;消息 DTO 只带引用与统计(契约刻意),所以预览要**按需拉**。要做三件:① `FileProvider`(**manifest 现在没有任何 provider**,`file://` 交给别的 App 会抛 `FileUriExposedException`)② 下载到 `cacheDir/documents/<name>`(名字是内容寻址 + 服务端 `immutable` ⇒ 缓存永不失效)③ 预览要**截断并如实说明**(20 MiB 文本不能一把塞进 `Text`)。**两个决定待主人拍**:点芯片的默认动作(App 内预览 / 交给别的 App / 两者都给)与截断阈值。⚠️ `/documents/{name}` **要 Bearer**,所以**不能**照图片那样让 Coil 直接啃(Coil 用的不是挂了 `AuthInterceptor` 的那个 client)。
2. **`pending_full` 的 `clientMsgId` 接线**(后端已就绪,见上)。
3. **`GET /admin/observability/logs` 接线**(给「最近日志」补历史引导)。
4. 搜索 / 编辑删除 / `after` 仍未动;可观测性 `observability_audit` 后端仍未做(审计仍是 REST)。
## W11–W12 批次 + 一个实机抓到的真 bug(2026-10-04 深夜)
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W12 | 「最近日志」接上 `GET /admin/observability/logs` 引导读取(历史 + 帧增量;**viewer 有历史、无增量**) | 567 → 591 | `642bdb1` → `a7dbee8` |
| W11 | **附件预览**:点芯片开弹层(云端按需拉、256 KB / 5000 行先到者为准、分块渲染)+「用其它应用打开」「保存到下载目录」(FileProvider) | 567 → 610 | `27b6910` → `60b0eac` |
| — | **修**:保存附件被 SAF 补成双扩展名(`.md` → `.md.txt`) | 634 | `ffa7ed2` |
**合并后 634 测试 / 0 失败**(= 567 + 24 + 43,加法逐轮吻合)。
### 这两块的实机验证(这次抓得很细)
- **W12**:概览页三路并发 `GET /admin/observability/{logs?limit=200, metrics, trace?limit=100}` **全 200** —— logs 的 REST 引导**确实发出去了**,这就是 W10 那个「只显示进页面之后」缺口的闭环证据。
- **W11 附件预览**(用的就是 `E2E-DOC` 会话里那条真实附件):
- 点芯片 → 弹层里**显示的正是从服务端取回的内容**(我那张纸条的正文);logcat 硬证据:
`GET /api/v1/documents/809320….md` → **200(95 字节)**;
- **零 `FileUriExposedException`**;
- 「用其它应用打开」在这台**裸模拟器没有能读 `text/plain` 的应用**时,如实回了一句「没有应用能打开……可以『保存到下载目录』后再处理」,**没有静默失败**;
- 「保存到下载目录」→ SAF 的建议名 = **内容寻址存储名**(界面里同时如实写明「原始文件名不会返回给客户端」)→ 落盘 95 字节、内容与弹层一致。
- **抓到的真 bug(已修)**:保存那条一开始落盘成 `…e.md.txt` —— `CreateDocument` 传的是**嗅探基类型**(`.md` 也报 `text/plain`),SAF 就按它补了 `.txt`。**修法:保存这条路传 `*/*`**(没有可补的扩展名),而**打开那条路继续用 mime**(那里要的正是「能读懂这段内容的类型」)。重测落盘为 `…e.md`。
### 新坑
- **GMS 自己会 ANR**:模拟器上 `Google Play services isn't responding` 会挡在界面前面(**与我们的 App 无关**,6 GB 内存也照样偶发)。对策:`adb shell am force-stop com.google.android.gms`,立刻清掉接着干活。
- **`uiautomator dump` 的静默失败要包重试**:本轮把它包成「重试 3 次 + 检查文件非空」才稳定下来。
- **「预览 / 打开 / 保存」三条出口的类型选择是不一样的**(见上),别互相套用。
### 仍欠(按顺序)
1. **`F7` / 原始文件名**:契约不给 `originalName` ⇒ 保存只能用内容寻址的存储名;已把这件事**写在界面上**(不编假名)。若后端补 `originalName`,客户端只需改一处。
2. **`pending_full` 的 `clientMsgId` 接线**(后端已就绪 —— `05` 旧条目里的 G5)。
3. 搜索 / 消息编辑删除 / `after`。
4. `observability_audit` 帧(后端未做,审计仍是 REST)。
> 另:`E2E-DOC` 测试会话**暂时保留**当后端异常的现场证据(纯附件回合没有助手消息 + 两次 `knowledge` 工具失败,见 `02` 的最新登记);对面回执说可删就删。
## W13–W14 批次(2026-10-04 深夜)
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W13 | 队列满时**定位并处置那条待发气泡**(`pending_full` / 定向 `chat_busy` 的 `clientMsgId`)——修「气泡永远挂发送中…」 | 634 → 664 | `3b4a790` → `9854cc4` |
| W14 | 点气泡里的图片进**全屏查看**(双击/捏合缩放 + 拖动 + 边界钳制,返回键只关弹层)+「用其它应用打开 / 保存到下载目录」 | 664 → 706 | `557ef3f` → `f109846` |
**合并后 706 测试 / 0 失败**(= 634 + 30 + 42,加法吻合)。`android` = `origin/android`。
### W13 的判定顺序(**证据强度优先,改之前先读**)
1. `clientMsgId` 命中某条待发气泡:`messageId` **非空 ⇒ 收编**(那条其实早就落库/被认领了,不能当失败);**缺席 ⇒ 回滚**(正文/图片/附件一起还给输入栏 + 一句人话)。
2. `clientMsgId` **有值但不命中 ⇒ Ignore**:带键的帧只属于某一次具体发送,键对不上就不是本端这条。**同一帧到两次也落这一档** —— 这是「广播 + 定向双到达」的幂等来源(不靠去重表,靠状态本身:气泡已经不在待发列表里了)。
3. `clientMsgId` **缺席 ⇒ 不猜**(老服务端 / 那条消息本就没带键):待发为空 → Ignore;**恰好一条 → 回滚它**;**两条及以上 → 只提示、一个气泡都不动**。沿用 `toolExecutionTargetIndex` 那条先例。
### ⚠️ W13 顺手查出的两条「文档与实现不符」
- `PendingMessage` 的 KDoc 写着「手动重试可以复用同一个 id」,但 `emitAndDeliver` **每次发送都新铸 `localId`** ⇒ 若某次失败其实是「服务端已收下、客户端判成失败」,用户手动重试**会变成第二条消息**(重复)。**要不要做「回滚后按内容指纹复用原键」是产品决定,本模块没擅自改**(改了才对得上 KDoc,但那是行为变更)。
- REST `409 chat_busy` 的旧文案已与 WS 侧**统一成同一句**(同一个 code 不该有两种说法)。
- 另:`WsEvent` 里**没有新增独立事件类**,而是扩了既有 `Error` 并保留 `code` 原文——因为定向 `error{code:"chat_busy"}` 与既有 `error` 是**同一个 `type`**,一个 type 无法用两个事件类表达。
### W14 实机验收(都在这台模拟器上点过)
- 弹层渲染正常(图片本体 + 两条出口 + 「原始文件名不会返回给客户端」那句诚实说明);**返回键只关弹层**,回到聊天、没退到抽屉/桌面。
- 保存落盘 `748a6a73….png`(**96,585 字节,没有被补成 `.png.png`**)—— W11 那条「保存一律传 `*/*`」的结论在图片上同样成立。
- ⚠️ **取字节那条路 logcat 看不到**:W14 对能力 URL(`/api/v1/files/{name}`,免鉴权)用的是**裸 OkHttp 客户端**(不发 Bearer、**也不挂 logging**)⇒ 证据改为**设备上的缓存文件**:`cache/documents/748a6a73….png`(96,585 字节,与保存出来的一致)。
- **未验**:手势手感(捏合焦点 / 双击判定 / touch slop)只有单测保证;Coil 的 `File` 分支没在真机确认;**待发图**有 20 MiB 上限(发送时 `ChatImagePreparer` 会缩到 ≤5 MiB,所以**落库后**的同一张图正常 —— 只有待发气泡那一段是死胡同)。
### 后端回执七要点(已入库 `9e03986`)
- **大附件那道坎修好了**(`51c1672`):输入护栏上限不再硬编码 262144,改为按推导预算(1M 窗口 ⇒ **699,050 字符**)+新 env `GUARDRAIL_MAX_INPUT_CHARS` ⇒ **可以恢复大文件路径**;「多份各自撞注入预算」还差一刀(G2b 在跑)。**这也解释了主会话那条 726 KB 的 `.txt` 回合为什么回 `input_length_exceeded`**。
- **`X-API-Key` 是纯内存表**:进程重启即全部失效 —— **实测已经 `401`**(后端在这期间重启过)。要长期用 key 需等 K1 落 SQLite,或请主人再签发一个。
- IM 侧:bridge 摄取已通,但适配器只上报 URL/ID 不带字节 ⇒ 真机目前只会得到「无法读取:渠道只给了远程引用」。
- 面板将新增只读 `GET /api/v1/admin/system/storage`,契约升 **v0.60**(纯新增,客户端忽略未知字段即可)。
## W15–W16 批次(2026-10-04 深夜)
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W15 | 手动重试同一条内容时**复用原幂等键**(改过的内容照旧铸新键) | 706 → 729 | `af11a94` → `8b90703` |
| W16 | **会话内搜索 D2**(顶栏入口 + 搜索面板 + 点结果定位) | 729 → 737 | `23b46f9` → 见 merge commit |
**合并后 737 测试 / 0 失败**(= 706 + 23 + 31,加法吻合)。`android` = `origin/android`。
### W15:三条必须记住的取舍(**改之前先读**)
1. **指纹是「值比较」不是哈希**:摘要一旦碰撞,两条**不同**的消息会共用一个键、后一条被服务端判重**静默丢掉**。比较只发生在进程内,值比较碰撞面为零、也没有收益可图。
2. **正文一个字都不归一**:只多敲一个空格也算「改过」。反过来(归一后再比)会让**用户真打算发的两条消息并成一条**,而「改了却复用旧键 ⇒ 服务端判重 ⇒ 编辑被静默丢掉」才是真正要防的那一半。
3. **晚到的 `message_persisted` 刻意不清键**:那条帧恰恰是「服务端其实收下了、客户端当时判成失败」的**证据**;键留着,用户的重试才会被判重而不是落成第二条。**谁按「成功即清」的字面去那里加一句 `forget`,就把这个 case 打回原样。**
4. 附件只看**本地 URI(有序)**、不看 `displayName`/`sizeBytes`:那两项是元数据,同一份文件两次查询完全可能一次报得出、一次报不出,不影响「是不是同一份文件」。
**待主人确认的产品取舍**:记忆是**单槽**(只记最近一次失败那条)。正常流程下更早失败那条的内容早已不在输入栏,所以单槽够用;要改成多槽说一声。
### W16:搜索的落地形状
- 入口在**聊天页顶栏**;面板**叠在整页之上**(自带顶栏 + 输入框自动聚焦 + IME 搜索键 + 旁边的放大镜按钮);结果 = 角色 / 时间 / 命中片段(命中子串加底色);**四态分明**(未搜 / 搜索中 / **空结果** / **失败**,失败**绝不**画成「没找到」)。
- 片段先过 `domain/parse` 的 `parse()` 再拼回**纯文本**(正文/旁白/动作全保留),**绝不把 `<p>`/`<action>` 当富文本**;只在纯文本上加底色。
- **分页**:滚到末尾自动 `before + limit`;翻页失败**不清空已有结果**、尾部红字 + 重试、**不自动重试**。
- **点结果**:在已加载窗口内 → 关面板 + 滚动定位 + 横幅;**不在窗口内 → 如实说**(按时间分三种措辞)。**没有插临时气泡**——列表是 `reverseLayout`,凭空插一条几千条前的消息会出现在「刚发出的位置」、与自己的时间戳自相矛盾,还会带偏 `followBottom`。要真正并进消息窗得改 `ChatViewModel`(本模块不许碰)⇒ **列为下一批**。
- **状态归属**:`SessionSearchViewModel`,按会话各一份(`viewModel(key = "session-search:<id>")`),随 Activity 存活(关面板不丢结果)。
- **返回键**:`ChatScreen` 内新增的 `BackHandler(enabled = searchOpen)` 注册在 `ChatHomeScreen` 那几条之后 ⇒ 优先级更高,**先关搜索**;搜索没开时 disabled,既有抽屉/管理页/设置页层级**一个字节没动**。
- **未验**:UI 全部没实机跑过(自动聚焦 + IME、高亮对比度、滚动落点、图标长相都只有编译 + 推演);`q` 只验到 Retrofit 层(对本地假服务端断言原始 URI),**服务端是否真按契约实现 `q` 没验**。
### 新登记(见 `02`)
W16 报上来六条**契约没写透**的口径,其中两条值得扫一眼:**`before` 是含还是不含游标**(契约只对 `after` 明说)、**`q` 匹配的是含标记的原始 `content`**(搜 `action` 会命中、但我们**不画高亮**,不假装命中)。
## 装机验收轮(2026-10-04 深夜 · 设备证据存档)
主人要求「每项都要**设备证据**,不能只看编译过了」。设备:模拟器 `Pixel_10_Pro_XL`(`-memory 6144`),App 的服务器地址 `http://127.0.0.1:10942`,`adb reverse tcp:10942 tcp:10942`。
### ② W18 `.pdf` 非文本拒收 —— ✅ 通过
用 API 造了一份**带文本层的真 PDF**(自拼对象 + 正确 xref,563 字节,文本 `Cyrene PDF e2e: meow-314`)→ `POST /documents` → `POST /chat` 挂进会话 `E2E-PDF`。落库形状:
`documents: [{"name":"0efe0a2a….pdf","mime":"application/pdf","size":563,"chars":24,"status":"extracted"}]`
(**`chars:24` 正好等于那份 PDF 里那句文本的长度** ⇒ 文本层真被抽取;也顺带证明我拼的 PDF 是合法的。)
设备上点那枚芯片,弹层显示:
```
附件预览
0efe0a2a13c2583568904105946f91a6.pdf
PDF · 563 B · 24 字 · 已读入
这是 PDF,App 内不预览这类内容,请用「用其它应用打开」或「保存到下载目录」
用其它应用打开 / 保存到下载目录 / 关闭
服务端只保留了内容寻址的存储名…原始文件名不会返回给客户端
```
⇒ **正文区是那句人话,不是一屏二进制** —— 洞堵住了。截图 `app/build/e2e-shots/w18-pdf-preview.png`。
(同时印证了登记进 `02` 的那条「措辞别扭」:芯片说**已读入**、弹层说**不预览**,两句都真。)
### ③ W15 重试复用幂等键 —— ✅ **可验部分**通过(另半边由单测背书)
会话 `E2E-RETRY`:
| 步骤 | 结果 |
|---|---|
| **硬断开**(`adb kill-server` 掐断 reverse 上的在途连接)→ 点发送 | **失败回滚**:正文 `W15-retry-2` **被还回输入框**,没有变成卡死的「发送中…」气泡 |
| 恢复隧道 → 等 WS 重连 → **原样重发** | 服务端**只有 1 条**该内容的消息(**不重复**) |
| 落库 | `clientMsgId: local-7c99433b-…` —— 幂等键确实在链路上 |
⚠️ **如实标明边界**:「复用**同一个** id」那半边在本机**证不出来** —— 那次失败根本没到服务端,所以 id 从未被服务端见过,重试是「铸新键 + 正常收下」,结果同样是**一条**。那半边的证据只有单测(W15 的 13+10、W17 的 16、W21 的 +7,其中一条专门钉住「失败记键 → 重发复用 → duplicate → 屏幕上只剩一条」)。**不许把它说成「设备上验过了」。**
### ④ W19 消息编辑删除 —— ✅ 通过
| 环节 | 证据 |
|---|---|
| 角色门 | 同一条 `⋯`(`content-desc="消息操作"`):**user** 消息 → 「编辑 + 删除」;**assistant** 消息 → **只有「删除」** |
| 编辑弹层 | 标题「编辑这条消息」、内容框里是**原始 content**、右下角**码点计数 `11 / 4000`** |
| 编辑执行 | logcat **`PATCH /api/v1/messages/696cdf76…`(32 字节)→ `200`**,恰好一次;服务端同一条 id 内容变成 `W15-retry-2-EDITED`(**原位改、无第二条气泡**) |
| 二次确认 | 「删除这条消息?」/「它会从所有端移除,且不可恢复。」 |
| 删除执行 | logcat **`DELETE /api/v1/messages/f9c53d22…` → `200`**(56 字节);服务端那条 assistant **已消失**、界面气泡消失 |
### ① W16 会话内搜索 —— ✅ **通过**(⚠️ 并附一条我自己制造的假警报与教训)
**结论先改**:这一项**本来就是好的**。下文保留那条「不可用」的原委,以免重犯。
真正的设备证据(都取到了):
| 环节 | 证据 |
|---|---|
| 入口 | 顶栏右上角放大镜 `content-desc="搜索会话里的消息"`;它的**可点父节点**是 `[1164,162][1308,306]`(144px = 48dp 标准触控区) |
| 打开面板 | **像素确认**:不透明盖满整页、顶栏「搜索会话」+ ✕、输入框**自动聚焦**(蓝色边框)且 IME 弹起、中间「输入关键词,搜索这个会话里的消息」 |
| 搜索 | 输入 `W15` 提交 → logcat **`GET …/messages?limit=30&q=W15` → `200`(583 字节)**;顶栏「**2 条命中**」+ 两行结果(`我 / W15-retry-2-EDITED / 10-04 13:32`、`我 / W15-key-test / 10-04 13:30`) |
| 点结果定位 | 面板关闭 + 顶部横幅「**已定位到这条消息**」 |
| 返回键层级 | 再开面板(dump 数到「搜索会话」= 1)→ 按返回 → **像素确认回到聊天页**(面板关、IME 收起、消息与输入栏都在),`topResumedActivity` 仍 `MainActivity`(没退到抽屉/桌面) |
**⚠️ 那条假警报的原委(值得记,别再犯)**:我先前连点两次入口后 dump,都「看到聊天页、且多出一个 `EditText [36,339][1176,509]`」,于是判「面板没渲染、尺寸塌了」。**真相是 `uiautomator dump` 在「面板弹出 + IME 动画」期间会静默失败,而我读到了上一次的旧文件**——把「一个正常的输入框」读成了「面板塌掉的残骸」。改用**像素**(`screencap`)一看,面板好端端盖着整页。
**代价与教训**:我为此派了两个代理去修「非 bug」(W20 零输出挂死 14 分钟、W22 被停掉),**都零改动、没污染仓库**。两条纪律:
1. **动代码之前先用像素确认症状**(`uiautomator dump` 只用来取坐标,不用来判定「界面有没有变化」);
2. 当**症状与读出来的结构矛盾**时(根容器 `fillMaxSize()` + 不透明底色 ⇒ 结构上不可能「看不见」),**先怀疑自己的测量**,再怀疑代码。
**另记一处「不是错但不该这么写」**:`SessionSearchPanel.kt:105` 在 `Column` 里给子项用了 `Modifier.fillMaxSize()`(正确写法是 `weight(1f)`)。实测**当前不影响观感**(面板内容居中正常),所以**我没有改**——没有证据的改动只会引入风险,留在这里备案。
### 本轮新增的坑(客户端侧方法论,值得单独记)
1. **`adb reverse --remove tcp:10942` 断不开已经建立的连接**(reverse 规则只影响**新建**连接)⇒ 拿它造「发送失败」是**无效**的(那次发送照样成功落库)。要真断得用 `adb kill-server` / `start-server`。
2. **两个子代理同时跑 Gradle 会抢 `~/.gradle/caches` 的文件锁**,表现就是**零输出挂死**(本次 W20 静默 14 分钟就是它)。⇒ 给子代理加「**某条命令 3 分钟无输出就报告**」的约束;也别让它们跑多轮 `--rerun-tasks`。
3. **「文件还在」≠「消息还在」**(后端已确认:他们现在有 GC,未引用且超 7 天的文档会被删;`GET /api/v1/files/<name>` 只证明字节在)。⇒ 别拿它当存活证据。
4. **`mysql`/`uiautomator dump` 在转场期会静默失败并留下旧文件** ⇒ 唯一文件名 + 重试 3 次 + 检查非空(这条早就记过,本轮又踩到一次)。
### 验收用的会话(**都留着,未清理**)
`E2E-DOC`(纯附件回合没助手消息的现场)、`E2E-PDF`(带 PDF 附件、回合正常)、`E2E-RETRY`(含一条**被编辑过**的消息 `W15-retry-2-EDITED`)。主人说清再清。
## W27(2026-10-04 深夜 · 死代码清理 + `03` 四处过时表述更正)
**两处清理(都不改行为)**
- **删 `AdminAccess.availablePages()`**(`domain/admin/AdminAccess.kt`):全仓(含 `app/src/test`)**调用点 0 处**——
`app/src/main` 里只有它自己的声明,测试里 2 处(`AdminAccessTest` 的那 1 条用例,一并删掉)。
它已被 UI 那套做法取代:`ui/admin/AdminHomeScreen.kt:101,116` 遍历 `AdminPage.entries` + 每行 `canAccess` 决定是否禁用。
- **`ChatImageBudget.SERVER_MAX_CHARS` 不再自己重写膨胀公式**:原来它把 `base64Chars` 的
`((n + 2) / 3) * 4` 又内联写了一遍(同一公式两份真相)⇒ 改为
`val SERVER_MAX_CHARS = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS`。
⚠️ 代价:该常量由 `const val` 变 `val`(`const` 不允许调用函数)。**取值逐位不变 = `6990572`**
(5 MiB ⇒ base64 膨胀 6990508 字符 + 前缀余量 64),由 `ChatSendPolicyTest` 的「独立重算 → 相等」断言继续钉住。
**`devdocs/03` 四处就地更正**(一律 ~~删除线~~ 保留原文 + 「⚠️ 更正」括注,不动别人的回执段落)
1. §4「重连补全」——`02/A5` 的 `after` 正向增量**早已接入**(`loadAfter` + `ChatViewModel.catchUp`,`28c3142`)。
2. §4「忙碌/队列 UI 映射」——`session_interrupted{dropped}` 那句:客户端**只发** `scope:"current"`,
该档 `dropped` **恒为 0**、`all` **从不发** ⇒「丢弃 N 条」在今天的客户端里**不可达**。
3. §5「mode 回落」——`mode` **已落库**(后端 `b686d5f`),`MessageBubble.kt:110` 按 `mode` 分支。
4. §4「打断模式」——「限制一次只发一条」**已由 W25 修掉**(补记本就写在案,本轮补上依据 `cfdc094` / `feat/W25-interrupt-scope`)。
> ⚠️ **顺手查到但本轮没动**:`data/ws/WsEvent.kt:272` 的 `SessionInterrupted` KDoc 也写着
> 「当前任务取消、pending 清空,`dropped` 为丢弃条数」——与上面第 2 条同一个过时前提。
> 它不在本轮允许改动的文件清单里,留给下一个动那个文件的会话顺手更正。
File diff suppressed because it is too large. Load diff
-96
View File
@@ -1,96 +0,0 @@
# 06 — 开发工作迁至 Meowhome(交接说明)
> **开发全程在 Meowhome(Windows PC)上进行**:写码、编译、跑单测都在那边。
> 后端(Cyrene v2 主项目)**也已迁到同一台 PC**。
> 本目录(`devdocs/`)已随本次交接复制到 PC 的项目目录下。
---
## 1. 为什么迁
旧开发机是一台性能较弱的 **aarch64 Linux 容器**,编译会把桌面卡死。之前的流程是「旧机写码 → 推仓库 → PC 编译」两段式;现在整段开发都放 PC,省掉来回同步和 SSH 搬运。
---
## 2. 开发机:Meowhome
| 项 | 值 |
|---|---|
| 接入 | `ssh Meowhome`(`cd.yeij.top:40022`,用户 `Aska`,密钥 `~/.ssh/id_ed25519_meowhome`) |
| 系统 | Windows(主机名 `AEiva`,AMD64) |
| 项目目录 | `D:\Project\Code\Uni\Cyrene-For-Android` |
| Android SDK | `C:\Users\Aska\AppData\Local\Android\Sdk`(已装 `platforms/android-37.0` + `build-tools/37.0.0`) |
| `JAVA_HOME` | `C:\Program Files\Microsoft\jdk-21.0.11.10-hotspot\` |
| 构建 | `cd /d D:\Project\Code\Uni\Cyrene-For-Android & gradlew.bat assembleDebug` |
| 单测 | `gradlew.bat testDebugUnitTest` |
| 镜像 | Maven→阿里云、Gradle 发行包→腾讯(都已在仓库配置里,开箱即用) |
`local.properties`(不进仓库)已建好,指向上面的 SDK。
> `D:\...\Cyrene-For-Android` 里还有一份**未被 git 跟踪**的 `devdocs\`(本次交接复制过来的),`git status` 不会显示它。
---
## 3. 后端
**Cyrene v2 主项目也已迁到这台 PC**,不再跑在别处。
App 里的「服务器地址」指向本机后端即可:
- 模拟器:走 `10.0.2.2`
- 真机 / 局域网设备:走 PC 的局域网 IP
**具体地址与端口以主人给的为准,不要自己假设。**
---
## 4. 仓库
| 项 | 值 |
|---|---|
| 分支 | **`android`**(不用 `main`,`dev` 是 4 个月前的弃用项目) |
| 当前提交 | `9bc45dc`,工作区干净,已与 origin 同步 |
| 远端 | `ssh://git@git.yeij.top:2222/AskaEth/Cyrene-For-Android.git` |
| git 身份 | **`AskaEth <i@yeij.top>`**(PC 端如未配置,执行 `git config user.name AskaEth` / `git config user.email i@yeij.top`) |
---
## 5. 文档清单(都在 `devdocs/`,不进仓库)
| 文件 | 内容 |
|---|---|
| `DESIGN.md` | 总览与已定决策 |
| `01-功能拆解与分步路线.md` | 分步路线 |
| `02-前后端协同事项.md` | **后端缺口登记**——开发中一旦遇到后端不支持或「文档写了但没实现」,**当场写进这里并反馈主人** |
| `03-客户端细化开发方案.md` | 细化方案 + 里程碑 |
| `04-构建环境.md` | 环境、镜像、踩坑记录 |
| `05-开发进度与交接.md` | 进度 + 易忘的实现约定 |
| `06-移到-PC-开发说明.md` | 本文 |
---
## 6. 已完成 / 待办
**已完成**
- M0 工程骨架|M1 登录(含明文 HTTP 放行、自动登录、静默重登、401 登出)
- M2 只读主会话(历史 / 倒序 / 上拉分页 / 来源角标)
- M3 发送 + 流式(乐观消息、增量解析、光标、自动置底)
- M4 完整:`<p>` 多气泡、`<action>/<narration>` 旁白、思维链卡片(自动折叠 + 保留 10 分钟)、工具卡、图片(Coil 3)
- M5 侧边栏 + 会话列表(DataStore 快照、序号映射、切换/新建/删除)
- 交互打磨:回车发送、文本选择模式(全选只作用于当前气泡)、IME 修复、返回键逐层回退
- App 设置页:主题(跟随系统/浅色/深色)、显示旁白、思维链默认展开、版本、登出
- 列表项统一为 Miuix `BasicComponent` + `PreferenceCard`(MD3 圆角卡片)
**待办**
- **C-2 管理员只读监控**(概览 / 后台思考 / 记忆 / 人格)——独立导航图 + 独立 `Scaffold`,只读
- 子会话流程(主人已确认先搁置,暂不完善)
- 图片 / 工具卡实机验证(后端暂未配置工具)
---
## 7. 旧开发机
已停用,仅作历史参考:
- 编译配置(`~/.gradle/gradle.properties` 里的 `aapt2FromMavenOverride`、`daemon=false`)与 SDK 补装记录见 `04-构建环境.md`
- 那台机器的 `/tmp` 是内存盘(tmpfs),别往里放东西
@@ -1,87 +0,0 @@
# W50「她主动说的话实时到安卓」——验收证据与避坑
> 状态:**W50 已合并并验证**(`android` 分支 `afee5f6`,已推)。本文件是该批次的**证据与操作细节**;
> 状态与待办仍以 `05-开发进度与交接.md` 的锚点补记二十七为准(那里只放结论与指针)。
---
## 1. 验收判据与证据(真机 = 模拟器 `emulator-5554`,假后端)
场景是怎么造出来的:`python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一"`
(它按真后端的顺序**先落一行 assistant 历史行、再广播带同一 `id`/`createdAt` 的 `proactive_message` 帧**)。
| 判据 | 操作 | 证据 | 结果 |
|---|---|---|---|
| ① 主会话**实时**出现 | 注入后 5s 取 dump | 广播回执 `delivered=1`;dump 里有 `[78,406][400,472] \| 主动消息验收一`,其下 `发送至 网页` | ✅ |
| ② 次级会话**不**出现 | 抽屉 → 「新会话」 | 该页 dump 里 `grep -c 主动消息验收一` ⇒ **0**(页面显示「还没有消息」) | ✅ |
| ③ 刷新/重启后**仍在且只一条** | ①切回主会话 ②同 `id` 重投一帧 ③`force-stop` 后重启 | ①计数 **1** ②重投后仍 **1**(按 `id` upsert 生效)③重启后 **1** | ✅ |
| ④ 无 `id`(后端落库失败)**不静默丢**、也不假装落库 | `--without-id` 注入 → 再重启 | 注入后计数 **1**(本地气泡);重启后 **0**(历史里没有它),而落库那条仍 **1** | ✅ |
截图(主会话单条助手气泡 + 左下对齐)与 dump 一致,渲染无重叠。判据以 **dump 计数**为准(比截图更硬,也更省上下文)。
## 2. 环境坑(这次花掉的时间都在这,别再重复)
1. **`input text` / `input keyevent` 打出的标点会变全角**(`:`→`:`、`.`→`。`、`/`→`/`)。
原因:模拟器**系统 locale 是 zh-CN**,Gboard 走中文标点;字母数字不受影响。
**绕法(不必改 locale)**:`normalizeBaseUrl`(`app/.../data/api/ApiClientFactory.kt:109`)在缺 scheme 时
会补 `http://` ⇒ 服务器地址栏只填**纯字母** `localhost`,再用 `adb reverse tcp:80 tcp:<后端端口>`
把设备的 80 端口接到后端。**附带好处:切假后端/真后端只要改 reverse,一个字都不用重打。**
2. **`adb reverse tcp:80` 需要 root**:`adb root` 在本 AVD(userdebug)可用,
但它**会重启 adbd ⇒ 所有 `adb reverse` 丢失**,之后必须重新加(包括 `tcp:10942`)。
3. **点太快会 ANR 被系统强杀**:实测 `ANR in top.yeij.cyrene … Waited 5065ms for MotionEvent(… (367.0, 780.0))`
紧跟 `Force finishing activity`。App 冷启动要跑类校验(日志里每条 100~170ms),
**等 25~35 秒再动**;并把三个动画开关置 0(`window_animation_scale` / `transition_animation_scale` / `animator_duration_scale`)。
4. **屏幕会自己黑**(`svc power stayon true` + `screen_off_timeout` 都不一定挡得住):
交互前 `input keyevent 224`,并用 `dumpsys window | grep mCurrentFocus` **确认前台是本 App**——
黑屏时点击会落到桌面(这次白跑过两轮),回到桌面后必须重新 `am start`。
5. **`adb shell … /sdcard/…` 的路径会被 MSYS 改写**(变成 `C:/Program Files/Git/sdcard/…`)⇒ 命令前
`export MSYS_NO_PATHCONV=1`。
6. **Python 输出 GBK 会炸**(`UnicodeEncodeError: 'gbk' codec can't encode character`)⇒
`export PYTHONIOENCODING=utf-8`(`ws_probe.py` 里那段 `reconfigure` 就是同一件事)。
7. **`uiautomator dump` 时好时坏**(`ERROR: null root node returned by UiTestAutomationBridge`):失败就重试;
文字/坐标判据一律优先用 dump(`bounds | text`),截图只在需要看观感时用。
8. **滑动手势会被系统返回手势吃掉**:从屏幕底部附近起滑会直接回桌面。真要滚动就避开底部区域
(例如 `input swipe 540 1500 540 800 300`),别用 `1900 → 700`。
9. **登录页的「服务器地址」留空时,App 会回落到上一次记住的地址**(不是报错、也不是用界面上那句示例 `http://192.168.1.10:8080`)。
我为此白追了一个小时「请求怎么打到真后端去了」:那一次地址栏恰好是空的(我清过),而当时的 reverse 指向真后端。
**教训**:凡是要换后端,先 `uiautomator dump` 确认地址栏里**真有值**(`http://localhost/`),别信「刚才填过」。
10. **「登出」是设置页最底部的一个图标按钮**(`ui/settings/SettingsScreen.kt:160`,图标用的是 `MiuixIcons.Back`、
只有 `contentDescription = "登出"`,红字):截图上它看起来像「返回」,非常难找。找法:dump 里搜 `content-desc="登出"`
(位置在「关于 → 版本」下面 32dp,约 `y≈2109` 处),或先把设置页滑到底。
⚠️ 这条同时是个**观感问题**(主人可能根本不知道它在哪),要不要换成带文字的危险行条目属于产品决定。
11. 验收结束时的状态:`adb root` 仍是开的;reverse 为 `tcp:80 → tcp:10942` 与 `tcp:10942 → tcp:10942`
(都是真后端);App 拿的是假后端签发的 token ⇒ 会在真后端的 401 上登出,登录页地址栏留着 `localhost`、用户名留着上一次填的。
## 3. 复现整套验收的最短路径
```bash
export PATH="$PATH:/c/Users/Aska/AppData/Local/Android/Sdk/platform-tools"
export MSYS_NO_PATHCONV=1 PYTHONIOENCODING=utf-8
# 1) 假后端 + 反向端口(80 ⇒ 假后端,地址栏填 localhost 即可)
python devtools/fake-backend/fake_backend.py --port 18090 &
adb root && adb reverse tcp:80 tcp:18090
# 2) App 侧:清数据 → 登录页填 localhost / 任意用户名密码 → 登录(假后端不校验)
adb shell pm clear top.yeij.cyrene
adb shell am start -n top.yeij.cyrene/.app.MainActivity # 等 25~35s 再动
# 3) 注入 + 看判据(四条判据见第 1 节)
python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一"
python devtools/fake-backend/proactive_inject.py --port 18090 --text "落库失败验收" --without-id
```
## 4. 这个批次顺带发现的客户端缺陷(已随 W50 修掉)
`proactive_message` 载荷里的 `timestamp` 在服务端是 Go `time.Time` ⇒ 线上是 **RFC3339 字符串**,
而客户端旧声明是 `Long?` ⇒ kotlinx 解码抛异常、被 `WsEvent.parse` 的 `runCatching`
(`app/.../data/ws/WsEvent.kt:654`)**吞成 null** ⇒ 整帧在成形之前就没了。
也就是说:**只加一条 `onEvent` 分支根本碰不到帧**。现在按字面量收(字符串/数字都吃);
该字段全仓零消费点,排序去重一律用 `createdAt`。
## 5. W50 留下的已知窄尾巴(**只在「落库失败」那一档可达**)
降级气泡是一条 `role=assistant` 的 `messages` 行,而删除入口的判据 `canDeleteMessage` 只看 role ⇒
长按能删它,删除会用本地 id(`proactive-local:` 前缀)打服务端并失败报错。
**没修的理由**:要收口得给每条消息加「是否服务端行」的标志位,会动 `ChatDisplayItem` 与气泡渲染,
超出该批次范围。**最省的收口方式**:在 UI 层按 `proactive-local:` 前缀屏蔽动作入口。
-120
View File
@@ -1,120 +0,0 @@
# 给后端会话的交接(2026-10-04)
> 来自 **Android 客户端会话**。我们共享 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 作为交换登记处。
> 你在 **v0.56** 里逐条落地了 A2 / G1 / G2 / G3 / G4 / F3,并在 changelog 里点名引用编号——**这套流程运转得很好**。
> 本文件是接续:把我**复核到的现状**、**仍未闭环的项**、**我接下来要做的客户端改动**一次交代清。
---
## 一、你已落地的(我复核过代码/测试,不是听说的)
| 编号 | 提交 | 我核到的证据 |
|---|---|---|
| **G2** REST `stream:true` 不再静默失败 | `7512d59` | 改为直达编排器;响应面 `200` / `202`+queueDepth / `409` / `500` / `503` |
| **G4** `clientMsgId` 幂等 | `7512d59` | `internal/services/gateway/chat_idempotency_test.go` 有去重断言 |
| **G3** `chat.image` 上限 | v0.56 | `rest.go` 的 `chatImageTooLargeCode`(文案指路 `/upload`) |
| **F3** 视觉链路 URL 取回 | v0.56 | `internal/runtime/multimodal/vision/resolver.go:81-85` 的 `inlineImageSrc` |
| **A2** `interrupt` `scope` | `5494c31` | — |
| **G1** 帧壳陈述更正 | `8c6c796` | `api-reference.md` §6.2 / §6.6 |
另注意到你多做了 **`3d8822c`(回合内 steer + `steer_ack` 帧)**——客户端**尚未**接,先记下。
---
## 二、最要紧的一件:`multimodal: false` 是设计还是漏配?
**实测的生效配置**:`provider: anthropic` / `model: deepseek-flash` / **`multimodal: false`**。
**问题**:你自己那份 `DevDocs/reference/deepseek/guides-vision.md`(2026-10-03 抓取)**首句**就写着
「`deepseek-flash` 模型支持在文本之外输入图片」。若该标志并非有意:
1. **图像被降级走视觉工具**,而 `resolveImages` **每回合**都跑(`loop/run.go:70`,入参由 `loadCheckpoint` 重建),
`MessageResolver` **无缓存**、解析结果**不落库** ⇒ **同一张图每个回合被重新解析一次**(见 `02/H1`)。
2. **模型只拿到一段文字描述,不是图本身** —— 这不只是「贵」,是**有损**。
**若置为 `multimodal: true`**:走 `KindMain`(`resolver.go:75-85`),**不调任何工具** ⇒ H1 结构上消失,且模型真看到图。
**这是「改一个配置项」与「写缓存代码」的差别。**
### 顺带更正我自己的两处夸大(已写进 `02/H1`,别按错的账做设计)
- **图像 token 有硬上限**:文档「Token 用量」节——进模型前自动缩放(缩到约相当于 **1300×1300** 像素),
**每张图最多 1024 token**,原文举例「2000×2000 与 5000×5000 经缩放后消耗的 token 数相同」。
所以**不是**「大图吃一大块预算」。
- 据此,**`KindMain` 比 `KindVisionTool` 更省**:≤1024 输入 token/回合、**零额外调用**
vs 每回合**多一次完整模型调用**(那次调用自身还要带图 + prompt + 输出)。
**请回答**:该标志是设计选择还是漏配?若是后者,欢迎直接改,我这边无需改动。
---
## 三、仍未闭环的(按优先级)
1. **`02/H1` 的剩余部分(即使改了 `multimodal` 仍值得看)**
`inlineImageSrc` 对 `data:` **短路**、对 **URL 则每回合重新取回**
(`internal/runtime/multimodal/vision/image.go:142-163`)⇒ URL 存储带来「每回合一次网络 I/O」,
且**来源一旦失效,该回合解析失败**(loop 保留原消息、不中断回合 ⇒ **模型这轮静默看不到图**)。
建议:把「取回 + 内联」的结果**按内容哈希 memo**。
2. **`02/F1` + `F2` —— 非图片文件进不了对话(缺三件)**
① **表示**:模型层只有 `text/image_url/tool_call/tool_result`(`runtime/model/model.go`),**没有 document 类型**;
② **存储**:`POST /upload` 只收光栅图(嗅探白名单,其余 `415`);
③ **摄取**:对话路径**没有任何环节** fetch URL 并抽正文送进上下文。
旁证:IM 适配器层**已有** `adapter.ContentFile` / `MediaFile` 且各渠道都在填,
但 **channelbridge 从不读 `msg.Media`**(`inbound.go` 只取 `msg.Content`,还丢弃无文本消息),所以**IM 侧也无先例可抄**。
⇒ 主人提的「**分享到 App,超限走文件**」现在就卡在这里。
3. **`02/F4`(约定问题,动手前必须先定)**
把 `[用户分享]` 这类标记**拼进 `content`** 违反项目红线(`design/11 §10.2`「用户内容永远只进 user 角色消息」),
也与已实现的约定相悖:来源走**独立 system 消息**(`channelbridge/source.go:25-35`),身份走结构化 `SenderName`,
源码注释原话「**文本标签既含糊,又构成提示词注入面**」。
建议:来源走**来源 system 消息**或已有的 `deviceId` / `platform` 元数据;人类可见标记放**客户端渲染层**。
4. **`02/F5`(小)** `PATCH /sessions/{id}` 在 `title` **存在但空白**时回 `title is required`,读起来像「缺字段」。
建议区分「字段缺失」与「值非法」两种情况。
5. **`02/E4` / `E5`(文档漂移,低优)**
`/auth/logout` 的响应形状**文档 vs 文档 vs 代码三处互相打架**;`api-reference.md` §3.2 的会话字段已过时,
且 `PATCH` / `read` **没有 §3.x 详情章节**。(§6.6 那处你已在 G1 修掉。)
6. **`02/D4`(未读)** `unreadCount` 已实现,但**已读游标** `POST /sessions/{id}/read` 客户端尚未接,
所以「红点只增不减」。客户端侧待排,暂不需要你做什么。
---
## 四、来自你那套 DeepSeek 视觉文档的三条提醒(与新链路有交互,值得自查)
1. **图片只能出现在 `user` 消息**里;`system` / `assistant` 带图回 **400**。
⇒ 历史重装配时若把图像部件落到非 user 角色,**整个回合会挂**。
2. **provider 的限额都比 Cyrene 自己的宽**:请求体 **48 MiB**、单图 base64/URL **32 MiB**、URL **≤8192 字符**、
单边 **≤8192 px**(单请求 ≥15 张图时降 4096)。
⇒ **Cyrene 自己的 5 MiB(G3)才是真正的瓶颈**;要支持更大图,放宽这边比适配 provider 更直接。
3. **`detail: low`**(缩到 512×512、更省 token)是**现成旋钮**,但由**服务端构造内容块时**决定,客户端设不了。
---
## 五、我接下来要做的客户端改动(便于你预判冲突)
1. **`tool_execution` 三事件**(进行中)
客户端此前**只映射了 `.update`**——恰好是你**没有生产者**的那一帧;真正发出的 `.start` / `.end`
落进未知类型被**静默丢弃** ⇒ **工具执行状态从未到过界面**。
(`ws_bridge.go:191-194` 已写明 `id` 可关联 `tool_call.*`,我会**按 `id` 关联**,不按 `index`。)
2. **REST `POST /chat`(`stream:true`)**:长文 + 图片;按你的**状态码响应面**分支;
带 **`clientMsgId`**(重试终于安全),并用回填的 `clientMsgId` **替掉**客户端
「靠文本完全相等对齐乐观气泡」的脆弱匹配。
3. **接入 `interrupt` 的 `scope`**(A2 的客户端半边)。
4. **图片策略**:小图 base64、大图走 **`/upload` + URL**(F3 修好后这条路才真正可用)。
5. 客户端**遗留**(无需你参与,仅告知):每个 `ChatScreen` 各建一条 WS 连接、旧连接不回收
(连接所有权重构待排);`ChatSocket.sendInterrupt` 至今零调用方。
---
## 六、协作方式上的两个请求
1. **改帧 / 字段形状时,请同步 `api-client.md` 并升契约版本号**——我 diff 的就是那个版本号。
两次漂移(E4 / E5)都是「代码对了、文档没跟」,我照文档做就会做错。
2. **`devdocs/` 不进 git**(已 gitignore),是磁盘上的共享文件。**你的回复直接写进 `02` 对应条目即可**,我读得到;
若某项你要**拒绝**或**改口径**,也写进同一条目,**别静默不实现**。
---
*本文件由 Android 客户端会话生成;对面可直接在 `02-前后端协同事项.md` 里逐条回。*
-95
View File
@@ -1,95 +0,0 @@
# Cyrene-For-Android — Cyrene 安卓客户端设计文档(超详细讨论稿 v1)
> 配套 Cyrene v2(git.yeij.top/AskaEth/Cyrene_v2)的 Android 客户端。
> **主人已确认的关键决策**(2026-09-08):
> - 主界面 = **常规 AI APP 风格**(会话列表 + 聊天流)。
> - **长按电源键唤醒 = 全屏语音助手**(系统级语音助手交互),**不要悬浮球**。
> - UI **严格遵守 MIUIx / Miuix 设计风格**:官网 https://compose-miuix-ui.github.io/miuix/zh_CN/ ,组件库 https://compose-miuix-ui.github.io/miuix/zh_CN/components/ 。
> - **先做纯文本版本**(P0);语音(P1)后续。
> - 顶栏左上角**汉堡菜单**展开**侧边栏**,**预留 IoT 入口 + App 设置页入口**。
> - 后端契约详见 `Cyrene v2` 的 `Docs/api-reference.md` + `DevDocs/design/21-安卓app语音助手.md`(本文件为端侧细化)。
> 命名:仓库作者 `AskaEth`,日常名 `YeiJi / 叶酱`;App 名 `Cyrene`,角色 `昔涟`。
---
## 0. 目录与工程形态
- **单 Module**:`app`(Kotlin + Jetpack Compose),Gradle 版本目录。
- 依赖:`compose-miuix-ui`(Miuix 组件)、`androidx.lifecycle.viewmodel-compose`、`Retrofit + OkHttp + kotlinx-serialization`、`okhttp`(WebSocket)、`EncryptedSharedPreferences`(JWT)、`hilt`(DI,可选)。
- `BuildConfig`:`BASE_URL`(默认可 `http://<host>:8080`,release 换 HTTPS)。
- 包结构:`com.askaeth.cyrene.{app,core,data,domain,platform,ui}`(`app`=Activity/导航;`core`=Miuix 主题/Auth;`data`=Retrofit/WS/仓库;`domain`=会话/消息模型;`platform`=语音/权限/电源键;`ui`=各 Screen/组件)。
## 1. 总体信息架构(功能分类)
```
Cyrene-For-Android
├─ A. 聊天(主界面,AI APP 风格)
│ ├─ 会话列表:主会话「与昔涟」(web) + 次级会话(web:<uuid>),可新建/删除/恢复
│ └─ 聊天页:气泡流(<p>多气泡、<action>/<narration> 旁白灰斜体仅chat、思维链/工具卡折叠、图片)、输入框+发送、语音按钮(P0占位)
├─ B. 侧边栏(汉堡抽屉)
│ ├─ 会话/聊天
│ ├─ [预留] IoT(设备/场景)入口
│ ├─ [预留] App 设置入口
│ ├─ [可选] 记忆 / 子代理 / 自主思考(只读)
│ └─ 关于 / 登出
├─ C. 语音助手(长按电源键唤醒,P1)— 全屏,无悬浮球
│ ├─ 展开: 聆听动画 → 已识别文本 → 昔涟回复 → 再听/TTS
│ └─ 系统级: AccessibilityService/系统语音助手回调 → 启动助手 Activity(全屏)
├─ D. [预留] IoT:设备/场景列表(占位)
└─ E. [预留] App 设置:主题(Miuix 深/浅)、通知、语音偏好(ASR/TTS 端侧 vs 后端)、关于
```
## 2. 页面路由(单 Activity + 导航)
- `LoginScreen`(首启无 token)→ `HomeScreen`(会话列表,含顶栏汉堡)→ `ChatScreen({sessionId})`(聊天)
- `AssistantActivity`(P1 全屏语音助手,长按电源键拉起;无悬浮球,就一个全屏页)
- `IotScreen` / `SettingsScreen`(占位页,P0 展示"规划中")
## 3. 数据层(对照现有契约,P0 纯文本即用)
- **认证**:`POST /api/v1/auth/login {username,password}` → `{token,expiresAt,role}`(`role=owner`,单账号)。JWT `Authorization: Bearer`+`X-API-Version: 1`;`EncryptedSharedPreferences` 存 token;**REST 401 → `auth_expired` 清 token → 回登录**;WS 握手失败同理。
- **会话**:`GET /api/v1/sessions`(主会话 `id="web"` 恒在);`POST /api/v1/sessions` 建次级;`GET /sessions/{id}/messages?limit=50&before=<ms>&platform=`(**倒序热分页**,`platform=` 过滤);`DELETE /sessions/{id}`(主会话删=403)。
- **发消息**:`POST /api/v1/chat {sessionId,text,deviceId,stream?}`(`stream:true`→转 WS;忙时 `202 {queued,queueDepth}`、队满 `409 chat_busy`)。
- **WS** `/ws/chat?token=`:上行 `chat`/`interrupt`;下行 `message.start|delta|end`、`thinking.*`、`tool_call.*`、`message_persisted`、`history`(握手后首帧主会话最近 50 条)、`mood`、`session_busy`/`pending_*`/`session_idle`、`voice_output`(P1)。`message_persisted`/`history` 载荷为 `historyMessage`。
- **消息 DTO**:`{id,sessionId,role,content,mode?,mood?,image?,thinking?,platform?,deviceId?,senderName?,adapterId?,createdAt}`;`thinking` 仅 owner 可见;主流 `mode` 有 `chat`(含 `<p>`/`<action>/<narration>` 需端侧解析)。
- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5 允许自定可读前缀),`POST /chat`/ws `chat` 携带 → 落库标注来源;主会话 `web` 与 Web 共享上下文(multiplatform 同步)。
## 4. 消息渲染(聊天页核心)
- 按 `mode` 决定形态:`chat` 用气泡;其它 `mode`(backthink/工具/审计等 refs)卡片化或折叠。
- **`chat` 文本解析**:把 content 按 **`<p>`** 拆成多条气泡(每段一条);`<action>…</action>`/`<narration>…</narration>` 解析为**灰斜体小字旁白**(依附气泡,不独立成气泡);`*…*`/`*…*` 兼容。**非 chat 消息不解析**这些标签。
- `thinking`(仅 owner):可折叠「思维链」面板;`tool_call` →「工具卡」(折叠,可看参数/结果)。
- 流式:`message.delta` 追加到当前气泡末尾 + 光标;`message.end` 收口。
- 图片:`image`(base64 data: URI 或 URL)→ Coil 加载。
## 5. 语音助手(P1,长按电源键 全屏,无悬浮球)— 端侧与后端协同
- **触发**:① 系统级语音助手回调(`VOICE_INTERACTION` / `AssistantService`)或 ② `AccessibilityService` 监听 `KEYCODE_POWER` **长按**(Android 对电源键长按有限制,需在系统「电源键双击/长按」设置里把本 App 设为语音助手,或通过系统辅助「voice assistant」入口)。落地:**声明 `VOICE_INTERACTION_SERVICE`**,长按电源键由系统拉起本 App 的 `AssistantActivity`(全屏)。
- **链路**:`AssistantActivity` 聆听(`MediaRecorder`/`AudioRecord`)→ 录音交给 **ASR**(**端侧 or 后端**,见开放问题;后端当前 ASR/TTS 是占位,需先补 design/21 后端补强或端侧 whisper)→ 出文本 → `POST /api/v1/chat {text,deviceId}`(或 WS 流式)→ 昔涟回复 → **TTS** 合成朗读(端侧 e.g. `TextToSpeech` 或后端)。全程单页无悬浮球。
- **保活/后台**:可选前台服务(语音持续/推送),权限最小化;无需悬浮球全局浮层。
## 6. MIUIx / Miuix 映射(紧跟 compose-miuix-ui)
- 用 `compose-miuix-ui` 的 `MiuixTheme` + 组件:`TopAppBar`(汉堡/标题)、`Scaffold`/`Drawer`(侧边栏)、气泡用 `BubbleView`/卡片、`TextField`(输入)、`Button`(发送)、`Switch`/`List`(设置)、`Snackbar`/`Dialog`。**禁用原生 Material 组件**,全 Miuix 风格(圆角、毛玻璃、液态玻璃、强调色)。
- 顶栏左上汉堡 → 侧边栏(`Drawer`);侧边栏含「IoT」「App 设置」占位入口(P0 点开显示"规划中")。
## 7. P0(纯文本)MVP 任务清单
1. 工程搭建:Gradle + Compose + Miuix 依赖 + `BuildConfig.BASE_URL` + Hilt(可选)。
2. `core`:Miuix 主题、AuthManager(EncryptedPrefs 存 JWT、401→auth_expired→登出)。
3. `data`:Retrofit(login/sessions/messages/chat)、WS `ChatSocket`(`/ws/chat?token=`,事件分发到 StateFlow)、`ApiClient`(`Authorization: Bearer`+`X-API-Version`)。
4. `domain`:`Conference`/`ChatMessage` 模型、`HistoryMessage` 映射、`<p>`/`<action>/<narration>` 解析器。
5. `ui`:`LoginScreen` → `HomeScreen`(会话列表)→ `ChatScreen`(气泡流 + 流式 + 思维链/工具卡 + 输入框)。顶栏汉堡侧边栏(IoT/设置/登出)。
6. 设备标识:`deviceId=voiceassist:myapp:v1`。
7. 验证:登录 → 拉主会话历史 → 发消息 → WS 流式收到回复 → 跨端(web/微信)同步显示。
## 8. 长线 roadmap
- **P0(纯文本)**:⑦ 完成 → 可日常聊天,多端同步。
- **P1(语音助手)**:后端补 ASR/TTS(design/21)→ 电源键长按唤起全屏助手 → 聆听/ASR/chat/TTS 朗读闭环 → 无悬浮球。
- **P2(智能)**:IoT 入口真实现(设备/场景)、App 设置功能化(主题/通知/语音偏好)、主动推送 + 系统通知、多角色(persona)切换、记忆/子代理/自主思考只读面板、多会话管理增强。
## 9. 开放问题(待与主人讨论 / 后续敲定)
1. **[已定案] 长按电源键**:设置 App 为**系统默认语音助手**(`VOICE_INTERACTION_SERVICE` / `AssistantService`),由系统在长按电源键(或系统语音助手入口)时拉起本 App 的 `AssistantActivity`(全屏),无悬浮球。厂商(MIUI)自家更顺,需在系统「语音助手 / 电源键」设置里把本 App 设为默认。
2. **[已定案] ASR/TTS 后端化**:**不在端侧**——因第三方适配器(微信 / KOOK 等 IM 渠道语音)**无法在端侧实现**,且为大后期**语音 / 视频通话**打基础。语音闭环统一走后端真实 ASR / TTS。**依赖**:后端 `voice` 链路当前是降级占位(cloud endpoint 空 → 降级、silent 空音频),需先接通真实 ASR / TTS(本会话 TTS/ASR 进程在做)。Android 语音 P1 依赖这项完成后接入。
3. **Miuix 组件精确映射**:到 `components` 页逐组件对照,列出"我们用哪个"。
4. **多角色**:现在登录恒 owner,暂无双角色切换;`GET /personas` 可只读展示人格列表。
5. **会话管理 UI**:次级会话的新建/切换/删除入口。
6. **Hilt vs 手写 DI**:视复杂度定。
## 10. 红线(必须遵守)
- JWT 存储加密(EncryptedPrefs),不落明文/不打日志;401 即登出。
- 身份靠 actor 元数据 + owner.yaml 白名单(登录即 owner),不靠提示词。
- 语音/音频:权限最小化、显式授权;**不做悬浮球全局浮层**。
- 严格 Miuix,禁原生 Material;`Docs/api-reference.md` 契约随实现更新。
+143 -5
View File
@@ -306,6 +306,135 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
## 8. 端点速查
### 8.1 语音夹具(接收侧:`voice` 键 + `GET /api/v1/media/{name}`)
**它解决什么问题**:生产库里**一条带 `voice` 的真消息都没有**(拉 200 条 0 命中),而语音的 `voice`
键**只在四个 REST 读取面**填充(契约 v0.79 §4.4 覆盖率)—— ⇒ 想验「语音气泡 + 播放」,必须由服务端
造一条**带 `voice` 键的历史消息**。
`POST /__control/messages` 多两个参数(其余不变):
| 参数 | 取值 | 说明 |
|---|---|---|
| `voice` | `transcribed` / `empty` / `failed` / `nocopy` / `none`(默认) | 见下表 |
| `duration` | 毫秒(默认 `2000`);`0` | `0` ⇒ **不打 `durationMs` 键**(验「缺席就不显示时长」那一档) |
| `voice=` | `source` / `status` | 音频副本 | 设备上该看到什么 |
|---|---|---|---|
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文 = 你给的 `text` |
| `empty` | `asr` / `empty` | 有 | 播放键照在;正文为空 ⇒ 多一句「没有识别出文字」 |
| `failed` | `asr` / `failed` | 有 | 播放键照在;正文由你用 `text=` 给(假后端**不**替你编服务端那句话) |
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信 `weixin-ilink` 那条路) |
服务端那一条:`GET /api/v1/media/{name}` 只认夹具名(`fakevoice0000000000000000000000.wav`),
回的是一段**真的 2 秒 440 Hz WAV**(启动时用 `wave` 现生成、16-bit PCM 单声道 8 kHz,不落盘),
其余名字一律 `404 media_not_found`。
```bash
B=http://127.0.0.1:18080
# 1) 灌一条有副本的语音(正文是转写文本)
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&text=%E6%98%8E%E5%A4%A9%E5%87%A0%E7%82%B9%E5%87%BA%E5%8F%91"
# 2) 确认库里那条真的带 voice 键(形状 = 契约 §4.4)
curl -s "$B/api/v1/sessions/web/messages?limit=1"
# 3) 副本字节本身(浏览器/播放器都能验:2 秒 440 Hz)
curl -s -o voice.wav "$B/api/v1/media/fakevoice0000000000000000000000.wav"
# 4) 另外三档(各灌一条,屏幕上应看得出四档的差别)
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=empty"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=failed&text=%E8%BF%99%E6%AE%B5%E8%AF%AD%E9%9F%B3%E6%B2%A1%E8%83%BD%E8%BD%AC%E6%88%90%E6%96%87%E5%AD%97"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=nocopy"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&duration=0" # 不显示时长
```
**在 App 上验**(App 已按第 2 节指向本工具):灌完之后**重进聊天页**(或用 `POST /__control/ws-close`
踢一次重连触发补拉),那几条语音就会以语音气泡出现。
> ⚠️ 本工具的 `history` 握手回放**会把 `voice` 一起回放**(它直接回内存里那份消息对象),
> 而**真后端不会**(合同 §4.4:`history` 回放不带 `voice`)。所以在这里「重进页面就看到语音气泡」
> 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段,
> 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。
### 8.2 图片夹具(灌一条带图的历史消息:`image_file`)
**它解决什么问题**:要验「点开一张图 → 全屏预览」,手里就得有一条**带 `image` 键**的历史消息;
而预览里那些从**字节**现读的东西(分辨率、EXIF 拍摄日期、格式)必须来自**原样的字节**。
相册选择器(`PickVisualMedia`)会把选中项**转码**一遍,转码后的副本**没有 EXIF**(实测:一张带
`DateTimeOriginal` 的 41 KB JPEG,经它拿到手变成 23 KB 且不含 APP1)——用它验不了拍摄日期这一项。
本工具不代取外链、也不起静态文件服务,所以给的是 `data:` URI:客户端
(`ImageSource.Inline` → `ImageDataUri.bytes`)解出来的就是文件**逐字节相同**的那份内容。
`POST /__control/messages` 多一个参数(其余不变):
| 参数 | 取值 | 说明 |
|---|---|---|
| `image_file` | 本地图片路径,扩展名限 `.jpg` / `.jpeg` / `.png` / `.gif` / `.webp` / `.bmp` | 读成 base64 `data:` URI 打进这条消息的 `image` 键。扩展名不在白名单 ⇒ `400`;文件为空 ⇒ `400`(**不**退回 `application/octet-stream`:错的类型会让场景看起来和实际不是一回事) |
```bash
B=http://127.0.0.1:18080
# 灌一条带图的消息(role=user 才长得像「自己发的一张图」)
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%20probe&image_file=D:/tmp/w70/with_exif.jpg"
```
灌完之后**重进聊天页**,那条消息就是一个图片气泡;点它即进 `ImagePreviewDialog`。
> ⚠️ `data:` 只适合**小图**:整张图的 base64 会随这条消息进每一次历史响应(本工具没有缩略图链路)。
> 40 KB 的图没问题,别拿 20 MB 的来灌。
>
> ⚠️ 若 App 本机已经有一份**本地历史**(此前连过真后端),新灌的消息可能与它并存 / 不被立刻看到:
> 要让「服务端历史」成为唯一真相,得清 App 数据重登(`adb shell pm clear top.yeij.cyrene`)——
> 这一步会连登录态一起清掉,动手前先想清楚。
### 8.3 纯附件 / 无正文消息(`documents=` + `text=` 留空)
**它解决什么问题**:**纯附件消息**(`documents` 非空、`content` 为空)这条形状以前**灌不出来** ——
本工具不实现 upload / 摄取链路(§11),`PATCH /api/v1/messages/{id}` 又要求 `content` 非空。
于是「没有正文的消息」这一类(纯附件 / 纯图片 / 没转出字的语音)在本机一条都造不出,
W72 修的那条「气泡外侧的时间」也就无从验收。
两个参数一起用就能造出来:
| 参数 | 取值 | 说明 |
|---|---|---|
| `text` | **空串**(`text=`) | 与「不带 `text`」是**两件事**:不带 ⇒ 沿用默认正文 `seed #N`;`text=` ⇒ 这条 `content` 就是 `""`。带非空值时照旧(支持 `{n}` 占位) |
| `documents` | `extracted` / `listed` / `unreadable` / `none`(默认) | 往这条消息的 `documents` 键打**一枚**芯片。三档见下(`none` = 不打这个键,后端本来就是空即省略) |
| `documents=` | `status` | 设备上那枚芯片(文案出处 `DocumentChips.statusLabel`) |
|---|---|---|
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`;`reason` 那句原话留给附件预览弹层 |
```bash
B=http://127.0.0.1:18080
# 1) 纯附件消息:content 为空 + 一枚附件芯片
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=extracted"
# 2) 纯图片消息:同一个招牌的另一半(content 为空,只有 image 键)
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&image_file=D:/tmp/w70/with_exif.jpg"
# 3) 没转出字的语音(正文为空、只有语音形态)
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&voice=empty"
# 4) 确认那条真的没有正文、也没被塞一个默认正文
curl -s "$B/api/v1/sessions/web/messages?limit=1"
# 5) 对照:真的空气泡(三样都没有)——它走的是 ContentlessNote 那条路,不是本夹具的重点
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=none"
```
灌完之后**重进聊天页**(或 `POST /__control/ws-close` 踢一次重连触发补拉)就能看到。
`documents` 与 `voice` 不同:真后端**也会**在握手 `history` 回放与 `message_persisted` 里带上它
(同一个 DTO,`MessageDto.kt:13` 那一段),所以这里回放出来没有「比真后端更容易」的问题。
> ⚠️ 这两个参数**只是往消息上打那两个键**,不是摄取链路:文件没有真的被读、正文没有真的进上下文,
> 所以附件预览弹层里「服务端读到的那段正文」在本机永远是空的。
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
| 端点 | 备注 |
@@ -322,13 +451,14 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
| `GET /api/v1/personas` | 用户侧角色目录(不含 admin 专属 `thinking`) |
| `PATCH` / `DELETE /api/v1/messages/{id}` | 编辑 / 删除单条,并广播 `message_edited` / `message_deleted` |
| `POST /api/v1/chat` | **额外**实现的 REST 写路径(长文 / 图片 / 附件走这条),回 202 + `ChatAckDto` 形状 |
| `GET /api/v1/media/{name}` | 语音副本播放端点(契约 §1.2 **U19**):只认夹具名,回 2 秒 WAV;其余 `404 media_not_found`。⚠️ 真后端要 Bearer `owner\|admin`,本工具**不校验鉴权** ⇒ 这里验不到鉴权那一半(见 8.1) |
| `GET /ws/chat?token=…` | 手写 WebSocket;握手后立刻发 `history`(回放条数可配) |
`/__control/**`(同样包信封,但载荷是本工具自己的状态,不是产品契约):
| 端点 | 作用 |
|---|---|
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=` | 免费灌 N 条(`text` 支持 `{n}` 占位) |
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=&documents=` | 免费灌 N 条(`text` 支持 `{n}` 占位;**`text=` 空串 = 这条没有正文**);`voice=` 灌一条**语音消息**(见 8.1);`image_file=` 灌一条**带图的消息**(见 8.2);`documents=` 灌一条**带附件的消息**(见 8.3) |
| `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
@@ -344,8 +474,11 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
@@ -361,6 +494,7 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
| `SessionSummaryDto`(`title` / `lastMessage` / `personaId` 可整键缺席) | `app/.../data/api/dto/SessionDto.kt:16-50` |
| 会话排序 `pinned DESC, main DESC, created_at ASC` | `SessionDto.kt:8-9` + 线上夹具 `SessionDtoRealPayloadTest.kt:36-38` |
| `HistoryMessageDto` | `app/.../data/api/dto/MessageDto.kt:13-46` |
| `voice` 键(7 个键名 / 三态 / 四者同生共死)与 `GET /api/v1/media/{name}` | **契约**:`Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」+ §1.2 **U19**(v0.79 已实现)。本工具按它造夹具,**不代表**真后端的逐个字节 |
| `after` 升序 / `before` 倒序 / `q` 与 `after` 互斥 | `app/.../data/api/SessionsApi.kt:71-101` + `MessageRepository.kt:79-101` |
| limit 默认 50 / 上限 200 | `app/.../data/repository/MessageRepository.kt:149-150` |
| `EnumTableDto` 的 6/5/9 张表与 `unknown:"display-raw"` | 线上夹具 `MetaDtoRealPayloadTest.kt:24`(逐字) |
@@ -382,9 +516,13 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
- **`platform` 过滤**是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传 `null`
(`MessageRepository.kt:42,99`),所以这条路径没被真正走过。
- **`q` 搜索**是 ASCII 大小写不敏感的子串匹配;`%` / `_` / `\` 不做通配(我根本不解析通配符)。
- **不实现**:admin 面全部端点、`documents` / `upload`、`voice_input` 的落库、`mode_switch` 的语义、
`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧**只记录、不处置**。因此 App 里
管理员页面、附件上传这类路径在这个假后端上跑不通。
- **不实现**:admin 面全部端点、`documents` / `upload` 的**摄取链路**(`POST /upload` 与「文件真的被读、
正文真的进上下文」那一段;`/__control/messages` 的 `documents=` **只是往消息上打那个键**,见 8.3)、
`voice_input` 的落库、
`mode_switch` 的语义、`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧
**只记录、不处置**。因此 App 里管理员页面、附件上传这类路径在这个假后端上跑不通。
⚠️ `GET /api/v1/media/{name}` **实现了**(语音播放那一半,见 8.1),但**不校验 Bearer**
—— 真后端那条是 `owner|admin` 端点(契约 U19)。
- **主动消息只能靠 `proactive_message` 帧造**:本工具不模拟 `backthink`(真后端也没有 HTTP 入口),
所以「她自己决定什么时候开口」这件**服务端**行为在这里验不到 —— 能验的是「帧到了客户端之后」那一段
(见第 7 节场景五)。同理,本工具**不会**自动补发 `message_persisted`:真后端走 IM 桥的主动消息会额外
+306 -10
View File
@@ -17,7 +17,9 @@
import argparse
import base64
import hashlib
import io
import json
import math
import os
import re
import struct
@@ -25,6 +27,7 @@ import sys
import threading
import time
import uuid
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, unquote, urlsplit
@@ -114,6 +117,155 @@ def now_ms() -> int:
return int(time.time() * 1000)
# —— 语音夹具(接收侧那条路:`voice` DTO 键 + `GET /api/v1/media/{name}`)———————
#
# 为什么要造假音频:**本端没有别的造法**。生产库里一条带 `voice` 的真消息都没有(拉 200 条 0 命中),
# 而语音的 `voice` 键**只在四个 REST 读取面**填充、实时帧与握手回放都没有(契约 v0.79 §4.4 覆盖率)
# ⇒ 想验「语音气泡 + 播放」,必须有一条**带 voice 键的历史消息**,而它只能由服务端造。
#
# 这份夹具是**按客户端解出来的形状**写的(`VoiceDto.kt` / `HistoryMessageDto.voice`),
# 不是真后端的权威形状;它只负责让那条路在设备上跑起来。
VOICE_FIXTURE_NAME = "fakevoice0000000000000000000000.wav"
VOICE_FIXTURE_MIME = "audio/wav"
VOICE_FIXTURE_DURATION_MS = 2000
def make_voice_wav(duration_ms: int = VOICE_FIXTURE_DURATION_MS, freq: float = 440.0, rate: int = 8000) -> bytes:
"""生成一段**真的能播**的 WAV(16-bit PCM 单声道,首尾各 20ms 淡入淡出)。
为什么是真音频而不是几个字节:这条路验的就是「MediaPlayer 放得出来」。给一串随便的字节,
客户端只会走到「这段语音本机播不了」那一档——那验的是失败分支,不是主路径。
纯标准库(`wave` + `math`),零依赖;每次启动现生成,不落盘(本工具不留持久数据)。
"""
frames = max(1, int(rate * duration_ms / 1000))
fade = max(1, int(rate * 0.02))
buffer = io.BytesIO()
with wave.open(buffer, "wb") as handle:
handle.setnchannels(1)
handle.setsampwidth(2)
handle.setframerate(rate)
samples = bytearray()
for index in range(frames):
gain = min(1.0, index / fade, (frames - index) / fade)
value = int(12000 * gain * math.sin(2 * math.pi * freq * index / rate))
samples += struct.pack("<h", value)
handle.writeframes(bytes(samples))
return buffer.getvalue()
def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS):
"""按控制端点给的种类造一个 `voice` 键的值;`none` ⇒ 不打这个键(返回 None)。
`kind` 的取值(都对着契约 §4.4 那几档,供设备上一档一档看):
| kind | `source` / `status` | 音频副本 | 设备上该看到什么 |
|---|---|---|---|
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文即转写文本 |
| `empty` | `asr` / `empty` | 有 | 同上,但正文为空 ⇒ 多一句「没有识别出文字」 |
| `failed` | `asr` / `failed` | 有 | 播放键照在,正文是服务端那行提示(假后端不会替你编,用 `text=` 给) |
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信那条路) |
"""
if kind in (None, "", "none"):
return None
name = VOICE_FIXTURE_NAME if kind != "nocopy" else None
duration = duration_ms if duration_ms > 0 else None
copy_keys = (
{"name": name, "mime": VOICE_FIXTURE_MIME, "size": len(VOICE_WAV), "url": "/api/v1/media/{}".format(name)}
if name
else {}
)
source = "platform" if kind == "nocopy" else "asr"
status = {"transcribed": "transcribed", "empty": "empty", "failed": "failed", "nocopy": "transcribed"}[kind]
payload = {"source": source, "status": status}
payload.update(copy_keys)
if duration is not None:
payload["durationMs"] = duration
return payload
# 夹具音频只生成一次(启动时),`size` 与真正发出去的字节因此是同一份。
VOICE_WAV = make_voice_wav()
IMAGE_MIME_BY_EXTENSION = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
}
def image_fixture(path: str) -> str:
"""本地图片文件 → base64 `data:` URI(`/__control/messages` 的 `image_file` 用它灌一条带图的消息)。
mime 按扩展名给,白名单与客户端 upload / 附件那一套同源(png / jpeg / gif / webp / bmp);
认不出的扩展名**直接报错**而不是编一个 `application/octet-stream`——客户端在字节那一步还会
自己嗅探(`ImageFormats.sniff`),这里给错只会让场景看起来和实际不是一回事。
为什么给 `data:` 而不是 URL:本工具**不代取外链**、也不起静态文件服务。`data:` 让客户端
(`ImageSource.Inline` → `ImageDataUri.bytes`)拿到的是**原样的字节**——预览里那些从字节里
现读的东西(分辨率、EXIF 拍摄日期)因此与源文件逐字节一致,不会被中间商转码掉。
"""
extension = os.path.splitext(path)[1].lower()
mime = IMAGE_MIME_BY_EXTENSION.get(extension)
if mime is None:
raise ValueError(
"unknown image extension {!r} (expected one of {})".format(
extension, ", ".join(sorted(IMAGE_MIME_BY_EXTENSION))
)
)
with open(path, "rb") as handle:
raw = handle.read()
if not raw:
raise ValueError("empty image file: {}".format(path))
return "data:{};base64,{}".format(mime, base64.b64encode(raw).decode("ascii"))
# —— 附件夹具(`documents` DTO 键)———————————————————————————————
#
# 为什么要造它:**纯附件消息**(`documents` 非空、`content` 为空)是「气泡外侧那枚时间」新补的那一档,
# 而它在库里**没有别的造法** —— 本工具不实现 upload / 摄取链路(见 README §11),
# `PATCH /api/v1/messages/{id}` 又要求 `content` 非空 ⇒ 一条这样的消息以前根本灌不出来。
#
# 形状按客户端解出来的写(`HistoryMessageDto.documents` → `DocumentRefDto.kt:67`)。与真后端的
# 一致处:**空 / 缺失就整键省略**(后端 `omitempty`),客户端把那两种形状都归一成空列表。
DOCUMENT_FIXTURE_NAME = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt"
DOCUMENT_FIXTURE_REASON = "这个夹具不实现真正的摄取,所以没有可注入的正文"
def documents_fixture(kind: str):
"""按控制端点给的种类造一个 `documents` 键的值;`none` ⇒ 不打这个键(返回 None)。
`kind` 三档对应芯片上看得见的那三种样子(文案出处 `DocumentChips.statusLabel`):
| kind | `status` | 设备上那枚芯片 |
|---|---|---|
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`,`reason` 那句原话留给预览弹层 |
只给**一枚**芯片:造这一档要验的是「有附件、没有正文时那枚时间挂在哪一块」,不是芯片排的换行。
"""
if kind in (None, "", "none"):
return None
if kind not in ("extracted", "listed", "unreadable"):
raise KeyError(kind)
payload = {
"name": DOCUMENT_FIXTURE_NAME,
"mime": "text/plain",
"size": 12,
"status": kind,
}
if kind == "extracted":
payload["chars"] = 10
if kind == "unreadable":
payload["reason"] = DOCUMENT_FIXTURE_REASON
return [payload]
def iso_now() -> str:
return datetime.now().astimezone().isoformat(timespec="milliseconds")
@@ -129,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
return "<redacted sha256:{}>".format(digest)
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
BODY_SUMMARY_STRING_MAX = 200
def summarize_body(raw) -> dict | None:
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
数组长度),只把超长字符串换成 `<N chars>`。
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
也不记大块内容。
"""
if not raw:
return None
try:
parsed = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return {"bytes": len(raw), "note": "not json"}
return _summarize_value(parsed)
def _summarize_value(value):
if isinstance(value, dict):
return {key: _summarize_value(item) for key, item in value.items()}
if isinstance(value, list):
return [_summarize_value(item) for item in value]
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
return "<{} chars>".format(len(value))
return value
class JsonlLog:
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
@@ -136,11 +319,17 @@ class JsonlLog:
def __init__(self, path: str):
self.path = path
self.lock = threading.Lock()
os.makedirs(os.path.dirname(path), exist_ok=True)
self.dir = os.path.dirname(path)
os.makedirs(self.dir, exist_ok=True)
def append(self, record: dict) -> None:
line = json.dumps(record, ensure_ascii=False)
with self.lock:
# 目录**每次写都补一次**:README 说「删掉 `out/` 即清空」,而人在服务跑着的时候删掉
# 它就再也不会自己回来——之后每一条请求都会在写日志时抛 FileNotFoundError,
# 表现成「假后端活着但什么都不回」(W76 实测踩到)。重建目录只是一次 stat,
# 代价可以忽略;让它自己长回来,比要求人先停服务再删要可靠。
os.makedirs(self.dir, exist_ok=True)
with open(self.path, "a", encoding="utf-8", newline="\n") as handle:
handle.write(line + "\n")
@@ -276,10 +465,17 @@ class Store:
documents=None,
created_at=None,
message_id=None,
voice=None,
image=None,
) -> dict:
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
[voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)——
只有控制端点能造出来,真后端的入站语音副本链本工具不模拟。
[image] 非空 = 这条带一张图(契约 §1.1:base64 `data:` URI 或 http(s) URL,两种形状客户端
都认,判据见 `ImageSource.of`)。本工具**不代取 URL**,所以只由控制端点灌 `data:` URI
(见 `/__control/messages` 的 `image_file`)——与 `voice` 同一性质:造场景用,不是后端行为。
"""
with self.lock:
session = self.sessions.get(session_id)
@@ -307,6 +503,10 @@ class Store:
message["mood"] = mood
if documents:
message["documents"] = documents
if voice:
message["voice"] = voice
if image:
message["image"] = image
session.messages.append(message)
return message
@@ -436,11 +636,15 @@ class Store:
with self.lock:
self.downstream_count += 1
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers) -> dict:
"""把收到的请求头**记下来**(鉴权头不校验,但要看得见)。
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
"""把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
也没有理由把它原文写进一个可能被 commit 的文件。
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
`out/http.jsonl` 里读得出来,不用再去猜。
"""
recorded_headers = {}
for key, value in headers.items():
@@ -460,6 +664,9 @@ class Store:
"query": query,
"headers": recorded_headers,
}
summary = summarize_body(body)
if summary is not None:
record["body"] = summary
self.http_log.append(record)
with self.lock:
self.http_count += 1
@@ -861,6 +1068,7 @@ MESSAGES_RE = re.compile(r"^/api/v1/sessions/([^/]+)/messages$")
SESSION_RE = re.compile(r"^/api/v1/sessions/([^/]+)$")
SESSION_READ_RE = re.compile(r"^/api/v1/sessions/([^/]+)/read$")
MESSAGE_RE = re.compile(r"^/api/v1/messages/([^/]+)$")
MEDIA_RE = re.compile(r"^/api/v1/media/([^/]+)$")
class Handler(BaseHTTPRequestHandler):
@@ -882,17 +1090,38 @@ class Handler(BaseHTTPRequestHandler):
query = parse_qs(parts.query, keep_blank_values=True)
return parts.path, query
def handle_one_request(self):
"""每个请求进来时**先把 body 缓存清掉**。
`protocol_version` 是 HTTP/1.1,同一条 keep-alive 连接上的多个请求由**同一个 Handler 实例**
依次处理(`handle_one_request` 循环),所以 `_read_body` 的缓存必须按请求隔离。少了这一步,
第 2 个请求会拿到第 1 个请求的 body(摘要记成上一份),而它自己那份**根本没人从 `rfile` 读走**
——残留字节留在内核缓冲里,被下一个请求的请求行一并吃掉,`BaseHTTPRequestHandler` 于是回
`501 Unsupported method ('{"sessionId":...}POST')`。客户端表现成「3 张图只投出去 2 张」,
而真正的锅在这里。
"""
self._body_read = False
self._body_cache = b""
super().handle_one_request()
def _read_body(self):
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
if getattr(self, "_body_read", False):
return self._body_cache
self._body_read = True
self._body_cache = b""
length = self.headers.get("Content-Length")
if not length:
return b""
return self._body_cache
try:
size = int(length)
except ValueError:
return b""
return self._body_cache
if size <= 0:
return b""
return self.rfile.read(size) or b""
return self._body_cache
self._body_cache = self.rfile.read(size) or b""
return self._body_cache
def _parse_json_body(self):
raw = self._read_body()
@@ -939,7 +1168,15 @@ class Handler(BaseHTTPRequestHandler):
def _handle(self, method: str):
path, query = self._split()
self.store.record_http(self._label(), method, path, query, self.headers)
# 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
body = (
self._read_body()
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
else b""
)
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
try:
if path == "/ws/chat":
self._handle_ws(method, query)
@@ -1096,8 +1333,40 @@ class Handler(BaseHTTPRequestHandler):
self._delete_message(message_id)
return
match = MEDIA_RE.match(path)
if match and method == "GET":
self._serve_media(unquote(match.group(1)))
return
self._fail(404, "not_found", "no such endpoint: {} {}".format(method, path))
def _serve_media(self, name: str) -> None:
"""`GET /api/v1/media/{name}`(契约 v0.79 §1.2 **U19**,语音副本播放端点)。
⚠️ **真后端要 Bearer `owner|admin`**(语音是私密内容);本工具**不校验任何鉴权**(README 首条),
所以这条端点在设备上验的是「客户端有没有把请求发对地方、字节能不能播」,**不**能验鉴权。
只认夹具那一个名字:其余(含穿越形态)一律 `404 media_not_found`——与真后端
「名字非法 / 穿越 / 不存在统一同码」的取向一致,也方便顺手验一次失败分支。
"""
if name != VOICE_FIXTURE_NAME:
self._fail(404, "media_not_found", "media not found")
return
data = VOICE_WAV
try:
self.send_response(200)
self.send_header("Content-Type", VOICE_FIXTURE_MIME)
self.send_header("Content-Length", str(len(data)))
self.send_header("Content-Disposition", 'inline; filename="{}"'.format(name))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "private, max-age=86400, immutable")
self.end_headers()
self.wfile.write(data)
log("http", "media {} bytes (voice fixture)".format(len(data)))
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
self.close_connection = True
def _create_session(self) -> None:
body, raw = self._parse_json_body()
if raw is not None and body is None:
@@ -1399,13 +1668,40 @@ class Handler(BaseHTTPRequestHandler):
prefix = self._param(query, "prefix", "seed")
template = self._param(query, "text", None)
platform = self._param(query, "platform", None)
voice_kind = self._param(query, "voice", None)
image_file = self._param(query, "image_file", None)
try:
voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS))
except KeyError:
self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none")
return
try:
image = image_fixture(image_file) if image_file else None
except (OSError, ValueError) as error:
self._fail(400, "bad_request", "image_file unusable: {}".format(error))
return
try:
documents = documents_fixture(self._param(query, "documents", None))
except KeyError:
self._fail(400, "bad_request", "documents must be one of extracted/listed/unreadable/none")
return
if session_id not in self.store.sessions:
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
return
first_id = last_id = None
for index in range(1, count + 1):
content = template.format(n=index) if template else "{} #{}".format(prefix, index)
message = self.store.add_message(session_id, role, content, platform=platform)
# `text=` **空串**与「不带 text」是两件事:前者是「就要一条没有正文的消息」
# (纯附件 / 纯图 / 无转写的语音都靠它造),后者走原来的 `seed #N` 默认正文。
content = template.format(n=index) if template is not None else "{} #{}".format(prefix, index)
message = self.store.add_message(
session_id,
role,
content,
platform=platform,
voice=voice,
image=image,
documents=documents,
)
first_id = first_id or message["id"]
last_id = message["id"]
with self.store.lock: