Compare commits
19
Commits
e0e657d5ee
...
android
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ab1ff004 | ||
|
|
576dd58a6b | ||
|
|
a9c940d39f | ||
|
|
f6eb93027d | ||
|
|
e94bdf6583 | ||
|
|
6aa434dad5 | ||
|
|
222f691b78 | ||
|
|
436c0f06f6 | ||
|
|
4a9e22ea77 | ||
|
|
6d158ca9bd | ||
|
|
b35b1b53ab | ||
|
|
504c500e20 | ||
|
|
db53de1a7e | ||
|
|
d716d26864 | ||
|
|
8babd1d2bb | ||
|
|
53d7a9fb69 | ||
|
|
5989e0d4eb | ||
|
|
4d25d9ec69 | ||
|
|
4e822df5ed |
No files matched your search
@@ -15,6 +15,16 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!--
|
||||
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
|
||||
|
||||
它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
|
||||
被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
|
||||
改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
|
||||
没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!--
|
||||
联网权限**显式**声明,不靠任何传递依赖。
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
/**
|
||||
* 一段待朗读的文本(`POST /api/v1/audio/speech` 的请求体,api-client.md §1.2 U6 / 行 126)。
|
||||
*
|
||||
* ## 只有两个字段,是**刻意的**
|
||||
* 契约给了五个键 `{text, voice?, format?, instruction?, sessionId?}`,但其中三个在本端**不生效**:
|
||||
* - `voice`:接受但不生效 —— 发音人**不由它决定**;
|
||||
* - `format`:不生效 —— 产物恒为 WAV(响应也明说「别照抄 `Content-Type`」,见 [SpeechApi]);
|
||||
* - `instruction`:只影响表达方式,本端当前的产品口径里没有「怎么说」这一档输入。
|
||||
*
|
||||
* 发一个服务端会忽略的键,只会让排查时多一条**看起来有含义、实际没有**的字段。所以这里只发
|
||||
* 真正起作用的那两个 —— 尤其是 [sessionId]:发音人由它**那条会话的生效人格**解析
|
||||
* (会话绑定人格 → 全局默认人格 → 都没有就取服务端配置的 default),**省略它不等于没有角色**。
|
||||
*
|
||||
* [sessionId] 为 `null` 时字段**整个缺席**(`ApiClientFactory.json` 的 `explicitNulls = false`),
|
||||
* 而不是发一个 `"sessionId": null`。
|
||||
*/
|
||||
@Serializable
|
||||
data class SpeechRequestDto(
|
||||
@SerialName("text") val text: String,
|
||||
@SerialName("sessionId") val sessionId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 语音合成(契约 **v0.96 增量 / v0.97 补写**,api-client.md §1.2 **U6** `POST /api/v1/audio/speech`)。
|
||||
*
|
||||
* ## 鉴权:owner-only,双凭据门
|
||||
* `Authorization: Bearer <owner JWT>` **或** `X-API-Key`,两个分支在服务端落到同一个
|
||||
* `authorize(RoleOwner)`。本端只走前者(主人 2026-10-26 拍板:不设 API Key 输入框),令牌由
|
||||
* [ApiClientFactory.create] 挂在 OkHttpClient 上的 `AuthInterceptor` 自动注入,本接口不碰它。
|
||||
* 无 / 无效凭据 → `401`,错误体是 §4.1 那个 `{error:{code,message}}` 信封。
|
||||
*
|
||||
* ## 响应**不是信封**
|
||||
* 成功是 `200` + **原始音频字节**(与 [MediaApi] 同一个形状),所以返回 [ResponseBody] 由上层
|
||||
* 读字节并负责关闭,[Streaming] 让它不一次性读进内存。
|
||||
*
|
||||
* ⚠️ **产物恒为 WAV,按字节嗅探判定,不要照抄 `Content-Type`** —— 那条头的实际值不在契约里
|
||||
* 承诺(§4.1 行 561 的同句旁注就是这个意思)。嗅探在 [top.yeij.cyrene.data.voice.SpeechClient]。
|
||||
*
|
||||
* 失败时的错误码(全部是 `{error:{code}}` 信封):
|
||||
* `400`(空 text)、`500 voice_unavailable`(未接语音)、`500 tts_failed`、
|
||||
* `500 tts_persona_unmapped`(人格没有发音人映射)、`500 tts_no_character`(连生效人格都没有)、
|
||||
* `500 tts_empty`(拿到的是静默空音频,同时响应头带 `X-Voice-Provider: silent`)。
|
||||
*
|
||||
* @see top.yeij.cyrene.data.voice.SpeechClient
|
||||
*/
|
||||
interface SpeechApi {
|
||||
|
||||
/**
|
||||
* 把 [body] 合成为一段 WAV 字节。
|
||||
*
|
||||
* 路径**不带前导 `/`**(与 [ChatApi] 一致):Retrofit 的 `baseUrl` 已含 `/` 结尾,
|
||||
* 带前导 `/` 会把它当成根路径、把部署在子路径下的服务端地址整段丢掉。
|
||||
*/
|
||||
@Streaming
|
||||
@POST("api/v1/audio/speech")
|
||||
suspend fun speech(@Body body: SpeechRequestDto): Response<ResponseBody>
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import kotlinx.serialization.Serializable
|
||||
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
|
||||
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
|
||||
*
|
||||
* ⚠️ [voice] 是这条「一处覆盖三条路」的**例外**:它只在四个 REST 读取面填充,实时帧与握手回放
|
||||
* 上缺席(理由写在 [voice] 的 KDoc 里)。
|
||||
* [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
|
||||
* `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
|
||||
* (前提与理由写在 [voice] 的 KDoc 里)。
|
||||
*/
|
||||
@Serializable
|
||||
data class HistoryMessageDto(
|
||||
@@ -64,18 +65,30 @@ data class HistoryMessageDto(
|
||||
val turnId: String? = null,
|
||||
/**
|
||||
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]);
|
||||
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事,判据只有一个:这个键在不在。
|
||||
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事。
|
||||
*
|
||||
* ## ⚠️ 覆盖率(契约 v0.79 如实):四个 REST 读取面才有,实时帧没有
|
||||
* **有** `voice` 的:`GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
|
||||
* ## 判据是**两个**问题,别再合成一个
|
||||
* - 「这条**来自**一段声音吗」= 这个键在不在(`domain.chat.hasVoice`);
|
||||
* - 「要不要画语音形态」= 键在 **且** `status != "failed"`(`domain.chat.isVoiceMessage`)。
|
||||
* 后端 M57-M59 的出站合成降级行(`{source:"tts", status:"failed", fallbackReason}`)与入站的
|
||||
* 转写失败行都是**键在、但没有可播的语音**:正文照常按文字渲染,另加一行灰色小标
|
||||
* (`domain.chat.voiceFallbackNote`),**不画**语音形态。按旧的单条判据(键在即语音)它们会
|
||||
* 被画成一个点下去必然播不了的气泡。
|
||||
*
|
||||
* ## 覆盖率(契约 v0.97):**全部读面都带**,第一条帧就带
|
||||
* `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
|
||||
* `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与
|
||||
* `message_edited` 广播帧。
|
||||
* `message_edited` 广播帧,**外加** WS 握手 `history` 回放与 `message_persisted` /
|
||||
* `channel_persisted` 落库帧——后三处由后端 M44(`fb55769`)修好:落库顺序改为
|
||||
* **INSERT → 写 `voice` 列 → notify**,于是语音消息的**第一条**落库帧就带 `voice`
|
||||
* (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。
|
||||
*
|
||||
* **没有** `voice` 的:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放
|
||||
* ——入站语音的 `voice` 列是**落库之后**回填的,而落库帧在**落库那一刻**发出,天生拿不到
|
||||
* 这个值。所以**不要在实时帧上等 `voice`**(本端也刻意不为它加任何重试 / 轮询):实时到达的
|
||||
* 语音消息先按普通文本显示(转写文本本来就在 [content] 里),**等下一次拉取自动升级**成语音气泡。
|
||||
* 这是设计,不是缺陷。
|
||||
* 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底:
|
||||
* `voice` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。
|
||||
*
|
||||
* ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify;
|
||||
* ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
|
||||
* 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。
|
||||
*
|
||||
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
|
||||
* 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一
|
||||
|
||||
@@ -6,17 +6,23 @@ import kotlinx.serialization.Serializable
|
||||
* 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**,
|
||||
* `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。
|
||||
*
|
||||
* ## 🎤 判据 = 本键存在
|
||||
* ## 🎤 判据 = 本键存在 **且** `status != "failed"`
|
||||
* `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。
|
||||
* 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]`
|
||||
* 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。
|
||||
* 判据落在一处:`domain.chat.isVoiceMessage`。
|
||||
*
|
||||
* 键在只说明「这条来自一段声音」;要不要画语音形态还看 `status`(见下)。
|
||||
* 判据落在一处:`domain.chat.isVoiceMessage`(`domain.chat.hasVoice` 答的是前半个问题)。
|
||||
*
|
||||
* ## 三态 `status`(闭集)
|
||||
* `transcribed`(正文即转写文本)/ `empty`(转写成功但没识别出文字)/ `failed`(没拿到转写文本
|
||||
* ——**正文是服务端给的那行中文诚实提示**)。客户端**按 `status` 分支,绝不按正文文案分支**;
|
||||
* 设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
|
||||
* 无字节一律只进服务端日志),所以本端**不猜**细分。
|
||||
* `transcribed`(正文即转写文本;出站方向 = 合成成功)/ `empty`(转写成功但没识别出文字)/
|
||||
* `failed`(**没有可播的语音**——入站没拿到转写文本,出站是合成降级回文字)。客户端**按 `status`
|
||||
* 分支,绝不按正文文案分支**;设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 /
|
||||
* 限流 / 超时 / 格式不支持 / 无字节一律只进服务端日志),所以本端**不猜**细分。
|
||||
*
|
||||
* ## `fallbackReason` 只在 `failed` 时有
|
||||
* 出站方向(`source = "tts"`)合成降级时,服务端在这个键里给出降级原因;本端**原样**小字展示
|
||||
* (`domain.chat.voiceFallbackNote`),**不猜闭集**——取值集合后端尚未定稿,见那个函数的 KDoc。
|
||||
*
|
||||
* ## `name` / `mime` / `size` / `url` 四者同生共死
|
||||
* 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。
|
||||
@@ -39,7 +45,11 @@ import kotlinx.serialization.Serializable
|
||||
data class VoiceDto(
|
||||
/**
|
||||
* `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的
|
||||
* `voice_item.text`)。见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
|
||||
* `voice_item.text`)/ `"tts"`(我们自己合成的出站语音,后端 M57-M59)。
|
||||
* 见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
|
||||
*
|
||||
* 接收侧**不按 `source` 分支**:合成成功的那条与入站语音同形(`status = transcribed` +
|
||||
* 四键副本),走同一条渲染路径。
|
||||
*/
|
||||
val source: String = "",
|
||||
/**
|
||||
@@ -47,6 +57,9 @@ data class VoiceDto(
|
||||
*
|
||||
* 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值
|
||||
* (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。
|
||||
*
|
||||
* ⚠️ 只有 `"failed"` 是**没有可播的语音**的那一态(`domain.chat.isPlayableVoice`);认不出的
|
||||
* 取值一律当可播(fail-open,理由写在那个函数的 KDoc 里)。
|
||||
*/
|
||||
val status: String = "",
|
||||
/** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */
|
||||
@@ -57,6 +70,17 @@ data class VoiceDto(
|
||||
val size: Long? = null,
|
||||
/** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */
|
||||
val durationMs: Int? = null,
|
||||
/**
|
||||
* 合成降级的原因(后端 M57-M59 预告,`devdocs/02` 已登记;契约 **v0.98 的正式回执尚未到**)。
|
||||
*
|
||||
* 只在 `status == "failed"` 时有意义,其余状态下服务端不会下发(`omitempty`);入站转写失败
|
||||
* 也不带它——入站的失败原因由正文那行诚实提示承载。
|
||||
*
|
||||
* 本端**原样**小字展示(`domain.chat.voiceFallbackNote`),**不猜闭集、不做映射**:取值集合
|
||||
* 后端还没定(`tts_timeout` 之类是我们看到的预告示例,不是契约)。缺席 / 空白时那一行小标
|
||||
* 退化成固定的一句(`VOICE_FALLBACK_NOTE`),不白屏。
|
||||
*/
|
||||
val fallbackReason: String? = null,
|
||||
/** 副本的服务端相对路径(`/api/v1/media/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
|
||||
val url: String? = null,
|
||||
)
|
||||
@@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext
|
||||
* 三条读取出口共用同一个端点,只是给的查询参数不同:
|
||||
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
|
||||
* - [search]:`q` 会话内搜索(也是倒序);
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
|
||||
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
|
||||
*
|
||||
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
|
||||
*/
|
||||
@@ -87,7 +88,9 @@ class MessageRepository(
|
||||
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
|
||||
*
|
||||
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
|
||||
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
|
||||
* 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
|
||||
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
|
||||
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
|
||||
*/
|
||||
suspend fun loadAfter(
|
||||
sessionId: String,
|
||||
@@ -100,6 +103,87 @@ class MessageRepository(
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/**
|
||||
* **增量追尾**(W77):从 [after] 起把某会话的新消息一直翻到尾巴,最多 [maxPages] 页。
|
||||
*
|
||||
* 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。
|
||||
*
|
||||
* ## 什么时候只能说 `complete = false`
|
||||
* 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于:
|
||||
* `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被
|
||||
* 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次也问不到
|
||||
* (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。
|
||||
*
|
||||
* 要紧的是**切点**:只有当服务端的 `LIMIT` 在某一毫秒中间切过一刀,才会留下追不回来的尾巴。
|
||||
* 客户端看不见那一刀落在哪儿,只知道自己**拿过满页**就等于「发生过一次切点」。所以这里只有一条
|
||||
* 不变式,下面几档都是它的推论:
|
||||
*
|
||||
* > `complete = true` **当且仅当整个追尾从未拿过满页**。
|
||||
*
|
||||
* - **第一页就是空页 ⇒ `true`**:一条新消息都没有,没有可少给的;
|
||||
* - **第一页就不满 ⇒ `true`**:`LIMIT` 没切到任何东西,`> cursor` 的行全在这一页里;
|
||||
* - **拿过整页之后,下一页是空页 ⇒ `false`**:那次空页分不出「干净的尾巴」与「空页底下还压着
|
||||
* 同一毫秒的几条」;
|
||||
* - **拿过整页之后,下一页不满 ⇒ 也是 `false`**:不满只说明「`> cursor` 的行就这么多了」,对
|
||||
* 切点那一毫秒还有没有行**一无所知**。例如积压 `m1@500 m2@500 m3@500 m4@600`、页宽 2:
|
||||
* 第一页给 `m1 m2`(切点正好劈开 500 那一毫秒),第二页 `after=500` 只给 `m4@600`、不满页,
|
||||
* 而 `m3@500` 既不 `> 500`、也就永远拿不到;
|
||||
* - **翻满 [maxPages] ⇒ `false`**。
|
||||
*
|
||||
* 代价:积压一到页宽(默认 50 条)就回落整表。这与「翻满 [maxPages] ⇒ 回落」同类——那时增量
|
||||
* 已经取回 ≥50 行,多问一次整表是边际成本,也正合 [MAX_INCREMENT_PAGES] 那句「积压多到增量不再
|
||||
* 便宜」。主路径(几条新消息、第一页就不满)不受影响。
|
||||
*
|
||||
* 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。
|
||||
*
|
||||
* 不传 `platform`:未读面上的口径是服务端 `count(created_at > read_at)`(`unreadCountQuery`,
|
||||
* 不带平台过滤),按平台过滤会让本地数出来的条数**与服务端的 `unreadCount` 不同源**。
|
||||
*
|
||||
* @param after 本端**已见**的最大 `createdAt`(毫秒);服务端返回严格新于它的消息,升序。
|
||||
* @param maxPages 最多翻几页(默认 [MAX_INCREMENT_PAGES])。用尽即 `complete = false`:
|
||||
* 一次静默刷新不该为了补一个角标翻十几页。
|
||||
*/
|
||||
suspend fun loadIncrement(
|
||||
sessionId: String,
|
||||
after: Long,
|
||||
pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||
maxPages: Int = MAX_INCREMENT_PAGES,
|
||||
platform: String? = null,
|
||||
): Result<MessageIncrement> = withContext(dispatcher) {
|
||||
val size = pageSize.coerceIn(1, MAX_PAGE_SIZE)
|
||||
val byId = LinkedHashMap<String, HistoryMessageDto>()
|
||||
var cursor = after
|
||||
// 只要翻到过一整页,就说明服务端的 LIMIT 切过一刀,那一刀可能劈开了一毫秒(见 KDoc)。
|
||||
// 此后无论下一页是空页还是不满页,都说不出「追到了尾巴」。
|
||||
var chasedFullPage = false
|
||||
|
||||
fun done(at: Long, complete: Boolean) = Result.success(
|
||||
MessageIncrement(messages = byId.values.sortedWith(MessageOrder), cursor = at, complete = complete),
|
||||
)
|
||||
|
||||
var page = 0
|
||||
while (page < maxPages.coerceAtLeast(1)) {
|
||||
page++
|
||||
val raw = apiCall { api.listMessages(sessionId, size, before = null, platform = platform, after = cursor) }
|
||||
.getOrElse { return@withContext Result.failure(it) }
|
||||
val paged = toPagedMessages(raw, size)
|
||||
// 空页有两种含义,客户端分不出来:第一页就空 = 真的没有新的;拿过一整页之后空 = 可能还有
|
||||
// 同一毫秒的几条卡在游标上(服务端只给严格大于)。后者只能说「没追完」。
|
||||
if (paged.messages.isEmpty()) {
|
||||
return@withContext done(cursor, complete = !chasedFullPage)
|
||||
}
|
||||
// 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。
|
||||
paged.messages.forEach { byId[it.id] = it }
|
||||
val newest = paged.newestCreatedAt ?: return@withContext done(cursor, complete = false)
|
||||
// 不满页只说明「> cursor 的行就这么多了」,对切点那一毫秒一无所知 —— 与空页档同一个洞,
|
||||
// 同样是拿过整页之后就不作数。
|
||||
if (!paged.hasMore) return@withContext done(newest, complete = !chasedFullPage)
|
||||
cursor = newest
|
||||
chasedFullPage = true
|
||||
}
|
||||
done(cursor, complete = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
|
||||
* 完整消息 DTO**。
|
||||
@@ -152,6 +236,15 @@ class MessageRepository(
|
||||
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
|
||||
const val DEFAULT_SEARCH_PAGE_SIZE = 30
|
||||
|
||||
/**
|
||||
* [loadIncrement] 一次最多翻几页(500 条)。
|
||||
*
|
||||
* 这个上限只为一件事存在:一次**后台**的顺手对齐不该翻十几页。越过它就说明积压已经多到
|
||||
* 「增量」不再便宜,那时整表(`GET /sessions` 一次拿回权威的 `unreadCount` 与预览)
|
||||
* 反而更划算——调用方据此回落,见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*/
|
||||
const val MAX_INCREMENT_PAGES = 10
|
||||
|
||||
private val EMPTY_PAGE = PagedMessages(
|
||||
messages = emptyList(),
|
||||
hasMore = false,
|
||||
@@ -190,3 +283,18 @@ private fun toPagedMessages(
|
||||
newestCreatedAt = ascending.lastOrNull()?.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [MessageRepository.loadIncrement] 的结论:升序增量消息 + 推进后的游标 + 有没有真的追到尾巴。
|
||||
*
|
||||
* [complete] 为 `false` 时 [messages] 仍是**已拿到的全部**(不丢),但调用方不能把它当成
|
||||
* 「这之后就没有了」——未读面上那种情况要回落到整表,见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*
|
||||
* 放在这里而不是 `PagedMessages.kt`:`PagedMessages` 是纯分页形状(一页的元数据),
|
||||
* 这个类型是 [MessageRepository] 翻页循环的产物,跟着它的生产者走。
|
||||
*/
|
||||
data class MessageIncrement(
|
||||
val messages: List<HistoryMessageDto>,
|
||||
val cursor: Long,
|
||||
val complete: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SpeechApi
|
||||
import top.yeij.cyrene.data.api.SpeechRequestDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCache
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** 服务端回的字节**不是**一段 WAV(200 却回了 JSON 信封 / HTML 错误页 / 别的东西)。 */
|
||||
class SpeechNotAudioException : Exception("speech response is not wav")
|
||||
|
||||
/**
|
||||
* 一次 TTS 请求的**字节嗅探**:真的是 RIFF/WAVE 吗?
|
||||
*
|
||||
* 契约(api-client.md §4.1 行 561 的旁注、§1.2 U6)只说「产物恒为 WAV」,**没有**承诺
|
||||
* `Content-Type` 是什么,并明写「不要照抄 `Content-Type`」。所以判据只能落在字节上:
|
||||
* 头 4 字节 `RIFF` + 偏移 8 起的 4 字节 `WAVE`(RIFF 容器里夹一段 `fmt ` 那种标准布局)。
|
||||
*
|
||||
* 为什么要判:`MediaPlayer` 拿到非音频字节时抛的是它自己那套 `setDataSource` 异常,
|
||||
* 与本端「取字节失败」的语义完全不同;而 200 回 JSON 信封恰恰是**最可能**的一种
|
||||
* 「网关/反代把错误包了一层」的形态 —— 提前拦在这里,失败就被如实归到「解析失败」那一档,
|
||||
* 而不是在播放器里炸成一个没人认识的东西。
|
||||
*/
|
||||
fun isWavBytes(bytes: ByteArray): Boolean =
|
||||
bytes.size >= WAV_HEADER_BYTES &&
|
||||
bytes[0] == 'R'.code.toByte() &&
|
||||
bytes[1] == 'I'.code.toByte() &&
|
||||
bytes[2] == 'F'.code.toByte() &&
|
||||
bytes[3] == 'F'.code.toByte() &&
|
||||
bytes[8] == 'W'.code.toByte() &&
|
||||
bytes[9] == 'A'.code.toByte() &&
|
||||
bytes[10] == 'V'.code.toByte() &&
|
||||
bytes[11] == 'E'.code.toByte()
|
||||
|
||||
/** RIFF 头里本端要看的那几个字节都在前 12 个之内。 */
|
||||
private const val WAV_HEADER_BYTES = 12
|
||||
|
||||
/** 本端一次读得下、也放得动的合成音频上限(与语音副本同一把尺子:附件上限 20 MB)。 */
|
||||
private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
|
||||
|
||||
/**
|
||||
* 「把一段文字念出来」的取字节层(`POST /api/v1/audio/speech` 的唯一调用方,契约 api-client.md
|
||||
* §1.2 **U6**,v0.96 增量 + v0.97 补写)。
|
||||
*
|
||||
* ## 凭据:只走 Bearer,不设 API Key
|
||||
* 服务端是 owner-only 的**双凭据门**(Bearer owner JWT **或** `X-API-Key`,两个分支落到同一个
|
||||
* `authorize(RoleOwner)`)。本端只走前者:令牌由 [ApiClientFactory.create] 挂在 OkHttpClient 上的
|
||||
* `AuthInterceptor` 注入,并顺带享受「带令牌仍 401 ⇒ 单飞续期并重试一次」。
|
||||
* 主人 2026-10-26 拍板**不加 API Key 输入框** —— 那会多一份要保管、要轮换、要泄漏的凭据,
|
||||
* 而本端本来就已经有 owner 会话。
|
||||
*
|
||||
* ## 发音人从哪来:`sessionId`,不是 `voice`
|
||||
* 契约里 `voice` **接受但不生效**、`format` **不生效**。真正决定发音人的是 `sessionId` 那条会话的
|
||||
* **生效人格**(会话绑定 → 全局默认 → 服务端配置的 default)。所以 [speak] 一定要拿到当前会话 id
|
||||
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。
|
||||
*
|
||||
* ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次
|
||||
* 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
|
||||
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
|
||||
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
|
||||
* `documents/` 根下,不扩大暴露面一个字节)。
|
||||
*
|
||||
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
|
||||
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
|
||||
*
|
||||
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
|
||||
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
|
||||
* 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit]
|
||||
* (非空才算)——它是 `cacheDir` 下的东西,系统随时可回收,被回收之后下次朗读重新请求,
|
||||
* **不是**错误。
|
||||
*
|
||||
* ## 错误怎么分类
|
||||
* 非 2xx 一律走 [ApiErrorMapper](`401` ⇒ [CyreneError.Unauthorized],其余 ⇒
|
||||
* [CyreneError.Server] 且**保留服务端那句 `code`**——`tts_persona_unmapped` /
|
||||
* `tts_no_character` 这两档的文案完全不同,折成一句话就说不清了)。
|
||||
* 分类成「柔和提示」还是「明确降级」是纯函数的事,在
|
||||
* [top.yeij.cyrene.domain.chat.ttsFailureOf] 里,本类不做产品判断。
|
||||
*
|
||||
* @param baseUrlProvider 每次现读服务器地址(换服务器 ⇒ 客户端重建,与 `AppContainer` 同一纪律)。
|
||||
* @param apiFactory 按 baseUrl 造 [SpeechApi];收成 lambda 是为了单测能塞一个直连本地 HTTP 替身的
|
||||
* 实例,而不必造一整套依赖注入。
|
||||
*/
|
||||
class SpeechClient(
|
||||
private val baseUrlProvider: () -> String,
|
||||
private val tokenProvider: () -> String? = { null },
|
||||
private val unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
private val cacheDir: File,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val apiFactory: (String) -> SpeechApi = { baseUrl ->
|
||||
ApiClientFactory.create(
|
||||
baseUrl,
|
||||
SpeechApi::class.java,
|
||||
tokenProvider,
|
||||
unauthorizedPolicy,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
/** 按 baseUrl 缓存 Retrofit 实例(换地址才重建),与 `AppContainer.mediaApi` 同一手法。 */
|
||||
private var cachedApi: SpeechApi? = null
|
||||
private var cachedBaseUrl: String? = null
|
||||
|
||||
private fun api(): SpeechApi {
|
||||
val baseUrl = baseUrlProvider()
|
||||
val cached = cachedApi
|
||||
if (cached != null && cachedBaseUrl == baseUrl) return cached
|
||||
return apiFactory(baseUrl).also {
|
||||
cachedApi = it
|
||||
cachedBaseUrl = baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 [text] 合成成一段 WAV 并落盘,返回**可直接交给 `MediaPlayer`** 的那份文件。
|
||||
*
|
||||
* [sessionId] 是当前会话 id:它决定发音人(见类 KDoc)。传 `null` 表示「本端不知道是哪个会话」,
|
||||
* 服务端会退到全局默认人格 —— 调用方**不要**为了让字段好看而编一个 id 出来。
|
||||
*/
|
||||
suspend fun speak(text: String, sessionId: String?): Result<File> = withContext(dispatcher) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
// 服务端对空 text 回 `400`;本端连请求都不发(省一次往返,也省一次无意义的失败)。
|
||||
return@withContext Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
}
|
||||
val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId, baseUrlProvider()))
|
||||
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
|
||||
|
||||
try {
|
||||
val response = api().speech(SpeechRequestDto(text = trimmed, sessionId = sessionId))
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> body.use { stream ->
|
||||
val bytes = readBounded(stream.byteStream(), MAX_SPEECH_BYTES)
|
||||
when {
|
||||
// 超过上限:本端一次读不下(合成音频远小于它,走到这里说明对面不对劲)。
|
||||
bytes == null -> Result.failure(SpeechNotAudioException())
|
||||
// 0 字节:与 MediaRepository 同一口径,判解析失败而不是「拿到一段静音」。
|
||||
bytes.isEmpty() -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
// 200 却不是 WAV:**绝不**把 JSON / HTML 喂给 MediaPlayer(见 isWavBytes)。
|
||||
!isWavBytes(bytes) -> Result.failure(SpeechNotAudioException())
|
||||
else -> cache(file, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 2xx 却没有响应体:与 apiCall 同一口径。
|
||||
response.isSuccessful -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.failure(
|
||||
CyreneException(
|
||||
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun cache(file: File, bytes: ByteArray): Result<File> = try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(file)
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——绝不退回「把字节留在内存里等播放器
|
||||
// 读」那条路:MediaPlayer 只吃路径,而缓存目录写不进去时多半写得进的地方也没有。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 合成缓存的名字前缀:与语音副本(服务端内容寻址名)在同一目录里区分开。 */
|
||||
const val NAME_PREFIX = "tts_"
|
||||
|
||||
/** sha256 前 32 位十六进制,与服务端存储名 / [DocumentCache.localName] 同一长度。 */
|
||||
const val NAME_HEX_LENGTH = 32
|
||||
|
||||
/**
|
||||
* 缓存名 = `tts_<sha256(baseUrl \n sessionId \n text)[:32]>.wav`。
|
||||
*
|
||||
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
|
||||
* 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
|
||||
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
|
||||
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
|
||||
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
|
||||
*
|
||||
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
|
||||
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
|
||||
*/
|
||||
fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(
|
||||
"${baseUrl.trimEnd('/')}\n${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH)
|
||||
return NAME_PREFIX + hex + ".wav"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.chat.VoiceClip
|
||||
|
||||
/** 录音机的两个可见态(界面只认这两个)。 */
|
||||
sealed interface VoiceRecorderState {
|
||||
|
||||
/** 没在录。 */
|
||||
data object Idle : VoiceRecorderState
|
||||
|
||||
/**
|
||||
* 正在录。
|
||||
*
|
||||
* [elapsedMs] 由**已收到的字节数**反算([VoiceClip.durationMillisOf]),不是另起一个计时器——
|
||||
* 两个时钟一定会漂,而屏幕上那个数字必须与最终发出去的音频长度一致。
|
||||
*
|
||||
* [reachedLimit] = 已经录满 [VoiceClip.MAX_RECORDING_MILLIS],此后的字节被**丢弃**(不是
|
||||
* 悄悄截断已经收到的部分)。UI 看到它应当立刻收尾发送,见本类注释。
|
||||
*/
|
||||
data class Recording(val elapsedMs: Int, val reachedLimit: Boolean) : VoiceRecorderState
|
||||
}
|
||||
|
||||
/**
|
||||
* 麦克风录音机:`AudioRecord` 收 PCM → 交给 [VoiceClip] 封 WAV(本类不碰封装,见那里的 KDoc)。
|
||||
*
|
||||
* ## 为什么自己持有一个作用域,而不复用调用方的 `rememberCoroutineScope`
|
||||
* 读循环必须活到 [stop] 真的收尾:调用方的组合一旦被丢弃(转屏 / 退到后台那一刻重建),
|
||||
* 挂在它上面的协程会被取消,而 USB/内置麦克风已经交给系统的那一块缓冲就再也取不回来——
|
||||
* 用户按下停止却拿到一个缺尾的片段。所以读循环跑在这个类自己的 `SupervisorJob + IO` 上,
|
||||
* 由 [release] 明确收掉。
|
||||
*
|
||||
* ## 满了不自动发送,只置位
|
||||
* 到 [VoiceClip.MAX_RECORDING_MILLIS] 时把 [VoiceRecorderState.Recording.reachedLimit] 置真、
|
||||
* 继续把多余的字节丢掉。**发送时机由 UI 决定**(它要先把「正在录」那一行换回输入栏,再交给
|
||||
* VM):本类不知道 VM 也不发帧,这条边界让它在单测里不必造一个 VM。
|
||||
*
|
||||
* ## 权限
|
||||
* 调用方必须先拿到 `RECORD_AUDIO`(运行时权限)。本类**不申请**也不检查:`AudioRecord`
|
||||
* 在没权限时会抛 `SecurityException`,[start] 把它连同「设备不支持 / 初始化失败」一起收敛成
|
||||
* `false`,由 UI 按「这台设备现在开不了麦克风」说一句,**绝不静默**。
|
||||
*/
|
||||
class VoiceRecorder(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
||||
|
||||
/** 当前录音状态(界面据此显示「录音中 0:07」)。 */
|
||||
val state: StateFlow<VoiceRecorderState> = _state.asStateFlow()
|
||||
|
||||
private var record: AudioRecord? = null
|
||||
|
||||
/** 收集到的 PCM;只有 [start] 建、[stop]/[cancel] 取走或丢掉。 */
|
||||
private var collected: ByteArrayOutputStream? = null
|
||||
|
||||
private var reader: Job? = null
|
||||
|
||||
/**
|
||||
* 录音代次:每次 [start] 自增。
|
||||
*
|
||||
* 只服务一件事:读循环的 `finally` 里**只在还属于这一代**时才把状态收回 [VoiceRecorderState.Idle]。
|
||||
* 少了它,「取消 → 立刻重录」的窄窗口里,上一轮的收尾会把新那一轮的「录音中」抹成「没在录」,
|
||||
* 而麦克风其实还在收字节——界面上什么都不显示,用户以为自己没在录。
|
||||
*/
|
||||
private var generation = 0
|
||||
|
||||
@Volatile
|
||||
private var collecting = false
|
||||
|
||||
/**
|
||||
* 开始录音。
|
||||
*
|
||||
* @return `false` = 这台设备现在起不了麦克风(没有权限 / 设备被占用 / 不支持的采样参数)。
|
||||
* 调用方必须**如实告知**(不得假装在录)。
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(): Boolean {
|
||||
if (collecting) return false
|
||||
val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_MASK, ENCODING)
|
||||
if (minBuffer <= 0) return false
|
||||
val recorder = runCatching {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(ENCODING)
|
||||
.setSampleRate(SAMPLE_RATE_HZ)
|
||||
.setChannelMask(CHANNEL_MASK)
|
||||
.build(),
|
||||
)
|
||||
// 缓冲给到最小值的两倍:读循环偶尔晚一拍时不至于立刻丢帧。
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
}.getOrNull() ?: return false
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
runCatching { recorder.release() }
|
||||
return false
|
||||
}
|
||||
val started = runCatching { recorder.startRecording() }.isSuccess
|
||||
if (!started) {
|
||||
runCatching { recorder.release() }
|
||||
return false
|
||||
}
|
||||
record = recorder
|
||||
val sink = ByteArrayOutputStream(INITIAL_PCM_BYTES)
|
||||
collected = sink
|
||||
collecting = true
|
||||
val current = ++generation
|
||||
_state.value = VoiceRecorderState.Recording(elapsedMs = 0, reachedLimit = false)
|
||||
reader = scope.launch { readLoop(recorder, sink, current) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并交出这一段 PCM;`null` = 没有可发的字节(没在录 / 读失败 / 全被丢弃)。
|
||||
*
|
||||
* `suspend` 是刻意的:必须等读循环真的收尾([reader] 的 `join`)才去读那份缓冲,
|
||||
* 否则最后一块还没写进去就被取走——用户会听到句尾少一截。
|
||||
*/
|
||||
suspend fun stop(): ByteArray? {
|
||||
if (!collecting) return null
|
||||
collecting = false
|
||||
reader?.join()
|
||||
reader = null
|
||||
releaseRecorder()
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
val sink = collected ?: return null
|
||||
collected = null
|
||||
return sink.toByteArray().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** 取消:丢掉已经收到的字节,一个都不发(用户点了「取消」)。 */
|
||||
fun cancel() {
|
||||
collecting = false
|
||||
reader?.cancel()
|
||||
reader = null
|
||||
releaseRecorder()
|
||||
collected = null
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
}
|
||||
|
||||
/** 组合被丢弃时调(等价于 [cancel]:一段没发出去的录音不该在后台继续占着麦克风)。 */
|
||||
fun release() = cancel()
|
||||
|
||||
/**
|
||||
* 读循环:一块一块收进 [sink],顺手更新已录时长;满了之后**丢字节但不打断录音**
|
||||
* (见类注释「满了不自动发送」)。
|
||||
*
|
||||
* 读出错(`read < 0`)就结束这一次录音:硬件被抢走、设备被拔出都会走到这里,此时
|
||||
* 已经收到的部分仍然有效([stop] 照常交出它们)。
|
||||
*
|
||||
* @param generation 启动时那一代的编号:收尾只在自己还是「当前这一代」时才改状态。
|
||||
*/
|
||||
private suspend fun readLoop(recorder: AudioRecord, sink: ByteArrayOutputStream, generation: Int) {
|
||||
val buffer = ByteArray(READ_BYTES)
|
||||
try {
|
||||
while (collecting) {
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
if (read == 0) continue
|
||||
val used = sink.size()
|
||||
if (used < VoiceClip.MAX_PCM_BYTES) {
|
||||
sink.write(buffer, 0, minOf(read, VoiceClip.MAX_PCM_BYTES - used))
|
||||
}
|
||||
val elapsed = VoiceClip.durationMillisOf(minOf(sink.size(), VoiceClip.MAX_PCM_BYTES))
|
||||
_state.value = VoiceRecorderState.Recording(
|
||||
elapsedMs = elapsed,
|
||||
reachedLimit = elapsed >= VoiceClip.MAX_RECORDING_MILLIS,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// 读循环自己结束(出错 / 被取消)时也要把状态收回 Idle,否则界面会永远停在「录音中」。
|
||||
// 只在自己还是当前这一代时才收:否则「取消 → 立刻重录」的窄窗口里,上一轮的收尾
|
||||
// 会把新一轮的「录音中」擦掉。
|
||||
if (generation == this.generation && _state.value is VoiceRecorderState.Recording) {
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 `AudioRecord` 拆干净(`stop` 在没起录时抛 `IllegalStateException`,所以都包在 `runCatching` 里)。 */
|
||||
private fun releaseRecorder() {
|
||||
val current = record ?: return
|
||||
record = null
|
||||
runCatching { current.stop() }
|
||||
runCatching { current.release() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SAMPLE_RATE_HZ: Int = VoiceClip.SAMPLE_RATE_HZ
|
||||
const val ENCODING: Int = AudioFormat.ENCODING_PCM_16BIT
|
||||
const val CHANNEL_MASK: Int = AudioFormat.CHANNEL_IN_MONO
|
||||
|
||||
/**
|
||||
* 一次读多少字节:`2048` = 16 kHz 下单声道 16-bit 的 64 ms(1024 帧)。
|
||||
* 比这更小则更新频率过高(每秒几十次状态写),更大则「停止」那一刻的延迟可感。
|
||||
*/
|
||||
const val READ_BYTES: Int = 2048
|
||||
|
||||
/** 初始缓冲按 10 秒预分配(够大多数语音,长的那条会自己扩容)。 */
|
||||
val INITIAL_PCM_BYTES: Int = VoiceClip.pcmBytesFor(10_000)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,37 @@ class ChatSocket(
|
||||
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
|
||||
|
||||
/**
|
||||
* 发送 `voice_input`(契约 v0.96 §3.15):一段 base64 音频 + 格式,走与 `chat` 同一条出站通道。
|
||||
*
|
||||
* ## 为什么没有「离线队列」
|
||||
* 文本那条有补发队列(内容只是一小段字符串,留着不贵);语音的 audio 是**几 MB 的 base64**
|
||||
* 内联在帧里,队列化它等于把这段内存钉在待发状态里,而重连之后服务端到底要不要它、要哪一段
|
||||
* 又都是用户该重新决定的事。所以本层与 [sendChat] 一样:**发不出去就回 `false`**,由
|
||||
* `ChatViewModel` 把这段留在内存里、让用户按「重发」(复用同一个 `clientMsgId`)。
|
||||
*
|
||||
* @param audio base64(单行,`Base64.NO_WRAP`)。
|
||||
* @param format 契约白名单 `wav|mp3|ogg` 之一;本端恒 `"wav"`。
|
||||
* @param deviceId 与 `chat` 帧同一份来源标识。
|
||||
* @param clientMsgId 幂等键:同一段语音重发**必须复用**(否则服务端会把同一句话说两遍)。
|
||||
* @return 未连接 / 写失败返回 `false`。
|
||||
*/
|
||||
fun sendVoiceInput(
|
||||
sessionId: String?,
|
||||
audio: String,
|
||||
format: String,
|
||||
deviceId: String?,
|
||||
clientMsgId: String?,
|
||||
): Boolean = send(
|
||||
WsOutbound.VoiceInput(
|
||||
audio = audio,
|
||||
format = format,
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
clientMsgId = clientMsgId,
|
||||
),
|
||||
)
|
||||
|
||||
private fun send(outbound: WsOutbound): Boolean {
|
||||
if (_state.value != WsState.Connected) return false
|
||||
val socket = webSocket ?: return false
|
||||
|
||||
@@ -600,12 +600,30 @@ sealed class WsEvent {
|
||||
override val type: String get() = "observability_log"
|
||||
}
|
||||
|
||||
/** 语音合成回复(定向回请求连接)。 */
|
||||
/**
|
||||
* 语音合成回复(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
|
||||
*
|
||||
* ## 会话归属**只在 `data` 里**
|
||||
* 契约明写这帧的帧壳**没有** `sessionId`(与其它结构化流帧不同),会话 id 在载荷上。
|
||||
* 所以解析层用既有口径 [`resolveSessionId`] 把它提到 [sessionId] 上:帧壳有就用帧壳,
|
||||
* 否则用 `data` 里那一份。少了这一步,`isForSession` 会把帧**放行给每一个会话的 VM**
|
||||
* (空值 = 无法判定),于是切到别的会话时也会响一段本属于别的会话的语音。
|
||||
*
|
||||
* ## 只定向回发起 `voice_input` 的那条连接、断线不补投
|
||||
* 这是契约的明确边界,不是缺口:本端**不**为它做任何「重连后拉取」的补救(历史靠 REST,
|
||||
* 音频只在这一次连接里存在)。所以这一帧只有「当场播放 / 当场显示」这一种用途。
|
||||
*
|
||||
* [text] 是**转写**(那段音频被识别成什么),[reply] 是**她说的话**——两者的取舍见
|
||||
* `domain.chat.voiceReplyText`。[provider] 是降级信号(`silent` = 这一轮没有真音频),
|
||||
* 判据见 `domain.chat.voiceOutputHasAudio`。
|
||||
*/
|
||||
data class VoiceOutput(
|
||||
val audio: String?,
|
||||
val format: String?,
|
||||
val text: String?,
|
||||
val reply: String?,
|
||||
val deviceId: String? = null,
|
||||
val provider: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
@@ -890,8 +908,20 @@ sealed class WsEvent {
|
||||
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
|
||||
"message_deleted" -> decode<DeletedData>(data)
|
||||
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
|
||||
"voice_output" -> decode<VoiceOutputData>(data)
|
||||
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
|
||||
"voice_output" -> decode<VoiceOutputData>(data).let {
|
||||
VoiceOutput(
|
||||
audio = it.audio,
|
||||
format = it.format,
|
||||
text = it.text,
|
||||
reply = it.reply,
|
||||
deviceId = it.deviceId,
|
||||
provider = it.provider,
|
||||
turnId = turnId,
|
||||
seq = seq,
|
||||
// 帧壳**没有** sessionId(契约 v0.96),归属在 data 里 ⇒ 走既有口径提到帧上。
|
||||
sessionId = resolveSessionId(sessionId, it.sessionId),
|
||||
)
|
||||
}
|
||||
// 她主动说的话(v0.72)。帧壳**没有** `sessionId`(服务端不设),与会话归属有关的一切
|
||||
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
|
||||
"proactive_message" -> decode<ProactiveData>(data).let {
|
||||
@@ -1129,12 +1159,22 @@ private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList(
|
||||
@Serializable
|
||||
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
|
||||
|
||||
/**
|
||||
* `voice_output` 载荷(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
|
||||
*
|
||||
* `sessionId` 在这里**不是可选的**(契约「帧壳没有 sessionId(在 data 里)」),但解成缺省空串
|
||||
* 并交给 [WsEvent.resolveSessionId]:坏帧(真没给)时宁可退化成「无法判定」(放行、由消费方
|
||||
* 兜底),也不把整帧按坏帧丢掉——丢了就是「她回了语音但屏幕上什么都没有」。
|
||||
*/
|
||||
@Serializable
|
||||
private data class VoiceOutputData(
|
||||
val audio: String? = null,
|
||||
val format: String? = null,
|
||||
val text: String? = null,
|
||||
val reply: String? = null,
|
||||
val sessionId: String = "",
|
||||
val deviceId: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,12 +105,26 @@ sealed interface WsOutbound {
|
||||
}
|
||||
}
|
||||
|
||||
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
|
||||
/**
|
||||
* `voice_input`:`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`(契约 v0.96 §3.15)。
|
||||
*
|
||||
* 五个键里 [audio] / [format] 是我们**总要给**的(一段 WAV 的 base64 + `"wav"`,见
|
||||
* `domain.chat.VoiceClip`);[text] 只有本端**自己做过转写**时才给(给了服务端就跳过 STT),
|
||||
* 本端不做本地转写 ⇒ 恒省略。
|
||||
*
|
||||
* [clientMsgId] 与 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条,重复到达回
|
||||
* `chat_duplicate`、不再落库不再起回合。所以「这一段没发出去、用户点重发」必须**复用**
|
||||
* 同一个键(见 `domain.chat.voiceClientMsgIdFor`),换新键就是让服务端把同一句话再答一遍。
|
||||
*
|
||||
* [deviceId] 的取值与 `chat` 帧同一份(`ChatViewModel.DEVICE_ID`):服务端据此标记来源。
|
||||
*/
|
||||
data class VoiceInput(
|
||||
val audio: String? = null,
|
||||
val format: String? = null,
|
||||
val text: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val clientMsgId: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "voice_input"
|
||||
@@ -120,6 +134,8 @@ sealed interface WsOutbound {
|
||||
format?.let { put("format", it) }
|
||||
text?.let { put("text", it) }
|
||||
sessionId?.let { put("sessionId", it) }
|
||||
deviceId?.let { put("deviceId", it) }
|
||||
clientMsgId?.let { put("clientMsgId", it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一段**要发出去的语音**:录音参数、WAV 封装、体积闸门与幂等键(纯函数,可穷举单测)。
|
||||
*
|
||||
* ## 为什么自己封 WAV,而不是用 `MediaRecorder`
|
||||
* 契约 v0.96 给 `voice_input.format` 的白名单是 **`wav|mp3|ogg`**,而 Android `MediaRecorder`
|
||||
* 的默认产物是 **`m4a/aac`**——**不在白名单里**。要用它就得另接一个编码器把 aac 转成 mp3/ogg,
|
||||
* 那是新依赖(项目纪律:加依赖先问)。而 `AudioRecord` 给的 PCM + 一个 44 字节的 RIFF 头
|
||||
* 就是 WAV:**零依赖、零转码、逐字节可预期**,也能在单测里把头的每个字段钉死。
|
||||
*
|
||||
* ## 采样参数:16 kHz / 单声道 / 16-bit
|
||||
* - **采样率 16 kHz**:语音识别的行业口径就是 16 kHz(电话带宽 8 kHz 会把擦音削掉,44.1 kHz
|
||||
* 对 STT 没有任何增益)。同一句话的字节数因此只有 44.1 kHz 的 **36%**——这一条在本端格外重要,
|
||||
* 因为 `voice_input.audio` 是**内联 base64**(4/3 膨胀)走一条 WS 文本帧。
|
||||
* - **单声道**:麦克风阵列的立体声对 STT 只有副作用(两路几乎相同,白背一倍体积)。
|
||||
* - **16-bit**:`AudioRecord` 的原生 PCM 编码(`ENCODING_PCM_16BIT`),不做压缩——压缩要依赖。
|
||||
*
|
||||
* 由此得出一个**只有一条**的换算:`1 ms = 32 字节`,`1 s = 32 000 字节`。
|
||||
*
|
||||
* ## 体积闸门(为什么是 5 MiB)
|
||||
* 契约 v0.96 的 `voice_input` **没写** `audio` 的体积上限(`chat.image` 写了:解码后 5 MiB /
|
||||
* 字符串 6990572 字符)。本端按**已知最严的那一档**设计,也就是 [MAX_AUDIO_BYTES] =
|
||||
* `ChatImageBudget.SERVER_MAX_DECODED_BYTES`(5 MiB,解码后)——同一个部署对「内联 base64 字段」
|
||||
* 已经明确接受的量级,voice 没有理由比它更宽。超限**明确拒绝**([VOICE_TOO_LARGE_MESSAGE]),
|
||||
* 绝不静默截断:截断后的音频在服务端听来就是「话说到一半断了」。
|
||||
*
|
||||
* 60 秒的录满长度([MAX_RECORDING_MILLIS])只用到这条闸门的 **36%**(1 920 044 字节),所以
|
||||
* 正常路径永远撞不到它——它是**第二道**闸(硬件偶尔会多给几块缓冲,谁也说不准),不是主判据。
|
||||
*
|
||||
* ## 时长下限
|
||||
* 按下就松手的误触会得到几十毫秒的音频:那不是「说话」,送给服务端只会换来一次空转写。
|
||||
* 不足 [MIN_RECORDING_MILLIS] 的录音在**本端**就被挡下并说明([VOICE_TOO_SHORT_MESSAGE])。
|
||||
*/
|
||||
object VoiceClip {
|
||||
|
||||
/** 采样率(Hz)。16 kHz = STT 的行业口径,见类注释。 */
|
||||
const val SAMPLE_RATE_HZ: Int = 16_000
|
||||
|
||||
/** 声道数:单声道(立体声对 STT 无益,只翻倍体积)。 */
|
||||
const val CHANNEL_COUNT: Int = 1
|
||||
|
||||
/** 位深:`AudioRecord` 的原生 16-bit PCM。 */
|
||||
const val BITS_PER_SAMPLE: Int = 16
|
||||
|
||||
/** 每采样的字节数。 */
|
||||
const val BYTES_PER_SAMPLE: Int = BITS_PER_SAMPLE / 8
|
||||
|
||||
/** RIFF/WAVE 的 PCM 头长度:`RIFF`(4) + size(4) + `WAVE`(4) + `fmt `(16+8) + `data`(8)。 */
|
||||
const val HEADER_BYTES: Int = 44
|
||||
|
||||
/** 上行 `format` 的取值。契约白名单 `wav|mp3|ogg` 里我们**只产出**这一个。 */
|
||||
const val FORMAT: String = "wav"
|
||||
|
||||
/** 每秒的 PCM 字节数(= [SAMPLE_RATE_HZ] × [CHANNEL_COUNT] × [BYTES_PER_SAMPLE])。 */
|
||||
val BYTES_PER_SECOND: Int = SAMPLE_RATE_HZ * CHANNEL_COUNT * BYTES_PER_SAMPLE
|
||||
|
||||
/**
|
||||
* 一段语音(**含头**)的字节上限:与 `chat.image` 解码后同一条线,见类注释。
|
||||
* 刻意**引用**那个常量而不是再写一遍 `5 * 1024 * 1024`:上限只留一处,改的时候不会漂。
|
||||
*/
|
||||
const val MAX_AUDIO_BYTES: Int = ChatImageBudget.SERVER_MAX_DECODED_BYTES
|
||||
|
||||
/**
|
||||
* 一次录音的**最长**时长(毫秒)。60 秒取自 IM 的通行口径(微信 / QQ 都是这个量级),
|
||||
* 也对得上服务端 STT 的现实:再长的语音转写质量与等待都不划算。
|
||||
*
|
||||
* 到点由录音机自己置「满了」标记(见 `VoiceRecorder`),UI 随即收尾发送——
|
||||
* 不是悄悄截断,用户看到的是「到 60 秒了,这段发出去」。
|
||||
*/
|
||||
const val MAX_RECORDING_MILLIS: Int = 60_000
|
||||
|
||||
/** 短于这个时长的录音**不送**(按下就松手的误触),见类注释。 */
|
||||
const val MIN_RECORDING_MILLIS: Int = 1_000
|
||||
|
||||
/** 60 秒对应的 PCM 字节数(录音机据此封顶,不在读循环里另算一遍公式)。 */
|
||||
val MAX_PCM_BYTES: Int = pcmBytesFor(MAX_RECORDING_MILLIS)
|
||||
|
||||
/** 这段 PCM 加上 WAV 头之后的**总**字节数。 */
|
||||
fun totalBytesFor(pcmBytes: Int): Int = if (pcmBytes <= 0) 0 else HEADER_BYTES + pcmBytes
|
||||
|
||||
/** 时长 → PCM 字节数(向下取整;`<= 0` 的时长回 0)。 */
|
||||
fun pcmBytesFor(durationMillis: Int): Int =
|
||||
if (durationMillis <= 0) 0 else (durationMillis.toLong() * BYTES_PER_SECOND / 1000L).toInt()
|
||||
|
||||
/** PCM 字节数 → 时长(毫秒,向下取整)。 */
|
||||
fun durationMillisOf(pcmBytes: Int): Int =
|
||||
if (pcmBytes <= 0) 0 else (pcmBytes.toLong() * 1000L / BYTES_PER_SECOND).toInt()
|
||||
|
||||
/** 总字节数是否在 [MAX_AUDIO_BYTES] 以内(**等于**算通过:`<=`,与 `withinServerLimit` 同口径)。 */
|
||||
fun withinLimit(totalBytes: Int): Boolean = totalBytes <= MAX_AUDIO_BYTES
|
||||
|
||||
/**
|
||||
* WAV 头(44 字节,PCM / 单块 / 无扩展)。
|
||||
*
|
||||
* 逐字段按 RIFF 规范写:`RIFF` + 文件长 - 8 + `WAVE` + `fmt `(16 字节,PCM=1) + 声道 /
|
||||
* 采样率 / 字节率 / 块对齐 / 位深 + `data` + 数据长。全部**小端**。
|
||||
*/
|
||||
fun wavHeader(pcmBytes: Int): ByteArray {
|
||||
val data = ByteArray(HEADER_BYTES)
|
||||
putAscii(data, 0, "RIFF")
|
||||
putIntLe(data, 4, HEADER_BYTES - 8 + pcmBytes)
|
||||
putAscii(data, 8, "WAVE")
|
||||
putAscii(data, 12, "fmt ")
|
||||
putIntLe(data, 16, 16)
|
||||
putShortLe(data, 20, AUDIO_FORMAT_PCM)
|
||||
putShortLe(data, 22, CHANNEL_COUNT)
|
||||
putIntLe(data, 24, SAMPLE_RATE_HZ)
|
||||
putIntLe(data, 28, BYTES_PER_SECOND)
|
||||
putShortLe(data, 32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
|
||||
putShortLe(data, 34, BITS_PER_SAMPLE)
|
||||
putAscii(data, 36, "data")
|
||||
putIntLe(data, 40, pcmBytes)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 头 + PCM = 一个可直接交给任何 `WAV` 解码器的字节数组。 */
|
||||
fun toWav(pcm: ByteArray): ByteArray {
|
||||
val header = wavHeader(pcm.size)
|
||||
val out = ByteArray(HEADER_BYTES + pcm.size)
|
||||
header.copyInto(out, 0)
|
||||
pcm.copyInto(out, HEADER_BYTES)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 这段 PCM 该不该发、发出去长什么样(**唯一**的准入判据,录音机与 VM 共用)。
|
||||
*
|
||||
* 判序是刻意的:**空 → 超限 → 太短 → 可发**。
|
||||
* - 空:一个字节都没有(设备没给 / 权限刚被撤),连「太短」都谈不上;
|
||||
* - 超限:长度或字节数已经越线,绝不能发(哪怕时长看起来正常——硬件也可能给出异常长的一块);
|
||||
* - 太短:见 [MIN_RECORDING_MILLIS]。
|
||||
*/
|
||||
fun plan(pcm: ByteArray): VoiceClipPlan {
|
||||
if (pcm.isEmpty()) return VoiceClipPlan.Empty
|
||||
val total = totalBytesFor(pcm.size)
|
||||
if (!withinLimit(total)) return VoiceClipPlan.TooLarge(total)
|
||||
val duration = durationMillisOf(pcm.size)
|
||||
if (duration < MIN_RECORDING_MILLIS) return VoiceClipPlan.TooShort(duration)
|
||||
return VoiceClipPlan.Ready(toWav(pcm), duration)
|
||||
}
|
||||
|
||||
/** RIFF 的 `audioFormat`:1 = PCM(整数)。 */
|
||||
private const val AUDIO_FORMAT_PCM: Int = 1
|
||||
|
||||
private fun putAscii(target: ByteArray, offset: Int, text: String) {
|
||||
for (index in text.indices) target[offset + index] = text[index].code.toByte()
|
||||
}
|
||||
|
||||
private fun putIntLe(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
|
||||
target[offset + 2] = ((value ushr 16) and 0xFF).toByte()
|
||||
target[offset + 3] = ((value ushr 24) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
private fun putShortLe(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
/** [VoiceClip.plan] 的四档结论。 */
|
||||
sealed interface VoiceClipPlan {
|
||||
|
||||
/** 一段**可以发**的语音:已经封好 WAV,[durationMs] 是它的实际时长。 */
|
||||
class Ready(val wav: ByteArray, val durationMs: Int) : VoiceClipPlan
|
||||
|
||||
/** 一个字节都没录到。 */
|
||||
data object Empty : VoiceClipPlan
|
||||
|
||||
/** 短于 [VoiceClip.MIN_RECORDING_MILLIS](按下就松手那种误触)。 */
|
||||
data class TooShort(val durationMs: Int) : VoiceClipPlan
|
||||
|
||||
/** 总字节数超过 [VoiceClip.MAX_AUDIO_BYTES]。 */
|
||||
data class TooLarge(val totalBytes: Int) : VoiceClipPlan
|
||||
}
|
||||
|
||||
/** 空录音的如实告知(**不是错误**:什么都没发生,用户重来一次即可)。 */
|
||||
const val VOICE_EMPTY_MESSAGE: String = "没有录到声音,再按一次试试"
|
||||
|
||||
/** 太短的如实告知。 */
|
||||
const val VOICE_TOO_SHORT_MESSAGE: String = "录得太短了,说满一秒再发"
|
||||
|
||||
/**
|
||||
* 超限的如实告知。刻意说「没发出去」:那句话必须与事实上一致(这条一个字节都没上行),
|
||||
* 也不能把责任推给用户(他确实就说了那么久,是本端的闸门挡下的)。
|
||||
*/
|
||||
const val VOICE_TOO_LARGE_MESSAGE: String = "这段语音太长,本机没有发出去"
|
||||
|
||||
/** 这一条被本端挡下时该说的那句话;`null` = 可以发(纯函数)。 */
|
||||
fun voiceClipRejection(plan: VoiceClipPlan): String? = when (plan) {
|
||||
is VoiceClipPlan.Ready -> null
|
||||
VoiceClipPlan.Empty -> VOICE_EMPTY_MESSAGE
|
||||
is VoiceClipPlan.TooShort -> VOICE_TOO_SHORT_MESSAGE
|
||||
is VoiceClipPlan.TooLarge -> VOICE_TOO_LARGE_MESSAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音中 / 待重发那一段的时长文案(纯函数):`m:ss`,**从 0:00 起就有值**。
|
||||
*
|
||||
* 与接收侧的 [voiceDurationLabel] 差一条:那个对「不足一秒」回 `null`(服务端没给准数时宁可
|
||||
* 不显示),而这里显示的是**本端自己正在数的时间**——0:00 是事实,不是冒充。
|
||||
* 负数按 0 处理(时钟回拨 / 调用方算错时也不出现 `-1:-1`)。
|
||||
*/
|
||||
fun voiceElapsedLabel(elapsedMs: Int): String {
|
||||
val totalSeconds = elapsedMs.coerceAtLeast(0) / 1000
|
||||
return "${totalSeconds / 60}:${(totalSeconds % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/** 语音消息的幂等键前缀:与文本那条(`local-`)/ 分享那条(`share-`)同一手法,见 [voiceClientMsgIdFor]。 */
|
||||
const val VOICE_CLIENT_MSG_ID_PREFIX: String = "voice-"
|
||||
|
||||
/**
|
||||
* 一条**录好、还没发出去**的语音在本端的记账(只有小字段,audio 不在这里)。
|
||||
*
|
||||
* `clientMsgId` 是契约 v0.96 §3.15 的幂等键:同一个非空值服务端**只承认第一条**,重复到达回
|
||||
* `chat_duplicate`、不落库不起回合。所以「重发这一段」必须**复用**它([voiceClientMsgIdFor]),
|
||||
* 换了新键就是让服务端把同一句话再答一遍。
|
||||
*
|
||||
* `audio`(base64)刻意**不进** [top.yeij.cyrene.ui.chat.ChatUiState]:那是一格会被反复 `copy`
|
||||
* 并逐字段比较的状态(流式期间每秒几十次),几 MB 的字符串放进去等于每次比较都扫一遍它。
|
||||
* 字节留在 VM 自己那个字段上(生命周期与 VM 相同,转屏不丢)。
|
||||
*/
|
||||
data class VoiceDraftInfo(
|
||||
val clientMsgId: String,
|
||||
val durationMs: Int,
|
||||
val totalBytes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这一段该用哪个幂等键(纯函数):
|
||||
* - [draft] 非空 = **重发刚才没发出去的那一段** ⇒ 原样复用它当时的键(契约要求);
|
||||
* - [draft] 为空 = 一段**新**录音 ⇒ 现铸一个:前缀 + [mint] 给的随机串(随机而不是自增,
|
||||
* 理由与文本那条相同:服务端的判重窗口是整个会话历史)。
|
||||
*/
|
||||
fun voiceClientMsgIdFor(draft: VoiceDraftInfo?, mint: () -> String): String =
|
||||
draft?.clientMsgId ?: "$VOICE_CLIENT_MSG_ID_PREFIX${mint()}"
|
||||
@@ -10,13 +10,19 @@ import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
|
||||
|
||||
/**
|
||||
* `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得;
|
||||
* `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`)。
|
||||
* `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`);
|
||||
* `tts` = **我们自己合成的出站语音**(她的 `speak` 工具产出的那一段,后端 M57-M59 新增)。
|
||||
*
|
||||
* 三个取值在**接收侧**没有分叉:合成成功的那条与入站语音同形(`status = transcribed` + 四键
|
||||
* 副本齐全),照同一条路渲染;合成降级的那条是 `status = failed`(见 [VoiceStatus.FAILED])。
|
||||
*/
|
||||
object VoiceSource {
|
||||
|
||||
const val ASR: String = "asr"
|
||||
|
||||
const val PLATFORM: String = "platform"
|
||||
|
||||
const val TTS: String = "tts"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,17 +34,22 @@ object VoiceSource {
|
||||
*/
|
||||
object VoiceStatus {
|
||||
|
||||
/** 正文**就是**转写文本。 */
|
||||
/** 正文**就是**转写文本(出站方向 = 合成成功、有副本可播)。 */
|
||||
const val TRANSCRIBED: String = "transcribed"
|
||||
|
||||
/** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */
|
||||
const val EMPTY: String = "empty"
|
||||
|
||||
/**
|
||||
* 没拿到转写文本 ⇒ **正文是服务端给的那行中文诚实提示**,照常显示它。
|
||||
* 这一条**没有可播的语音**,正文是一句话(见 [isPlayableVoice])。
|
||||
*
|
||||
* 设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节
|
||||
* 一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
|
||||
* 两个方向同形、语义统一(后端 M57-M59 预告,契约 v0.98 待正式回执):
|
||||
* - **入站**:没拿到转写文本 ⇒ 正文是服务端给的那行中文诚实提示;
|
||||
* - **出站**:她本想用语音、合成降级回文字 ⇒ 正文是普通文字,另带 `fallbackReason`
|
||||
* (见 [voiceFallbackNote])。
|
||||
*
|
||||
* 入站方向设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
|
||||
* 无字节一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
|
||||
*/
|
||||
const val FAILED: String = "failed"
|
||||
}
|
||||
@@ -53,6 +64,19 @@ object VoiceStatus {
|
||||
*/
|
||||
const val VOICE_EMPTY_NOTE: String = "没有识别出文字"
|
||||
|
||||
/**
|
||||
* `status == "failed"` 的那一行**灰色小标**的固定前半句(后面可能跟服务端给的 `fallbackReason`)。
|
||||
*
|
||||
* 为什么要有这一行:这一态的消息 `voice` 键**在**、但**没有可播的语音**(见 [isPlayableVoice]),
|
||||
* 所以屏幕上不会有语音形态。少了这一行,一条「她本想发语音、合成降级成文字」的消息与一条普通文字
|
||||
* 消息**逐像素相同**——看的人无从知道这条本来是一段声音。它只说本端能确定的那件事(这条没有可播的
|
||||
* 声音),**不解释原因**(原因归服务端,见 [voiceFallbackNote] 里那段原话)。
|
||||
*
|
||||
* ⚠️ 措辞要同时站得住两个方向(入站转写失败 / 出站合成降级),所以**不写**「转写失败」也**不写**
|
||||
* 「合成失败」:本端分不清,也不该替服务端选一个说法。
|
||||
*/
|
||||
const val VOICE_FALLBACK_NOTE: String = "这条语音没有可播放的声音"
|
||||
|
||||
/**
|
||||
* 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。
|
||||
*
|
||||
@@ -80,12 +104,50 @@ data class VoiceAudio(
|
||||
)
|
||||
|
||||
/**
|
||||
* 🎤 **判据 = `voice` 键存在**(契约 §4.4)。
|
||||
* `voice` 键在不在(纯函数)—— 「这条消息**来自**一条语音」。
|
||||
*
|
||||
* 只有一个条件,而且**永远只有这一个**:`content` 里有没有 `[语音]` 这类字样与本判据无关
|
||||
* (QQ 的占位标记自 v0.67 起不再下发,别人也可以自己打出一模一样的字)。
|
||||
* 这是契约 §4.4 给的**唯一**来源判据:非语音消息整键缺席(不是 `{}` 也不是 `null`),
|
||||
* `content` 里有没有 `[语音]` 这类字样与本判据无关(QQ 的占位标记自 v0.67 起不再下发,
|
||||
* 别人也可以自己打出一模一样的字)。
|
||||
*
|
||||
* ⚠️ 它与「要不要画语音气泡」**不是同一件事**了:合成降级 / 转写失败的那些行 `voice` 键照样在,
|
||||
* 但**没有可播的语音**(见 [isPlayableVoice])。判「是不是语音消息」用本函数,判「画不画语音
|
||||
* 形态」用 [isVoiceMessage]——两个问题分开问,混用会让降级行被画成一个必然播不了的气泡。
|
||||
*/
|
||||
fun isVoiceMessage(message: HistoryMessageDto): Boolean = message.voice != null
|
||||
fun hasVoice(message: HistoryMessageDto): Boolean = message.voice != null
|
||||
|
||||
/**
|
||||
* 这条语音**有没有可播的语音形态**(纯函数):除了 `voice` 键要在,`status` 还不能是 `failed`。
|
||||
*
|
||||
* ## 为什么判据是「不是 `failed`」而不是「是 `transcribed` / `empty`」
|
||||
* 后端 M57-M59 预告(契约 v0.98 待正式回执)只点名了 `failed` 这一态**明确没有可播的语音**:
|
||||
* 入站是没拿到转写文本、出站是合成降级回文字,两个方向的正文都是一句话而不是一段语音。
|
||||
*
|
||||
* 反过来,认不出的 `status` **一律当可播**(fail-open)。理由是这个方向错得便宜、另一个方向错得
|
||||
* 贵:后端将来若新增一个成功态(比如 `transcribed` 之外的 `truncated`),fail-open 只是多画一枚
|
||||
* 语音形态(最坏是点了播不出来,有既有的失败文案兜着);fail-closed 则会把一条**真的有副本**的
|
||||
* 语音静默吞成纯文字,用户再也点不到那段声音——而且没有任何界面提示说明为什么。所以这里只把
|
||||
* 契约明确说过「没有语音」的那一态排除掉,其余交给「四键齐全才画播放控件」([voiceAudio])。
|
||||
*/
|
||||
fun isPlayableVoice(voice: VoiceDto): Boolean = voice.status != VoiceStatus.FAILED
|
||||
|
||||
/**
|
||||
* 🎤 **要不要画语音形态的判据** = `voice` 键存在 **且** `status != "failed"`(契约 §4.4 +
|
||||
* 后端 M57-M59 预告)。
|
||||
*
|
||||
* 只有这两个条件,而且**永远只有这两个**:
|
||||
* - `voice` 键缺席 ⇒ 不是语音消息([hasVoice]);
|
||||
* - `status == "failed"` ⇒ 这条**没有可播的语音**(入站转写失败 / 出站合成降级),正文照常按文字
|
||||
* 渲染,另加一行灰色小标([voiceFallbackNote])——**不**画语音形态,因为画出来就是一个必然
|
||||
* 播不了的控件。
|
||||
*
|
||||
* 入站语音(`asr` / `platform`)与合成成功的出站语音(`tts`)走的是**同一条**路:判据只看
|
||||
* `status`,不看 `source`。
|
||||
*/
|
||||
fun isVoiceMessage(message: HistoryMessageDto): Boolean {
|
||||
val voice = message.voice ?: return false
|
||||
return isPlayableVoice(voice)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条语音的**可播放副本**;四个键没凑齐(或名字 / MIME / URL 是空白)就回 `null`。
|
||||
@@ -128,18 +190,45 @@ fun voiceDurationLabel(durationMs: Int?): String? {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一条在**正文之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
|
||||
* 语音形态**之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
|
||||
*
|
||||
* 只对 `status == "empty"` 且**正文为空**时成立:
|
||||
* - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补);
|
||||
* - `failed`:正文**就是**服务端那行诚实提示,替它加一句等于替它改文案——**不做**;
|
||||
* - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾**
|
||||
* (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话);
|
||||
* - 未知 `status`(含空串):**不猜**,与 `failed` 一样只显示正文。
|
||||
* - 未知 `status`(含空串):**不猜**,只显示正文。
|
||||
*
|
||||
* ⚠️ `failed` 那一态**不走这里**(它连语音形态都不画):正文由服务端给,本端另加一行小标
|
||||
* ([voiceFallbackNote])。本函数仍对 `failed` 回 `null`,因为「语音形态旁边多说一句」这个位置
|
||||
* 对那一态根本不存在。
|
||||
*/
|
||||
fun voiceNote(status: String, content: String): String? =
|
||||
if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null
|
||||
|
||||
/**
|
||||
* `status == "failed"` 时那行灰色小标该写什么;别的状态回 `null`(纯函数)。
|
||||
*
|
||||
* 两段拼成一句话:**固定前半句**([VOICE_FALLBACK_NOTE],说的是本端能确定的事实)+ 服务端给的
|
||||
* `fallbackReason`(**一个字符都不改**,原样放进括号里)。
|
||||
*
|
||||
* ## 为什么原样放、不做映射
|
||||
* `fallbackReason` 的取值集合**后端还没定**(`devdocs/02` 里我们已经问了「是闭集还是自由文本」,
|
||||
* 尚未答复;契约 v0.98 的正式回执也还没到)。在有答案之前,本端**不猜**它是不是闭集、更不替它
|
||||
* 编一套友好文案:认不出的取值被映射成一句话,比原样显示更难排查——用户报「显示成『语音超时』」
|
||||
* 时,我们连服务端到底给了什么都不知道。原样显示,最坏是屏幕上出现一个 `tts_timeout` 这样的词,
|
||||
* 而它**正是**我们和后端对账时要的那个字符串。
|
||||
*
|
||||
* ## `fallbackReason` 缺席时
|
||||
* 只有固定前半句。这一档同时是**空正文 + `failed`** 的兜底:这一态的消息正文按契约应当非空
|
||||
* (服务端那行诚实提示 / 降级后的原文本),但本端**不靠**这条保证——真收到一条空正文的 `failed`
|
||||
* 行时,屏幕上至少还有这行小标,而不是一个**什么都没有**的空白(那正是空气泡那种最糟的呈现)。
|
||||
*/
|
||||
fun voiceFallbackNote(voice: VoiceDto?): String? {
|
||||
if (voice == null || voice.status != VoiceStatus.FAILED) return null
|
||||
val reason = voice.fallbackReason?.takeIf { it.isNotBlank() } ?: return VOICE_FALLBACK_NOTE
|
||||
return "$VOICE_FALLBACK_NOTE($reason)"
|
||||
}
|
||||
|
||||
/**
|
||||
* 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* `voice_output` 的**判据**(纯函数,可穷举单测):契约 v0.96 §3.15 下行帧。
|
||||
*
|
||||
* 帧形状(后端 `回执三十六`):
|
||||
* ```json
|
||||
* { "type": "voice_output",
|
||||
* "data": { "audio": "<base64>", "format": "wav", "text": "...", "reply": "她的话",
|
||||
* "sessionId": "web", "deviceId?": "...", "provider?": "silent|emotiontts|..." } }
|
||||
* ```
|
||||
*
|
||||
* ## `text` 与 `reply` 是两个东西(本端怎么用)
|
||||
* - **[text]**:这一轮**转写出来的话**——也就是我们那段音频被识别成什么。它与
|
||||
* `voice_input.text`(「已自行转写则跳过 STT」)同一个键名、同一个语义,只是方向相反。
|
||||
* - **[reply]**:**她说的话**(音频念的就是它),也就是用户真正要听/要看的那一句。
|
||||
*
|
||||
* 于是「屏幕上该显示哪一句」的答案是 [voiceReplyText]:**`reply` 优先**,它缺席时才退到 `text`
|
||||
* (宁可显示转写也不显示空白),两者都缺席就什么都不显示(绝不编一句)。
|
||||
*
|
||||
* ## `provider` 是**降级信号**,不是装饰
|
||||
* `provider == "silent"` = 这一轮**没有真音频**(`audio` 空,但 `text` / `reply` 有)。
|
||||
* 契约明写「UI 别把它画成播放器」——画了就是一个点下去必然失败的按钮(与接收侧
|
||||
* `voiceAudio` 的「四个键不齐就不画播放控件」同一条纪律)。
|
||||
*
|
||||
* 除此之外 `audio` 空白也按「没有音频」处理([voiceOutputHasAudio]):契约说 silent 时
|
||||
* `audio` 为空,反过来「空 audio + 没给 provider」也绝不能画出一个播放器。
|
||||
*
|
||||
* ## 它与 `voice` 键那条(接收侧放别人发来的语音)**互不相干**
|
||||
* 见 [isVoiceMessage]/[voiceAudio]:那是消息 DTO 的历史副本,这里是**本端刚发出去的那一段**的
|
||||
* 实时应答——两者不共用判据,共用只会把两套字段口径搅在一起。
|
||||
*/
|
||||
|
||||
/** `provider` 里那个**降级档**的取值(契约 v0.96)。 */
|
||||
object VoiceProvider {
|
||||
|
||||
/** 这一轮**没有真音频**(`audio` 空、`text`/`reply` 有)。 */
|
||||
const val SILENT: String = "silent"
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一帧是不是「没有真音频」那一档(纯函数)。
|
||||
*
|
||||
* 只认字面 `silent`(忽略首尾空白与大小写:它是机器可读码,不是文案)。未知取值一律**不当**
|
||||
* silent——那是另一个 provider,`audio` 在不在由 [voiceOutputHasAudio] 按字节判,不靠猜。
|
||||
*/
|
||||
fun isSilentVoiceProvider(provider: String?): Boolean =
|
||||
provider?.trim().equals(VoiceProvider.SILENT, ignoreCase = true)
|
||||
|
||||
/**
|
||||
* 这一轮**有没有真音频**(纯函数):`provider != silent` **且** `audio` 非空白。
|
||||
*
|
||||
* 两个条件都不许省:silent 档的 `audio` 契约上就是空的(万一真给了也不算数,因为服务端已经
|
||||
* 明说这一轮没发音),而非 silent 档的 `audio` 也可能因为编码失败缺席。
|
||||
*/
|
||||
fun voiceOutputHasAudio(audio: String?, provider: String?): Boolean =
|
||||
!isSilentVoiceProvider(provider) && !audio.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* 屏幕上该显示的那一句(纯函数,见文件头「`text` 与 `reply` 是两个东西」)。
|
||||
* `reply` 非空白 ⇒ 它;否则 `text` 非空白 ⇒ 它;都没有 ⇒ `null`(一个字符都不显示)。
|
||||
*/
|
||||
fun voiceReplyText(reply: String?, text: String?): String? =
|
||||
reply?.takeIf { it.isNotBlank() } ?: text?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* 这张卡片下面要补的那一句(纯函数:不需要补时回 `null`)。
|
||||
*
|
||||
* 两档**都要解释,但说的不是同一件事**:
|
||||
* - [silent]:**服务端**这一轮没有真音频(`provider == "silent"`)⇒「她只回了文字」。不解释的话,
|
||||
* 用户按着麦克风说话、回来的却是一段文字,分不清「她选择只回文字」与「播放器坏了」。
|
||||
* - 有音频却没落地([hasAudioFile] 为假):那是**本端**解码 / 写缓存失败 ⇒ 说「这段语音本机没
|
||||
* 准备好」,绝不把本机的故障说成「她只回了文字」(那是替服务端下了一个它没下的结论)。
|
||||
*/
|
||||
fun voiceReplyNote(silent: Boolean, hasAudioFile: Boolean): String? = when {
|
||||
silent -> VOICE_SILENT_NOTE
|
||||
!hasAudioFile -> VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这一轮没有音频」的说明。刻意说清是**服务端**这一轮没给音频(`provider` 的降级信号),
|
||||
* 而不是「本机播不了」——后者会把人引向「换个播放器」这种无用的动作。
|
||||
*/
|
||||
const val VOICE_SILENT_NOTE: String = "这一轮她只回了文字,没有语音"
|
||||
|
||||
/**
|
||||
* 「音频有、但本机没准备好」的说明(解码 / 写缓存失败)。
|
||||
*
|
||||
* 与 [VOICE_SILENT_NOTE] 分开是必须的:这一档的真相是**本端**的问题,而文字照样在
|
||||
* ([voiceReplyText] 已经把它取出来了),所以用户至少还能读到她说的话。
|
||||
*/
|
||||
const val VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE: String = "这段语音本机没能准备好,先看文字"
|
||||
@@ -0,0 +1,167 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/**
|
||||
* 「把这条消息念出来」这条路上,**本端要说的那几句话**,以及失败该分到哪一档。
|
||||
*
|
||||
* 这个文件里全是纯函数 / 常量,没有 Android、没有网络、没有状态:单测直接调(见
|
||||
* `VoiceSpeakTest`)。真正的取字节在 `data.voice.SpeechClient`,播放复用 `ui.chat.VoicePlayer`。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从一条消息解析出来的气泡里抽出**要念的那段纯文本**。
|
||||
*
|
||||
* ## 为什么只念 `body`
|
||||
* [ParsedBubble] 把一条气泡拆成三段:`head`(说话人抬头)、`body`(正文)、`tail`(旁白 / 动作)。
|
||||
* 念出来时**只念正文**:
|
||||
* - `head` 是「昔涟:」这种抬头,念出来像在念字幕;
|
||||
* - `tail` 里的旁白(`*她笑了笑*`)与动作(`[动作]`)是**舞台指示**,语音合成念它只会变成一串
|
||||
* 莫名其妙的字面量。
|
||||
*
|
||||
* ## 为什么按 `\n` 拼、且空就回 `null`
|
||||
* 一条消息可能被切成多个气泡(连续说两段);`\n` 让合成的停顿落在段落之间,比空格自然。
|
||||
* 全都为空(纯附件消息、纯旁白消息、或者解析完什么都不剩)时回 `null` —— 调用方据此**不显示**
|
||||
* 朗读入口,而不是让用户点一个必然拿到 `400` 的按钮。
|
||||
*
|
||||
* 取完 `trim`:首尾空白会变成合成音频开头 / 结尾的静音,没有任何用处。
|
||||
*/
|
||||
fun ttsTextOf(bubbles: List<ParsedBubble>): String? =
|
||||
bubbles.asSequence()
|
||||
.map { it.body }
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString("\n")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* 一条消息(`content` + `mode`)里**要念的那段文本**;`null` = 没有可念的正文。
|
||||
*
|
||||
* 这是「这条消息能不能朗读」的**唯一判据**:`ChatViewModel.speak` 用它决定发不发请求,
|
||||
* `MessageBubble` 用它决定画不画那枚图标。两处必须是同一个答案 —— 画了却发不出去、或者能发
|
||||
* 却不给入口,都是 bug。
|
||||
*
|
||||
* ## 为什么要看 `mode`
|
||||
* `chat`(或缺席)是「按 markup 切气泡的聊天式渲染」;其余 mode 下气泡里放的是**原文**,
|
||||
* 一条 markup 都不解析(`MessageBubble` 就是这么画的)。朗读要念的显然也是**用户看到的那段字**:
|
||||
* - 聊天式 ⇒ 走 [ttsTextOf] 剥掉抬头 / 旁白;
|
||||
* - 其余 ⇒ 原文原样,只 `trim`。
|
||||
*
|
||||
* 判据本身与 `MessageBubble` 里那句 `isChat` 一字不差,刻意留在这里一份:多写一份就会出现
|
||||
* 「界面按一种切法、朗读按另一种切法」的分叉。
|
||||
*/
|
||||
fun ttsTextOf(message: HistoryMessageDto): String? =
|
||||
if (message.mode.isNullOrBlank() || message.mode == "chat") {
|
||||
ttsTextOf(parse(message.content).filterNot { it.isEmpty })
|
||||
} else {
|
||||
message.content.trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次朗读失败该归到哪一档 —— 判据只看**服务端到底说了什么**,不看 HTTP 码的表面。
|
||||
*
|
||||
* ## 为什么分两档
|
||||
* 这两档对用户的意思完全不同,混成一句就等于什么都没说:
|
||||
* - [Soft]:**这会儿**没读出来(网络断了、令牌过期、网关抽风)。过一会儿再点是**有意义**的,
|
||||
* 所以文案只陈述事实、不诱导、也不吓人;
|
||||
* - [Unconfigured]:**配置**上就没这条路的。服务端明说了是哪种缺(`tts_persona_unmapped`
|
||||
* 没有发音人映射 / `tts_no_character` 连生效人格都没有),重试**永远不会**成功 —— 所以文案要说
|
||||
* 清「缺什么」,并且**不提重试**(提了就是在骗人点)。
|
||||
*/
|
||||
sealed interface TtsFailure {
|
||||
|
||||
/** 要说给用户的那一句话。 */
|
||||
val message: String
|
||||
|
||||
/** 再点一次**可能**有用吗?([Unconfigured] 恒 `false`:重试永远不会成功。) */
|
||||
val retryable: Boolean
|
||||
|
||||
/** 这会儿没读出来:不解释原因(本端确实不知道是哪一段断了),也不诱导重试。 */
|
||||
data object Soft : TtsFailure {
|
||||
override val message: String = TTS_SOFT_FAILURE_MESSAGE
|
||||
override val retryable: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端明说了配置缺一块。[code] 就是服务端那个错误码(原样留着,排查时对得上号)。
|
||||
*/
|
||||
data class Unconfigured(val code: String) : TtsFailure {
|
||||
override val message: String = when (code) {
|
||||
TTS_CODE_PERSONA_UNMAPPED -> TTS_PERSONA_UNMAPPED_MESSAGE
|
||||
TTS_CODE_NO_CHARACTER -> TTS_NO_CHARACTER_MESSAGE
|
||||
// 兜底:服务端回了一个本端还不认识的「配置缺失」类错误码。不猜它的意思,
|
||||
// 但也不装作是临时故障——它毕竟带着一个明确的 code。
|
||||
else -> TTS_UNCONFIGURED_MESSAGE
|
||||
}
|
||||
override val retryable: Boolean = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次朗读的异常分到 [TtsFailure] 两档里。
|
||||
*
|
||||
* 只有「服务端明确回了一个**配置缺失**类的 `code`」才进 [TtsFailure.Unconfigured];其余一切
|
||||
* ——网络失败、解析失败、401(令牌过期,续期后还能用)、`voice_unavailable` / `tts_failed` /
|
||||
* `tts_empty`(对面这会儿不行,不是本端的配置问题)——都进 [TtsFailure.Soft]。
|
||||
*
|
||||
* `tts_empty`(静默空音频)刻意归到柔和档:那是**这一次**合成出来的东西不能用,换个时间再试
|
||||
* 完全可能成功,说成「配置没配好」就是错的。
|
||||
*/
|
||||
fun ttsFailureOf(error: Throwable): TtsFailure {
|
||||
val cause = (error as? CyreneException)?.error ?: return TtsFailure.Soft
|
||||
return when (cause) {
|
||||
is CyreneError.Server -> when (cause.code) {
|
||||
TTS_CODE_PERSONA_UNMAPPED,
|
||||
TTS_CODE_NO_CHARACTER,
|
||||
-> TtsFailure.Unconfigured(cause.code)
|
||||
|
||||
else -> TtsFailure.Soft
|
||||
}
|
||||
|
||||
is CyreneError.Unauthorized -> TtsFailure.Soft
|
||||
is CyreneError.Local -> TtsFailure.Soft
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 朗读在某一条播放通道上的**键**:`tts:<messageId>`。
|
||||
*
|
||||
* 播放通道只有一条(`ui.chat.VoicePlayer` 的那个实例,与语音副本共用),所以键必须与语音副本
|
||||
* (键 = `voice` 的 `messageId`)分得开:前缀就是那道分界线。同一条消息的朗读与它的语音副本
|
||||
* 因此**互斥**(点其中一个会停掉另一个)——这是对的,同一时刻只该有一个声音。
|
||||
*/
|
||||
fun ttsClipIdOf(messageId: String): String = TTS_CLIP_PREFIX + messageId
|
||||
|
||||
/** 朗读入口那枚图标的读屏名字(也是设备验收时 `uiautomator` 的 `content-desc`)。 */
|
||||
const val TTS_SPEAK_DESCRIPTION: String = "朗读这条消息"
|
||||
|
||||
/** 正在朗读时那枚图标的读屏名字(点它 = 停)。 */
|
||||
const val TTS_STOP_DESCRIPTION: String = "停止朗读"
|
||||
|
||||
/** 正在取合成音频时那枚图标的读屏名字。 */
|
||||
const val TTS_LOADING_DESCRIPTION: String = "正在准备朗读"
|
||||
|
||||
/** 柔和档:这会儿没读出来。**不说原因**(本端确实不知道断在哪一段),也不提重试。 */
|
||||
const val TTS_SOFT_FAILURE_MESSAGE: String = "这会儿没能把这段读出来"
|
||||
|
||||
/** `tts_persona_unmapped`:人格有,但这个人格没有配发音人。 */
|
||||
const val TTS_PERSONA_UNMAPPED_MESSAGE: String = "这个角色还没配好发音人"
|
||||
|
||||
/** `tts_no_character`:连生效人格都没有(会话没绑定、也没有全局默认人格)。 */
|
||||
const val TTS_NO_CHARACTER_MESSAGE: String = "这个会话还没有配好发音人"
|
||||
|
||||
/** 服务端回了配置缺失类错误码、但本端还不认识那一个时的兜底文案。 */
|
||||
const val TTS_UNCONFIGURED_MESSAGE: String = "这个角色还没有配好朗读的发音人"
|
||||
|
||||
/** 朗读播放通道的键前缀(与语音副本的键 `messageId` 区分开)。 */
|
||||
const val TTS_CLIP_PREFIX: String = "tts:"
|
||||
|
||||
/** 服务端错误码:人格没有发音人映射。 */
|
||||
const val TTS_CODE_PERSONA_UNMAPPED: String = "tts_persona_unmapped"
|
||||
|
||||
/** 服务端错误码:连生效人格都没有。 */
|
||||
const val TTS_CODE_NO_CHARACTER: String = "tts_no_character"
|
||||
@@ -228,6 +228,31 @@ object ShareRules {
|
||||
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
|
||||
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
|
||||
|
||||
/**
|
||||
* 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。
|
||||
*
|
||||
* ## 两个事实合起来只剩两种答案
|
||||
* - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张
|
||||
* 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断
|
||||
* (后端口径 `02` F4:他们那边不需要做任何事)。
|
||||
* - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断
|
||||
* 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个**
|
||||
* 这样的意思。
|
||||
*
|
||||
* 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。
|
||||
*
|
||||
* ## 为什么提成一个纯函数
|
||||
* 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传
|
||||
* `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直
|
||||
* 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是
|
||||
* 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与
|
||||
* [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。
|
||||
*
|
||||
* @param fromShare 这条(这一批)是不是分享进来的
|
||||
* @param index 在这次发送切出的消息里的位置(0 = 第一条)
|
||||
*/
|
||||
fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0
|
||||
|
||||
/**
|
||||
* 从分享 Intent 的原始字段提取内容。
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
@@ -64,6 +66,8 @@ import top.yeij.cyrene.core.session.ChatSessionSlots
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheStore
|
||||
import top.yeij.cyrene.data.repository.ImageCacheStore
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.data.voice.VoiceRecorder
|
||||
import top.yeij.cyrene.data.voice.VoiceRecorderState
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||
@@ -76,7 +80,10 @@ import top.yeij.cyrene.domain.chat.DocumentTarget
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.ImageSource
|
||||
import top.yeij.cyrene.domain.chat.SharedTextPlan
|
||||
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
import top.yukonga.miuix.kmp.basic.Button
|
||||
@@ -93,6 +100,7 @@ import top.yukonga.miuix.kmp.icon.basic.Search
|
||||
import top.yukonga.miuix.kmp.icon.basic.Sidebar
|
||||
import top.yukonga.miuix.kmp.icon.extended.File
|
||||
import top.yukonga.miuix.kmp.icon.extended.Image
|
||||
import top.yukonga.miuix.kmp.icon.extended.Mic
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 判定「贴着底部」的容差(px)。 */
|
||||
@@ -110,6 +118,14 @@ private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L
|
||||
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
|
||||
private const val SEARCH_NOTICE_MILLIS = 8_000L
|
||||
|
||||
/**
|
||||
* 麦克风相关的告知(没授权 / 起不来)挂多久(毫秒)。
|
||||
*
|
||||
* 与同页其它几条「动作刚发生的如实告知」取同一个值:它描述的也是刚刚那一次按键,
|
||||
* 不是持续状态(权限是不是真的没有,用户再按一次麦克风就知道了)。
|
||||
*/
|
||||
private const val VOICE_FAILURE_NOTICE_MILLIS = 8_000L
|
||||
|
||||
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
|
||||
private val InputBottomSpacer = 22.dp
|
||||
|
||||
@@ -403,6 +419,120 @@ fun ChatScreen(
|
||||
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
|
||||
val voicePlayback by voicePlayer.state.collectAsState()
|
||||
|
||||
// —— 语音输入(契约 v0.96 §3.15 ①「用户按住麦克风说话」)——
|
||||
// 录音机归**这一个会话**:换会话 = 换实例(与上面那条播放通道同一个生命周期纪律),
|
||||
// 离开聊天页时 `onDispose` 收掉麦克风——一段没发出去的录音不该在后台继续占着它。
|
||||
//
|
||||
// ⚠️ 录音中**转屏**会重建组合 ⇒ 这一段丢失。本端不为此加一个「录音仓」:录音是几秒钟的
|
||||
// 动作,转屏 / 分屏重建时丢掉一次比在 VM 里维护一条 AudioRecord 生命周期便宜得多。
|
||||
val recorder = remember(sessionId) { VoiceRecorder() }
|
||||
DisposableEffect(recorder) {
|
||||
onDispose { recorder.release() }
|
||||
}
|
||||
val recording by recorder.state.collectAsState()
|
||||
val recordingState = recording as? VoiceRecorderState.Recording
|
||||
// 麦克风起不来 / 没授权时的如实告知(本端的问题,不走 VM 的 `error` 通道:
|
||||
// 那条通道的语义是「服务端 / 发送」,而这里一次都没上过路)。
|
||||
var voiceFailure by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(voiceFailure) {
|
||||
if (voiceFailure != null) {
|
||||
delay(VOICE_FAILURE_NOTICE_MILLIS)
|
||||
voiceFailure = null
|
||||
}
|
||||
}
|
||||
|
||||
val requestAudioPermission = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
// 授权刚拿到手就直接开录(用户按麦克风的意思就是「现在说」,不必再按第二次)。
|
||||
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
|
||||
} else {
|
||||
voiceFailure = VOICE_PERMISSION_DENIED_MESSAGE
|
||||
}
|
||||
}
|
||||
|
||||
// 开始录音:有权限就直接起;没有就申请。起不来时**如实说一句**,绝不假装在录。
|
||||
fun startVoiceRecording() {
|
||||
// 已经在录就不重入(录音中麦克风那枚按钮本来就不在屏幕上,这里是第二道闸)。
|
||||
if (recorder.state.value is VoiceRecorderState.Recording) return
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
|
||||
} else {
|
||||
voiceFailure = null
|
||||
requestAudioPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
// 「这一次收尾已经在飞」:防双击「发送」,也防它和「录满 60 秒自动收尾」撞在同一帧上。
|
||||
// 第二次进入时录音机已经不在录了,`stop()` 会回 `null`——那会被误读成「没有录到声音」。
|
||||
var finishingVoice by remember { mutableStateOf(false) }
|
||||
// 结束录音并交给 VM:准入判据(空 / 太短 / 超限)全在 `VoiceClip.plan` 里,这里一个字节
|
||||
// 都不自己判——空录音也照旧交给它,好让用户拿到那句「没有录到声音」。
|
||||
fun finishVoiceRecording() {
|
||||
if (finishingVoice) return
|
||||
finishingVoice = true
|
||||
scope.launch {
|
||||
try {
|
||||
val pcm = recorder.stop()
|
||||
viewModel.sendVoice(pcm ?: ByteArray(0))
|
||||
} finally {
|
||||
finishingVoice = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 录满 60 秒:录音机只置位不自动发送(见 VoiceRecorder 的 KDoc),收尾由这一层做。
|
||||
val reachedLimit = recordingState?.reachedLimit == true
|
||||
LaunchedEffect(reachedLimit) {
|
||||
if (reachedLimit) finishVoiceRecording()
|
||||
}
|
||||
|
||||
// `voice_output` 到了就放(契约:这一帧只定向回本连接、断线不补投 ⇒ 不播就永远听不到)。
|
||||
// 键里带 `file`:音频先落成本地文件再进状态,落好那一刻才是真正能播的时刻。
|
||||
// `autoPlay` 只用一次:这一帧到达时用户可能在别的会话,切回来时不该突然响一段旧语音
|
||||
// (判据见 VoiceReply.autoPlay)。「已经在放 / 正在载入」时不重新触发——否则第二次状态
|
||||
// 到达会把正在响的那条停掉。
|
||||
val voiceReply = state.voiceReply
|
||||
LaunchedEffect(voiceReply?.id, voiceReply?.file) {
|
||||
val reply = voiceReply ?: return@LaunchedEffect
|
||||
val file = reply.file ?: return@LaunchedEffect
|
||||
if (!reply.autoPlay) return@LaunchedEffect
|
||||
viewModel.consumeVoiceReplyAutoPlay()
|
||||
if (!voicePlayer.state.value.isActiveFor(reply.id)) {
|
||||
voicePlayer.toggleClip(reply.id) { Result.success(file) }
|
||||
}
|
||||
}
|
||||
|
||||
// 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:<messageId>`,
|
||||
// 与语音副本天然互斥——点一个停另一个)。键里带 `file`:`null → 文件` 那一刻才是真正能播的
|
||||
// 时刻。`autoPlay` 只用一次,理由与上面那条完全相同(用户可能已经切走了)。
|
||||
//
|
||||
// ⚠️ `autoPlay` 必须进键:用户「放 → 停 → 再点同一条」时 `(clipId, file)` 一对**没变**
|
||||
// (同一份缓存文件),只按那一对做键就再也不会起播。置假那一次会把键改成 `false`、下一轮
|
||||
// 点击改回 `true`,于是每一点都重新点着一次;置假本身触发的重入在第一行就返回了,不成环。
|
||||
//
|
||||
// 起播之后这一格**不再变**:播放走到哪一步是播放通道的事,VM 不需要知道(见 `TtsClip` 的
|
||||
// 类 KDoc)。
|
||||
val ttsClip = state.ttsClip
|
||||
LaunchedEffect(ttsClip?.clipId, ttsClip?.file, ttsClip?.autoPlay) {
|
||||
val clip = ttsClip ?: return@LaunchedEffect
|
||||
val file = clip.file ?: return@LaunchedEffect
|
||||
if (!clip.autoPlay) return@LaunchedEffect
|
||||
viewModel.consumeTtsAutoPlay()
|
||||
if (!voicePlayer.state.value.isActiveFor(clip.clipId)) {
|
||||
voicePlayer.toggleClip(clip.clipId) { Result.success(file) }
|
||||
}
|
||||
}
|
||||
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
|
||||
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
|
||||
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
|
||||
DisposableEffect(viewModel) {
|
||||
onDispose { viewModel.dropPendingTtsAutoPlay() }
|
||||
}
|
||||
|
||||
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
|
||||
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
|
||||
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
|
||||
@@ -569,8 +699,29 @@ fun ChatScreen(
|
||||
images = state.draftImages,
|
||||
documents = state.draftDocuments,
|
||||
busyText = busyLabel(state),
|
||||
recordingMs = recordingState?.elapsedMs,
|
||||
voiceDraft = state.voiceDraft,
|
||||
voiceReply = state.voiceReply,
|
||||
voicePlayback = voicePlayback,
|
||||
onValueChange = viewModel::onDraftChange,
|
||||
onSend = viewModel::send,
|
||||
onStartRecording = { startVoiceRecording() },
|
||||
onStopRecording = { finishVoiceRecording() },
|
||||
onCancelRecording = { recorder.cancel() },
|
||||
onRetryVoice = viewModel::retryVoice,
|
||||
onDiscardVoice = viewModel::discardVoice,
|
||||
// 播放 / 停止与关闭都在这一个动作里收口:关掉卡片之后再响一秒是明确的 bug。
|
||||
onToggleVoiceReply = {
|
||||
val reply = state.voiceReply
|
||||
val file = reply?.file
|
||||
if (reply != null && file != null) {
|
||||
voicePlayer.toggleClip(reply.id) { Result.success(file) }
|
||||
}
|
||||
},
|
||||
onDismissVoiceReply = {
|
||||
voicePlayer.stop()
|
||||
viewModel.dismissVoiceReply()
|
||||
},
|
||||
onPickImage = {
|
||||
imagePicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
@@ -635,6 +786,11 @@ fun ChatScreen(
|
||||
// 语音播放(接收侧):状态来自**这一个会话**的那条播放通道,
|
||||
// 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。
|
||||
voicePlayback = voicePlayback,
|
||||
// 朗读(W75):**只有这一条**的朗读状态进来(对不上的当没点过)。
|
||||
// 播放状态仍是同一条通道——朗读的键是 `tts:<messageId>`,所以两枚
|
||||
// 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。
|
||||
ttsClip = state.ttsClip?.takeIf { it.messageId == item.message.id },
|
||||
ttsPlayback = voicePlayback,
|
||||
onDocumentClick = { ref ->
|
||||
previewNotice = null
|
||||
previewTarget = DocumentTarget.of(ref)
|
||||
@@ -650,6 +806,20 @@ fun ChatScreen(
|
||||
onEdit = viewModel::startEdit,
|
||||
onDelete = viewModel::startDelete,
|
||||
onVoiceToggle = voicePlayer::toggle,
|
||||
// 朗读:请求、失败分类、落盘全在 VM;但**这一下是停还是念**必须在
|
||||
// 这里分(判据是纯函数 [ttsSpeakIntent])。直通 `viewModel::speak`
|
||||
// 会让「停止朗读」变成空操作(P1-1):播放中 `TtsClip.pending` 是假
|
||||
// ⇒ VM 的防抖放行、整格换成新 pending、缓存命中秒回,而下面那个起播
|
||||
// effect 又因为「已经在放同一条」跳过 `toggleClip`——三处各自都对,
|
||||
// 合起来点了没反应。唯一带停止语义的入口在播放通道上,所以在这一层分。
|
||||
onSpeak = { message ->
|
||||
val clip = state.ttsClip
|
||||
?.takeIf { it.messageId == message.id }
|
||||
when (ttsSpeakIntent(voicePlayer.state.value, message.id, clip)) {
|
||||
TtsSpeakIntent.Stop -> voicePlayer.stop()
|
||||
TtsSpeakIntent.Speak -> viewModel.speak(message)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
is ChatDisplayItem.Pending -> PendingBubble(
|
||||
@@ -753,6 +923,14 @@ fun ChatScreen(
|
||||
state.sendQueueNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
// 语音输入被本端挡下(没录到 / 太短 / 太大):与上面几条同档的如实告知。
|
||||
state.voiceNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
// 麦克风起不来 / 没授权:这是真错误(功能整个不可用),所以用红色。
|
||||
voiceFailure?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.error)
|
||||
}
|
||||
searchNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
@@ -906,14 +1084,43 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入栏:文本 / 图片 / 附件 / **语音**(契约 v0.96 §3.15 ①)。
|
||||
*
|
||||
* ## 三段语音形态各占一行(从上到下)
|
||||
* 1. [VoiceReplyRow]——她的语音回复(`voice_output` 的产物)。放在最上面,因为它说的是
|
||||
* 「刚刚那一轮的答复」,而下面几行是用户还要做的动作;
|
||||
* 2. [VoiceDraftRow]——一段录好但没发出去的语音(重发 / 丢弃);
|
||||
* 3. **录音中**:整行换成 [VoiceRecordingRow](录音中 / 取消 / 发送)。
|
||||
*
|
||||
* ## 录音中为什么把整行换掉
|
||||
* 录音是一条有去无回的动作:那一行里同时留着一个「发送」按钮(文本的)与一个「结束录音」按钮,
|
||||
* 手指落错就是把半句话当成文本发出去。换掉之后**文本发送那条路一个字节都没变**(不录音时
|
||||
* 逐字与之前相同),而录音中屏幕上有且只有与该动作有关的两个选择。
|
||||
*
|
||||
* ## 语音入口为什么放在「发送」左边
|
||||
* 与图片 / 附件同一侧(都是「加内容」),发送键仍在最右(肌肉记忆不变)。麦克风常驻——
|
||||
* 它是与打字并列的**另一种输入方式**,不是某个附加能力。
|
||||
*/
|
||||
@Composable
|
||||
private fun InputBar(
|
||||
value: String,
|
||||
images: List<String>,
|
||||
documents: List<DraftDocument>,
|
||||
busyText: String,
|
||||
recordingMs: Int?,
|
||||
voiceDraft: VoiceDraftInfo?,
|
||||
voiceReply: VoiceReply?,
|
||||
voicePlayback: VoicePlaybackState,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onStartRecording: () -> Unit,
|
||||
onStopRecording: () -> Unit,
|
||||
onCancelRecording: () -> Unit,
|
||||
onRetryVoice: () -> Unit,
|
||||
onDiscardVoice: () -> Unit,
|
||||
onToggleVoiceReply: () -> Unit,
|
||||
onDismissVoiceReply: () -> Unit,
|
||||
onPickImage: () -> Unit,
|
||||
onRemoveImage: (Int) -> Unit,
|
||||
onPickDocument: () -> Unit,
|
||||
@@ -926,6 +1133,25 @@ private fun InputBar(
|
||||
.imePadding()
|
||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
// 她的语音回复:最上面一行,因为它说的是「刚刚那一轮的答复」。
|
||||
voiceReply?.let { reply ->
|
||||
VoiceReplyRow(
|
||||
reply = reply,
|
||||
playback = voicePlayback,
|
||||
onTogglePlay = onToggleVoiceReply,
|
||||
onDismiss = onDismissVoiceReply,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
// 录好但没发出去的那一段:只在 `voice_input` 没能上行时出现。
|
||||
voiceDraft?.let { draft ->
|
||||
VoiceDraftRow(
|
||||
draft = draft,
|
||||
onRetry = onRetryVoice,
|
||||
onDiscard = onDiscardVoice,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
DraftImagePreviews(images = images, onRemove = onRemoveImage)
|
||||
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
|
||||
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
|
||||
@@ -934,46 +1160,64 @@ private fun InputBar(
|
||||
onRemove = onRemoveDocument,
|
||||
onClick = onPreviewDocument,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
|
||||
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
|
||||
if (images.size < ShareRules.MAX_IMAGES) {
|
||||
IconButton(onClick = onPickImage) {
|
||||
if (recordingMs != null) {
|
||||
VoiceRecordingRow(
|
||||
elapsedMs = recordingMs,
|
||||
onCancel = onCancelRecording,
|
||||
onSend = onStopRecording,
|
||||
)
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
|
||||
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
|
||||
if (images.size < ShareRules.MAX_IMAGES) {
|
||||
IconButton(onClick = onPickImage) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Image,
|
||||
contentDescription = "发送图片",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
|
||||
IconButton(onClick = onPickDocument) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Image,
|
||||
contentDescription = "发送图片",
|
||||
imageVector = MiuixIcons.File,
|
||||
contentDescription = "添加文件附件",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
|
||||
IconButton(onClick = onPickDocument) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.File,
|
||||
contentDescription = "添加文件附件",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = "和昔涟说点什么…",
|
||||
useLabelAsPlaceholder = true,
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = "和昔涟说点什么…",
|
||||
useLabelAsPlaceholder = true,
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = onSend,
|
||||
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
||||
// 纯附件回合服务端**支持**)。
|
||||
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
|
||||
) {
|
||||
Text("发送")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
// 麦克风(点按开始 / 点按结束,见 [VOICE_RECORD_DESCRIPTION] 的措辞)。
|
||||
// 换行后的入口与图片 / 附件同档;不读偏好、不占文本发送的任何一条判据。
|
||||
IconButton(onClick = onStartRecording) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = VOICE_RECORD_DESCRIPTION,
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = onSend,
|
||||
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
||||
// 纯附件回合服务端**支持**)。
|
||||
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
|
||||
) {
|
||||
Text("发送")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
|
||||
|
||||
@@ -2,9 +2,11 @@ package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -28,9 +30,12 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.image.ChatImagePreparer
|
||||
import top.yeij.cyrene.data.image.PreparedImage
|
||||
import top.yeij.cyrene.data.repository.ChatAck
|
||||
import top.yeij.cyrene.data.repository.DocumentCache
|
||||
import top.yeij.cyrene.data.repository.MessageEditDraft
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.PagedMessages
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.data.voice.SpeechClient
|
||||
import top.yeij.cyrene.data.repository.applyMessageEdit
|
||||
import top.yeij.cyrene.data.repository.historyForSession
|
||||
import top.yeij.cyrene.data.repository.mergeMessages
|
||||
@@ -55,6 +60,9 @@ import top.yeij.cyrene.domain.chat.SendChannel
|
||||
import top.yeij.cyrene.domain.chat.SharedTextPlan
|
||||
import top.yeij.cyrene.domain.chat.TurnBinding
|
||||
import top.yeij.cyrene.domain.chat.TurnBucket
|
||||
import top.yeij.cyrene.domain.chat.VoiceClip
|
||||
import top.yeij.cyrene.domain.chat.VoiceClipPlan
|
||||
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
|
||||
import top.yeij.cyrene.domain.chat.allClosed
|
||||
import top.yeij.cyrene.domain.chat.closingBucketOf
|
||||
import top.yeij.cyrene.domain.chat.closingTurn
|
||||
@@ -64,7 +72,14 @@ import top.yeij.cyrene.domain.chat.parallelForSend
|
||||
import top.yeij.cyrene.domain.chat.parallelWireValue
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import top.yeij.cyrene.domain.chat.toDocumentFailure
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
import top.yeij.cyrene.domain.chat.ttsFailureOf
|
||||
import top.yeij.cyrene.domain.chat.ttsTextOf
|
||||
import top.yeij.cyrene.domain.chat.turnBucketOf
|
||||
import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor
|
||||
import top.yeij.cyrene.domain.chat.voiceClipRejection
|
||||
import top.yeij.cyrene.domain.chat.voiceOutputHasAudio
|
||||
import top.yeij.cyrene.domain.chat.voiceReplyText
|
||||
import top.yeij.cyrene.domain.chat.withSteeredMessage
|
||||
import top.yeij.cyrene.domain.chat.withTurnBegin
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
@@ -109,6 +124,15 @@ data class PendingMessage(
|
||||
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
|
||||
*/
|
||||
val documents: List<DraftDocument> = emptyList(),
|
||||
/**
|
||||
* 这一条是**语音**时它的时长(毫秒);`null` = 不是语音(绝大多数消息)。
|
||||
*
|
||||
* 语音的 [text] 恒为空:本端不做本地转写,用户说的话要等服务端 STT 之后才会作为
|
||||
* user 消息的 `content` 落库。而气泡**必须立刻出现**(用户刚说完,屏幕上没有东西就是
|
||||
* 「没发出去」的样子)⇒ 这一条消息在正文之外还需要一个「这是语音、多长」的可见凭据。
|
||||
* 它只影响渲染(`PendingBubble` 画那一枚「语音 · 0:07」),不参与发送与指纹。
|
||||
*/
|
||||
val voiceDurationMs: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -616,6 +640,76 @@ fun ChatUiState.applyMessageActionFailure(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点「朗读」这一步的状态迁移(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* 返回 `null` = **这一次点击该被忽略**:同一条消息的请求还在飞([TtsClip.pending])。这是防抖的
|
||||
* 唯一判据。已经落好(`file != null`)或已经失败过(`failure != null`)的**不算**在飞 ——
|
||||
* 前者是「再放一遍」,后者是用户手动重试,两种都该放行。
|
||||
*
|
||||
* 返回新状态 = 放行,并且这一格已经换成「请求在飞」。`autoPlay` 保持默认的真:字节到手那一刻
|
||||
* 要起播一次(见 [TtsClip.autoPlay]),置假只在真的起播之后(`ChatViewModel.consumeTtsAutoPlay`)。
|
||||
*/
|
||||
fun ChatUiState.beginTtsSpeak(messageId: String, clipId: String): ChatUiState? {
|
||||
val current = ttsClip
|
||||
if (current != null && current.clipId == clipId && current.pending) return null
|
||||
return copy(ttsClip = TtsClip(messageId, clipId, null, null, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求回来了,把结论落到这一格(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、
|
||||
* 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。
|
||||
*
|
||||
* `autoPlay` **从旧那一格继承**而不是重置成真:用户可能在请求落地之前就离开了这一屏
|
||||
* ([ChatUiState.dropPendingTtsAutoPlay] 把这一位置了假),结果晚到之后若又把它抬回真,
|
||||
* 切回来时就会突然响一段刚才点的朗读 —— 那正是要拦的那件事。
|
||||
*
|
||||
* 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的
|
||||
* 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。
|
||||
*/
|
||||
fun ChatUiState.applyTtsOutcome(
|
||||
messageId: String,
|
||||
clipId: String,
|
||||
result: Result<File>,
|
||||
): ChatUiState {
|
||||
val current = ttsClip
|
||||
if (current == null || current.clipId != clipId) return this
|
||||
val autoPlay = current.autoPlay
|
||||
return result.fold(
|
||||
onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false, autoPlay)) },
|
||||
onFailure = { error ->
|
||||
val failure = ttsFailureOf(error)
|
||||
copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable, autoPlay))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一屏要走了:把**还没到手**的那一次自动起播作废(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
* 起播闸门活在渲染层那个 `LaunchedEffect` 里(见 `ChatScreen`),而它只在**这一屏在的时候**
|
||||
* 才会被跑一次。用户点了朗读之后立刻切走(换会话 / 进详情页),合成还在飞;结果落地的瞬间这一屏
|
||||
* 不在,闸门没被消费过;等他切回来,那个 effect 一挂上就以「文件到了 + `autoPlay` 还是真」为由
|
||||
* 起播 —— 突然响一段**已经过去**的朗读,不是他此刻的动作。
|
||||
*
|
||||
* ## 为什么在「离开」这一刻判,而不是给 clip 记一个发起时刻再按超龄丢
|
||||
* 时间不是这件事的判据:合成一段长文本本来就可能花上十几秒,按秒数丢会把**人就在看着屏幕**
|
||||
* 的那一次也一起丢掉(点了却不出声)。「这一屏还在不在」才是真正的分界 —— 离开时把没消费的
|
||||
* 自动起播划掉,正好只拦掉「他不在」的那些。
|
||||
*
|
||||
* ## 只动「还没到手」的那一格
|
||||
* 文件已经在手(`file != null`)说明闸门要么已经跑过(`autoPlay` 已被 [consumeTtsAutoPlay]
|
||||
* 置假)、要么就是正在起播的这一刻:这两种情况一律原样返回,绝不打断一次真的播放。
|
||||
*/
|
||||
fun ChatUiState.dropPendingTtsAutoPlay(): ChatUiState {
|
||||
val clip = ttsClip ?: return this
|
||||
if (!clip.autoPlay || clip.file != null) return this
|
||||
return copy(ttsClip = clip.copy(autoPlay = false))
|
||||
}
|
||||
|
||||
sealed interface ChatDisplayItem {
|
||||
val key: String
|
||||
|
||||
@@ -809,6 +903,45 @@ data class ChatUiState(
|
||||
* `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
|
||||
*/
|
||||
val sessionGone: Boolean = false,
|
||||
/**
|
||||
* `voice_output` 到达后的那一份「她的语音回复」(契约 v0.96 §3.15);null = 没有要显示的那一条。
|
||||
*
|
||||
* 它**不是一条消息**,所以不在 [messages] / [pending] 里:这一帧只定向回发起 `voice_input` 的
|
||||
* 那条连接、断线不补投(契约明写),回合的正文本来也会经流式 / `message_persisted` 落进历史。
|
||||
* 这里存的是「这一次语音回合的音频 + 她说的话」这一次性产物,渲染在输入栏上方一张卡片上,
|
||||
* 用户可以随时关掉([ChatViewModel.dismissVoiceReply])。
|
||||
*
|
||||
* 音频先落成本地文件再进这一格([VoiceReply.file]):把 base64 塞进状态会让**每一次**
|
||||
* 状态 `copy` 都在几 MB 的字符串上做相等比较(流式期间每秒几十次)。
|
||||
*/
|
||||
val voiceReply: VoiceReply? = null,
|
||||
/**
|
||||
* 一段**录好、但没发出去**的语音(`voice_input` 没能上行);null = 没有等着重发的。
|
||||
*
|
||||
* 只有小字段(键 / 时长 / 字节数),audio 本体留在 VM 的私有字段里——理由同上。
|
||||
* 界面上它是输入栏上方一条带「重发 / 丢弃」的卡片:重发**复用同一个 `clientMsgId`**
|
||||
* (契约:同一个非空值服务端只承认第一条),所以重发不会让同一句话落成两条。
|
||||
*/
|
||||
val voiceDraft: VoiceDraftInfo? = null,
|
||||
/**
|
||||
* 语音输入被**本端**挡下时的如实告知(没录到 / 太短 / 太大,见 `domain.chat.voiceClipRejection`);
|
||||
* null = 没有要说的。
|
||||
*
|
||||
* 与 [error] 分开:这些都不是「服务端说不行」,而是本端的准入闸门,且用户要做的事很具体
|
||||
* (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。
|
||||
*/
|
||||
val voiceNotice: String? = null,
|
||||
/**
|
||||
* 「把这一条念出来」这一次的产物(W75,`POST /api/v1/audio/speech`);null = 用户还没点过朗读。
|
||||
*
|
||||
* **只有一格**:用户同一时刻点的是**一条**消息(见 [TtsClip])。它同时承载三件事 ——
|
||||
* 请求在飞([TtsClip.pending])、字节已经落到本机([TtsClip.file])、上一次为什么没成
|
||||
* ([TtsClip.failure])。音频本体是**文件路径**而不是字节,理由同 [voiceReply]:每次状态
|
||||
* `copy` 都在几 MB 的数组上做相等比较是不可接受的。
|
||||
*
|
||||
* 「正在响」不在这一格里 —— 那归唯一的播放通道(`ui.chat.VoicePlayer`),键是 [TtsClip.clipId]。
|
||||
*/
|
||||
val ttsClip: TtsClip? = null,
|
||||
) {
|
||||
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
|
||||
val items: List<ChatDisplayItem>
|
||||
@@ -918,13 +1051,26 @@ class ChatViewModel(
|
||||
it.draftDocuments.isNotEmpty() ||
|
||||
it.pending.isNotEmpty() ||
|
||||
it.queuedForConnection.isNotEmpty() ||
|
||||
it.shareConfirm != null
|
||||
it.shareConfirm != null ||
|
||||
// 一段录好但没发出去的语音:它只在本端内存里(没有任何服务端副本),
|
||||
// 交掉这一格 = 让用户重说一遍——与「还没发出去的字」同一条理由。
|
||||
it.voiceDraft != null
|
||||
}
|
||||
|
||||
private var socket: ChatSocket? = null
|
||||
private var repository: MessageRepository? = null
|
||||
private var oldestLoadedAt: Long? = null
|
||||
|
||||
/**
|
||||
* 一段**录好、但没发出去**的语音的字节(`voice_input` 没能上行)。
|
||||
*
|
||||
* 刻意是 VM 的字段而不是 [ChatUiState] 的一格:状态会被反复 `copy` 并逐字段比较(流式期间
|
||||
* 每秒几十次),几 MB 的 `ByteArray` 放在那里等于每次比较都扫一遍;而它的生命周期与 VM
|
||||
* 完全相同(转屏不丢、切走会话仍在**这一格**里,见 `ChatSessionSlots`)。
|
||||
* 状态里只留小字段 [ChatUiState.voiceDraft](键 / 时长 / 字节数)供渲染。
|
||||
*/
|
||||
private var voiceRetry: VoiceRetryClip? = null
|
||||
|
||||
/**
|
||||
* **每一路**一个流式正文解析器(键与 `ChatUiState.streams` 同源)。
|
||||
*
|
||||
@@ -1480,12 +1626,311 @@ class ChatViewModel(
|
||||
emitPlannedMessages(
|
||||
plan = imageSendPlan(text, images, documents),
|
||||
clearDraft = true,
|
||||
applySendPreference = true,
|
||||
fromShare = false,
|
||||
localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音输入(契约 v0.96 §3.15 的 ①「用户按住麦克风说话」):一段**已录好的 PCM** → `voice_input`。
|
||||
*
|
||||
* ## 准入判据只有一处([VoiceClip.plan],纯函数)
|
||||
* 空 / 太短 / 超限三档都在这里被挡下并**明确说一句**([ChatUiState.voiceNotice],到期自己收走):
|
||||
* 一段被静默吞掉的录音在用户眼里就是「按了没反应」。封 WAV 也在那里面(44 字节 RIFF 头),
|
||||
* 本函数只做「发」这一件事。
|
||||
*
|
||||
* ## 乐观气泡与文本那条同源
|
||||
* 上屏走的是**同一个** `pending` 列表([PendingMessage],只是 `text` 空、带上
|
||||
* [PendingMessage.voiceDurationMs]),失败回滚走的是**同一个** `rollbackPending`——没有另造
|
||||
* 一套「语音待发」的状态机。用户说完话气泡立刻出现,转写的正文之后由服务端的 user 消息
|
||||
* 落库回来(按 `clientMsgId` 收编,判据在 [consumePendingFor])。
|
||||
*
|
||||
* ## 为什么没有补发队列
|
||||
* 文本那条会进 [ChatUiState.queuedForConnection] 等连接;语音不进——audio 是几 MB 的内存,
|
||||
* 钉在队列里等一个可能永远不来的连接不划算,而且「这段还发不发」应当由用户决定。
|
||||
* 所以发不出去就把这一段**留着**([ChatUiState.voiceDraft]),由用户按「重发」
|
||||
* ([retryVoice],**复用同一个 `clientMsgId`**)或「丢弃」([discardVoice])。
|
||||
*
|
||||
* @param pcm `AudioRecord` 收到的 16-bit / 16 kHz / 单声道 PCM(未封头)。
|
||||
*/
|
||||
fun sendVoice(pcm: ByteArray) {
|
||||
viewModelScope.launch {
|
||||
// 封 WAV + base64 编码都是几 MB 的活(PCM 复制 + 4/3 膨胀),放默认调度器上做,
|
||||
// 别占主线程——用户按下「发送」之后这一帧就该没有可感的停顿。
|
||||
val plan = withContext(Dispatchers.Default) { VoiceClip.plan(pcm) }
|
||||
when (plan) {
|
||||
is VoiceClipPlan.Ready -> deliverVoice(plan.wav, plan.durationMs, retry = null)
|
||||
// Ready 之外的每一档都有一句人话(判定与文案都在 domain,能穷举单测)。
|
||||
else -> voiceClipRejection(plan)?.let(::showVoiceNotice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重发**刚才没发出去的那一段**(复用它的 `clientMsgId`,契约要求)。
|
||||
*
|
||||
* 没有待重发的就什么都不做(按钮本不该出现;这里是第二道闸)。字节取的是 VM 里留着的那一份
|
||||
* WAV(**不重新封装**),所以重发的是**同一个字节序列**——服务端按幂等键判重之后留下的那份
|
||||
* 音频与用户眼前这段永远一致。
|
||||
*/
|
||||
fun retryVoice() {
|
||||
val clip = voiceRetry ?: return
|
||||
viewModelScope.launch { deliverVoice(clip.wav, clip.durationMs, retry = clip) }
|
||||
}
|
||||
|
||||
/** 丢掉那段待重发的语音(用户点了「丢弃」)。 */
|
||||
fun discardVoice() {
|
||||
voiceRetry = null
|
||||
_state.update { it.copy(voiceDraft = null) }
|
||||
}
|
||||
|
||||
/** 关掉「她的语音回复」那张卡片(播放器由渲染层在同一个动作里停掉)。 */
|
||||
fun dismissVoiceReply() {
|
||||
val file = _state.value.voiceReply?.file
|
||||
_state.update { it.copy(voiceReply = null) }
|
||||
if (file != null) {
|
||||
viewModelScope.launch { withContext(Dispatchers.IO) { file.delete() } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一条语音回复的**自动播放**已经用掉了(渲染层在真的起了播放之后调)。
|
||||
*
|
||||
* 置位之后即使组合被重建(转屏 / 切走再回来)也不会再自动响一遍——见 [VoiceReply.autoPlay]。
|
||||
* 幂等:已经置假时 `copy` 出的是同一个值,不会多一次有意义的变更。
|
||||
*/
|
||||
fun consumeVoiceReplyAutoPlay() {
|
||||
_state.update { state ->
|
||||
val reply = state.voiceReply
|
||||
if (reply == null || !reply.autoPlay) state else state.copy(voiceReply = reply.copy(autoPlay = false))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点了某条消息上的「朗读」(W75,`POST /api/v1/audio/speech`)。
|
||||
*
|
||||
* ## 念什么
|
||||
* 只念**她**的消息(`assistant`):`user` 那一侧点朗读没有意义,而且服务端的发音人由
|
||||
* **会话的生效人格**决定 —— 念主人自己说的话会用她的声音,那是错的。判据就是角色这一条,
|
||||
* 其余(要念哪段字)全交给纯函数 [ttsTextOf]:它剥掉抬头与旁白,只留正文;一个字都不剩
|
||||
* (纯附件 / 纯旁白)时**直接不发请求** —— 服务端对空 `text` 回 `400`,本端没有理由先跑一趟。
|
||||
*
|
||||
* ## 防重入
|
||||
* 同一条消息的请求**在飞的时候再点一次**直接忽略:`_state.ttsClip` 已经是那条消息的
|
||||
* `pending` 态。用户点**另一条**则照常发起(旧那条被整格替换,它的音频文件留在缓存里,
|
||||
* 下次点它命中、不发请求 —— 见 `data.voice.SpeechClient`)。
|
||||
*
|
||||
* 播放那半边**不在这里**:这里只把「字节到手」写进 [ChatUiState.ttsClip],起播由渲染层那个
|
||||
* `LaunchedEffect` 交给唯一的播放通道(`ui.chat.VoicePlayer`)。分开是必须的 —— VM 不该碰
|
||||
* `MediaPlayer`,而播放通道要能在「换了一条语音副本」时独立停下,不必回头改这一格。
|
||||
*
|
||||
* ## 失败
|
||||
* 分类是纯函数 [ttsFailureOf]:`tts_persona_unmapped` / `tts_no_character` 这两档是**配置**缺失
|
||||
* (重试永远不会成功,文案要说清缺什么),其余一律柔和提示。**不重试**:这条路上没有任何
|
||||
* 自动重试 —— 用户再点一次就是那次重试,而配置缺失那一档连这句话都不该说。
|
||||
*
|
||||
* 失败**不弹窗、不走 [ChatUiState.error] 横幅**:那是「这一次请求失败了」的全局通道,而朗读
|
||||
* 是一次附带的小动作,把它抬成横幅会盖住真正的发送失败。文案挂在**这条消息**下面(见
|
||||
* `MessageBubble`)。
|
||||
*/
|
||||
fun speak(message: HistoryMessageDto) {
|
||||
if (message.role == "user") return
|
||||
val text = ttsTextOf(message) ?: return
|
||||
val clipId = ttsClipIdOf(message.id)
|
||||
// 判据与落位都在纯函数里([beginTtsSpeak] / [applyTtsOutcome]):同一条正在飞 ⇒ 这里
|
||||
// 拿回 null,直接返回。用 compareAndSet 而不是 update,是因为「放行与否」和「写进哪一
|
||||
// 格」必须是同一次读写的结论——分开写会让并发的两次点击都以为自己放行了。
|
||||
while (true) {
|
||||
val current = _state.value
|
||||
val begun = current.beginTtsSpeak(message.id, clipId) ?: return
|
||||
if (_state.compareAndSet(current, begun)) break
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = speechClient.speak(text, sessionId)
|
||||
_state.update { it.applyTtsOutcome(message.id, clipId, result) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一次朗读的**自动起播**已经用掉了(渲染层在真的起了播放之后调)。
|
||||
*
|
||||
* 与 [consumeVoiceReplyAutoPlay] 同一条纪律、同一个理由:请求发出去之后用户可能已经切到别的
|
||||
* 会话 / 别的页面,切回来时不该突然响一段刚才点的朗读(见 [TtsClip.autoPlay])。
|
||||
*/
|
||||
fun consumeTtsAutoPlay() {
|
||||
_state.update { state ->
|
||||
val clip = state.ttsClip
|
||||
if (clip == null || !clip.autoPlay) state else state.copy(ttsClip = clip.copy(autoPlay = false))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一屏走了:把**还没到手**的那一次自动起播作废(判据与取舍全在纯函数
|
||||
* [dropPendingTtsAutoPlay] 里)。
|
||||
*
|
||||
* 由渲染层的 `DisposableEffect.onDispose` 调:起播闸门只在这一屏活着的时候跑得到,人走了
|
||||
* 而结果后到,切回来时就不该突然响一段旧朗读。
|
||||
*/
|
||||
fun dropPendingTtsAutoPlay() {
|
||||
_state.update { it.dropPendingTtsAutoPlay() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造
|
||||
* (与 `AppContainer` 那些按需 getter 同一手法)。
|
||||
*
|
||||
* `baseUrlProvider` / `tokenProvider` 都传**函数**而不是值:换服务器 / 令牌续期之后,
|
||||
* 下一次朗读自动用新的(与 `mediaApi` 同源同纪律)。凭据只走 owner Bearer ——
|
||||
* 主人 2026-10-26 拍板不设 API Key 输入框(见 `data.voice.SpeechClient` 的类 KDoc)。
|
||||
*/
|
||||
private val speechClient: SpeechClient by lazy {
|
||||
SpeechClient(
|
||||
baseUrlProvider = { container.authManager.currentBaseUrl },
|
||||
tokenProvider = container.authManager::currentToken,
|
||||
unauthorizedPolicy = container.authManager.unauthorizedPolicy,
|
||||
cacheDir = container.appContext.cacheDir,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。
|
||||
*
|
||||
* 键由纯函数 [voiceClientMsgIdFor] 决定:**重发复用 [retry] 那个键**,新录音现铸一个。
|
||||
* 判定「发出去没有」用的是 `sendVoiceInput` 的返回值 + **发出那一刻**的连接状态
|
||||
* (与文本那条 [sendThroughLadder] 同一手法):连接就绪却仍写不出去,与「还没连上」
|
||||
* 是两句不同的话,用户要做的事也不同。
|
||||
*/
|
||||
private suspend fun deliverVoice(wav: ByteArray, durationMs: Int, retry: VoiceRetryClip?) {
|
||||
val draft = retry?.let { VoiceDraftInfo(it.clientMsgId, it.durationMs, it.totalBytes) }
|
||||
val clientMsgId = voiceClientMsgIdFor(draft) { UUID.randomUUID().toString() }
|
||||
val audio = withContext(Dispatchers.Default) { Base64.encodeToString(wav, Base64.NO_WRAP) }
|
||||
val totalBytes = wav.size
|
||||
|
||||
// 乐观气泡:与文本消息**同一个** pending 列表(正文空、带语音时长)。
|
||||
_state.update {
|
||||
it.copy(
|
||||
pending = it.pending + PendingMessage(
|
||||
localId = clientMsgId,
|
||||
text = "",
|
||||
voiceDurationMs = durationMs,
|
||||
),
|
||||
error = null,
|
||||
voiceDraft = null,
|
||||
voiceNotice = null,
|
||||
)
|
||||
}
|
||||
|
||||
val ws = socket
|
||||
val sent = ws?.sendVoiceInput(sessionId, audio, VoiceClip.FORMAT, DEVICE_ID, clientMsgId) ?: false
|
||||
if (sent) {
|
||||
voiceRetry = null
|
||||
return
|
||||
}
|
||||
|
||||
val connection = ws?.state?.value ?: WsState.Idle
|
||||
val reason = if (connection == WsState.Connected) {
|
||||
VOICE_SEND_FAILED_MESSAGE
|
||||
} else {
|
||||
VOICE_NOT_CONNECTED_MESSAGE
|
||||
}
|
||||
// 回滚走的是**同一个** rollbackPending(摘掉刚才那枚气泡、把原因挂进 error 通道),
|
||||
// 但不走 [rollback]:语音没有可以退回输入栏的正文 / 图 / 附件,那段字节留在
|
||||
// [voiceRetry] 里等用户决定(重发或丢弃)。
|
||||
voiceRetry = VoiceRetryClip(clientMsgId, wav, durationMs, totalBytes)
|
||||
_state.update {
|
||||
it.rollbackPending(clientMsgId, reason).copy(
|
||||
voiceDraft = VoiceDraftInfo(clientMsgId, durationMs, totalBytes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `voice_output` 落地(契约 v0.96 §3.15):**她的语音回复**(音频 + 她说的话)。
|
||||
*
|
||||
* ## 判据全在 domain(纯函数)
|
||||
* [isSilentVoiceProvider] / [voiceOutputHasAudio] 决定「这一轮有没有真音频」,
|
||||
* [voiceReplyText] 决定显示哪一句(`reply` = 她说的,`text` = 转写)。`silent` 档
|
||||
* **不画播放控件**,只显示文字 + 一句说明(见 `VoiceReplyRow`)。
|
||||
*
|
||||
* ## 音频先落成本地文件
|
||||
* base64 解码 + 写缓存都在 IO 上做(几 MB 的活不能占主线程),解码 / 落盘失败就退化成
|
||||
* 「只有文字 + 那句『本机没能准备好』」——**绝不**把本机的失败说成「她只回了文字」
|
||||
* (那是替服务端下一个它没下的结论)。
|
||||
*
|
||||
* ## 只定向回原连接、断线不补投
|
||||
* 本方法**不做任何补救**(不拉历史、不重发 `voice_input`):契约明写这一帧不补投,
|
||||
* 回合的正文与转写本来就会经流式 / `message_persisted` 落到消息列表里。
|
||||
*/
|
||||
private fun onVoiceOutput(event: WsEvent.VoiceOutput) {
|
||||
val provider = event.provider
|
||||
val hasAudio = voiceOutputHasAudio(event.audio, provider)
|
||||
val id = "voice-reply-${UUID.randomUUID()}"
|
||||
val previous = _state.value.voiceReply?.file
|
||||
_state.update {
|
||||
it.copy(
|
||||
voiceReply = VoiceReply(
|
||||
id = id,
|
||||
reply = voiceReplyText(event.reply, event.text),
|
||||
transcript = event.text?.takeIf { text -> text.isNotBlank() },
|
||||
format = event.format,
|
||||
provider = provider,
|
||||
hasAudio = hasAudio,
|
||||
file = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
// 这一轮没有音频 ⇒ 到这里就结束了(正文已经在上面那一格里)。
|
||||
val audio = event.audio?.takeIf { hasAudio } ?: return
|
||||
viewModelScope.launch {
|
||||
val file = withContext(Dispatchers.IO) {
|
||||
previous?.delete()
|
||||
storeVoiceReply(audio, event.format)
|
||||
}
|
||||
_state.update { state ->
|
||||
// 只有「这一格还是我刚放进去的那一条」才补文件:迟到的解码不许覆盖更新的那一条。
|
||||
if (state.voiceReply?.id == id) {
|
||||
state.copy(voiceReply = state.voiceReply.copy(file = file))
|
||||
} else {
|
||||
file?.delete()
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `voice_output` 的 base64 音频落成 `cacheDir/media/` 下一个文件(**IO 线程调用**)。
|
||||
*
|
||||
* 目录与写入纪律直接复用 [VoiceStore] / [DocumentCache]:同一个 `media/` 根(与接收侧的
|
||||
* 语音副本同一层,与 `voice.url` 的 `/api/v1/media/` 同源)、同样的「先写 `.part` 再改名」。
|
||||
* 名字是现铸的随机名(`reply-<uuid>.<ext>`)而不是内容寻址:这一份是**一次性**的实时产物,
|
||||
* 没有第二个人会来按名字取它。
|
||||
*
|
||||
* @return 可直接交给 `MediaPlayer` 的文件;任何一步失败回 `null`(调用方据此退化成文字)。
|
||||
*/
|
||||
private fun storeVoiceReply(base64: String, format: String?): File? = runCatching {
|
||||
val bytes = Base64.decode(base64, Base64.NO_WRAP)
|
||||
if (bytes.isEmpty()) return null
|
||||
// 扩展名只影响可读性:`MediaPlayer` 按文件头嗅探,不认扩展名。不认识的格式一律 `.bin`。
|
||||
val extension = format?.trim()?.lowercase()?.takeIf { it in VOICE_REPLY_FORMATS } ?: "bin"
|
||||
val file = File(
|
||||
VoiceStore.directory(container.appContext.cacheDir),
|
||||
"reply-${UUID.randomUUID()}.$extension",
|
||||
)
|
||||
DocumentCache.write(file, bytes)
|
||||
file
|
||||
}.getOrNull()
|
||||
|
||||
/** 记一条语音输入的如实告知,到期自动收走(与 [showAttachmentNotice] 同一形态)。 */
|
||||
private fun showVoiceNotice(text: String) {
|
||||
_state.update { it.copy(voiceNotice = text) }
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(VOICE_NOTICE_MILLIS)
|
||||
_state.update { if (it.voiceNotice == text) it.copy(voiceNotice = null) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
|
||||
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
|
||||
@@ -1509,7 +1954,7 @@ class ChatViewModel(
|
||||
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
|
||||
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
|
||||
*
|
||||
* ## 分享不声明(`applySendPreference` 恒为 false)
|
||||
* ## 分享不声明(`fromShare = true` ⇒ [ShareRules.declaresSendPreference] 恒为 false)
|
||||
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递,
|
||||
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
|
||||
* 两种声明在这里**都**是错的:
|
||||
@@ -1570,7 +2015,7 @@ class ChatViewModel(
|
||||
emitPlannedMessages(
|
||||
plan = imageSendPlan(text, share.images, files),
|
||||
clearDraft = false,
|
||||
applySendPreference = false,
|
||||
fromShare = true,
|
||||
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
|
||||
)
|
||||
}
|
||||
@@ -1734,16 +2179,20 @@ class ChatViewModel(
|
||||
* 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则
|
||||
* 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。
|
||||
*
|
||||
* 判据**只有一处**:[ShareRules.declaresSendPreference](纯函数,穷举整批的测试在
|
||||
* `ShareSendDeclarationTest`)。这里只把两条入口的事实([fromShare])与位置([index])喂给它,
|
||||
* 不在本函数里再写一遍 `&& index == 0`——那种写法正是 W76 之前两处各半个判据的由来。
|
||||
*
|
||||
* @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送,
|
||||
* 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。
|
||||
* @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的
|
||||
* 那条传 false(分享不是用户按的发送键,从不声明)。
|
||||
* @param fromShare 这一批是不是分享进来的:分享落地那条传 true([sendShared]),输入栏那条传
|
||||
* false([send])。它是 [ShareRules.declaresSendPreference] 的入参之一——分享从不声明。
|
||||
* @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。
|
||||
*/
|
||||
private suspend fun emitPlannedMessages(
|
||||
plan: ImageSendPlan,
|
||||
clearDraft: Boolean,
|
||||
applySendPreference: Boolean,
|
||||
fromShare: Boolean,
|
||||
localIdPrefix: String,
|
||||
) {
|
||||
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages)
|
||||
@@ -1754,7 +2203,7 @@ class ChatViewModel(
|
||||
imageUri = message.imageUri,
|
||||
documents = message.documents,
|
||||
clearDraft = clearDraft && index == 0,
|
||||
applySendPreference = applySendPreference && index == 0,
|
||||
applySendPreference = ShareRules.declaresSendPreference(fromShare, index),
|
||||
localIdPrefix = localIdPrefix,
|
||||
)
|
||||
}
|
||||
@@ -1766,9 +2215,9 @@ class ChatViewModel(
|
||||
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
||||
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
||||
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
|
||||
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false:
|
||||
* [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些
|
||||
* ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。
|
||||
* `interrupt` 与 `parallel`)。它是**已经算好的结果**,由 [emitPlannedMessages] 按
|
||||
* [ShareRules.declaresSendPreference] 逐条判出来(唯一调用方就是它)——本函数不再自己推导,
|
||||
* 也没有默认值:判据只能有一个出处,给个 `= true` 的默认值等于把口径又抄了一遍。
|
||||
* @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认),
|
||||
* [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在
|
||||
* 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动**
|
||||
@@ -1781,7 +2230,7 @@ class ChatViewModel(
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
clearDraft: Boolean,
|
||||
applySendPreference: Boolean = true,
|
||||
applySendPreference: Boolean,
|
||||
localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX,
|
||||
) {
|
||||
// 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。
|
||||
@@ -1861,10 +2310,10 @@ class ChatViewModel(
|
||||
// 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。
|
||||
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
|
||||
//
|
||||
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。
|
||||
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍
|
||||
// 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true
|
||||
// (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。
|
||||
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,且是这一批的第一条)。
|
||||
// 这个值由 [emitPlannedMessages] 按 [ShareRules.declaresSendPreference] 逐条算好后传进来——
|
||||
// 分享那条入口([ChatViewModel.sendShared])因此一次都不声明:它是**批量**投递,两种声明在那儿
|
||||
// 都是错的(理由与取舍见 [sendShared] 的「分享不声明」一段)。
|
||||
val mode = currentInterruptMode()
|
||||
val busy = _state.value.busyMode != null
|
||||
if (applySendPreference) interruptRunningTurn(mode, busy)
|
||||
@@ -2285,7 +2734,7 @@ class ChatViewModel(
|
||||
* 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发
|
||||
* `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支
|
||||
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时**
|
||||
* 才调(分享那条批量路径传 false,见 [sendShared])。
|
||||
* 才调(那个值出自 [ShareRules.declaresSendPreference],分享那条批量路径恒为 false,见 [sendShared])。
|
||||
*
|
||||
* [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**:
|
||||
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
|
||||
@@ -2903,6 +3352,12 @@ class ChatViewModel(
|
||||
|
||||
is WsEvent.MessageDeleted -> applyMessageDeleted(event.id)
|
||||
|
||||
// 语音应答(契约 v0.96 §3.15):会话归属由解析层从 `data` 提到帧上(帧壳没有
|
||||
// sessionId),所以上面那道 `isForSession` 闸对它**真的生效**——别的会话的语音
|
||||
// 不会在本会话响起来。未知归属(坏帧)按同一道闸的既有口径放行(「无法判定」≠
|
||||
// 「不属于本会话」)。判据与取舍见 [onVoiceOutput]。
|
||||
is WsEvent.VoiceOutput -> onVoiceOutput(event)
|
||||
|
||||
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
|
||||
_state.update { it.copy(busyMode = event.mode).withActiveTurns(event.activeTurns) }
|
||||
}
|
||||
@@ -3382,6 +3837,46 @@ private const val SHARE_TEXT_NOT_STAGED = "这段内容没能存成文件,已
|
||||
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
|
||||
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
|
||||
|
||||
/** 语音输入被本端挡下时那句告知停留的时长(同其它几条「动作刚发生」的告知)。 */
|
||||
private const val VOICE_NOTICE_MILLIS = 8 * 1000L
|
||||
|
||||
/**
|
||||
* 语音**还没连上**、因此一个字节都没发出去时的如实告知(走 `error` 通道,与「发送失败」同档)。
|
||||
*
|
||||
* 说清三件事:没连上(所以确实没发出去)、这段没丢(留在 `voiceDraft` 那一行)、下一步按「重发」。
|
||||
* 刻意不写「发送失败」:失败的是连接,而这一段一次都没上过路——这与文本那条
|
||||
* [SEND_QUEUED_NOTICE] 的措辞纪律同源,只是语音的字节留在本端等**用户**决定(不进自动补发队列)。
|
||||
*/
|
||||
private const val VOICE_NOT_CONNECTED_MESSAGE = "还没连上,这段语音没发出去;连上后点「重发」再试一次"
|
||||
|
||||
/**
|
||||
* 连接就绪却仍写不出去(socket 对象已空 / 序列化抛异常)时的如实告知。
|
||||
*
|
||||
* 与 [VOICE_NOT_CONNECTED_MESSAGE] 分开:这一档说的不是连接,而是这一次写帧真的失败了,
|
||||
* 所以不提「连上后」——那时候它已经连着。
|
||||
*/
|
||||
private const val VOICE_SEND_FAILED_MESSAGE = "这段语音没发出去,点「重发」再试一次"
|
||||
|
||||
/**
|
||||
* `voice_output` 的音频落盘时**允许照抄**的扩展名(契约白名单 `wav|mp3|ogg`,服务端目前恒 `wav`)。
|
||||
* 白名单之外的取值一律用 `.bin`:扩展名只用来让文件看得懂,**不**参与解码判定。
|
||||
*/
|
||||
private val VOICE_REPLY_FORMATS: Set<String> = setOf("wav", "mp3", "ogg")
|
||||
|
||||
/**
|
||||
* 一段「录好、还没发出去」的语音的**完整**内容(只在 VM 私有字段里,不进界面状态)。
|
||||
*
|
||||
* [wav] 是已经封好 RIFF 头的那份字节:重发时原样再编码一次 base64,**不重新封装**——
|
||||
* 重发的必须是同一段字节,否则「同一个 `clientMsgId`」在服务端判重之后留下的那份音频
|
||||
* 与用户眼前这段就对不上了。
|
||||
*/
|
||||
private data class VoiceRetryClip(
|
||||
val clientMsgId: String,
|
||||
val wav: ByteArray,
|
||||
val durationMs: Int,
|
||||
val totalBytes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* 「并行没排上」告知在界面上停留的时长(同上:描述刚发生的那一次发送)。
|
||||
*
|
||||
|
||||
@@ -47,9 +47,18 @@ import top.yeij.cyrene.domain.chat.DocumentChipModel
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.ImageHostRebase
|
||||
import top.yeij.cyrene.domain.chat.TTS_LOADING_DESCRIPTION
|
||||
import top.yeij.cyrene.domain.chat.TTS_SPEAK_DESCRIPTION
|
||||
import top.yeij.cyrene.domain.chat.TTS_STOP_DESCRIPTION
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.hasVoice
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.isLoadingFor
|
||||
import top.yeij.cyrene.domain.chat.isVoiceMessage
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
import top.yeij.cyrene.domain.chat.ttsTextOf
|
||||
import top.yeij.cyrene.domain.chat.voiceFallbackNote
|
||||
import top.yeij.cyrene.domain.chat.voiceNote
|
||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
@@ -63,6 +72,8 @@ import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.extended.More
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pause
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -106,10 +117,15 @@ private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传
|
||||
*
|
||||
* 判据刻意**窄**,只有 `user` 消息才可能成立:
|
||||
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
|
||||
* - **语音消息一律不成立**(`voice != null`):语音的转写文本可能为空(`status = empty`,
|
||||
* 或 `transcribed` 却什么都没有),但那条消息**不是什么都没有** —— 它有一个语音形态
|
||||
* (`VoiceMessageRow` 会把它画出来)。少了这一条,一条「微信语音、没认出发音、也没有音频副本」
|
||||
* 的消息会被扣上「内容没有被传过来」这顶帽子,而那句话是**假的**;
|
||||
* - **`voice` 键在就一律不成立**([hasVoice]):这一条问的是「这条里**有没有东西可看**」,不是
|
||||
* 「有没有语音形态」,所以两档都要挡住:
|
||||
* - `status = empty` / `transcribed`(语音形态画得出来):转写文本可能为空,但那条消息**不是
|
||||
* 什么都没有** —— 它有一个语音形态(`VoiceMessageRow` 会把它画出来)。少了这一档,一条
|
||||
* 「微信语音、没认出发音、也没有音频副本」的消息会被扣上「内容没有被传过来」这顶帽子,
|
||||
* 而那句话是**假的**;
|
||||
* - `status = failed`(入站转写失败 / 出站合成降级):**没有**语音形态,但有一条正文
|
||||
* (服务端那行诚实提示 / 降级后的原文本)和那行降级小标([voiceFallbackNote] 的产物)——
|
||||
* 同样不是「什么都没有」;
|
||||
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
|
||||
* 顺手改会波及所有助手气泡,不在本次范围内。
|
||||
*
|
||||
@@ -123,8 +139,10 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
message.image.isNullOrBlank() &&
|
||||
message.documents.isEmpty() &&
|
||||
message.thinking.isNullOrBlank() &&
|
||||
// 判据只有一处(domain.chat.isVoiceMessage):绝不在这里另写一个 `voice != null`。
|
||||
!isVoiceMessage(message)
|
||||
// 判据只有一处(domain.chat.hasVoice):绝不在这里另写一个 `voice != null`。
|
||||
// ⚠️ 用 hasVoice 而不是 isVoiceMessage:failed 行(转写失败 / 合成降级)也**不是**什么都没有
|
||||
// —— 它有正文(服务端那行诚实提示 / 降级后的原文本)与那行降级小标(voiceFallbackNote)。
|
||||
!hasVoice(message)
|
||||
|
||||
/**
|
||||
* 一条落库消息渲染成一个或多个气泡:
|
||||
@@ -133,9 +151,12 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* - 其它 mode 原样整条渲染,不解析标签;
|
||||
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
|
||||
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
|
||||
* - `voice` 非空时(🎤 判据 = **这个键存在**,契约 §4.4)在正文之外多画一枚语音形态
|
||||
* ([VoiceMessageRow]:播放 / 暂停 + 时长,有副本时才有播放控件);正文照常渲染
|
||||
* ——转写文本 / 服务端那行诚实提示都在里面,一个字都不改;
|
||||
* - `voice` 键在时(🎤 判据 = **这个键存在且 `status != "failed"`**,契约 §4.4 + 后端 M57-M59
|
||||
* 预告,见 `isVoiceMessage`)在正文之外多画一枚语音形态([VoiceMessageRow]:播放 / 暂停 + 时长,
|
||||
* 有副本时才有播放控件);正文照常渲染——转写文本 / 服务端那行诚实提示都在里面,一个字都不改;
|
||||
* `status = "failed"` 那一档(入站转写失败 / 出站合成降级)**没有**语音形态可画,改为在正文之外
|
||||
* 多画一行灰色小标([VoiceFallbackNote],文案由 `voiceFallbackNote` 给:固定前半句 +
|
||||
* 服务端 `fallbackReason` 原样);
|
||||
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
|
||||
* [ImageHostRebase](见 [imageBaseUrl]);
|
||||
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
|
||||
@@ -149,7 +170,8 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
|
||||
*
|
||||
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
|
||||
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音形态)
|
||||
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音那一块
|
||||
* ——语音形态或 `failed` 行的降级小标,`voice` 键在就必画其一)
|
||||
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
|
||||
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
|
||||
* (W72 补的就是它)。
|
||||
@@ -189,6 +211,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认
|
||||
* [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。
|
||||
* @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。
|
||||
* @param ttsClip 这一条(或**别的一条**)的朗读状态;只有 `messageId` 对得上时才影响本条。
|
||||
* 见 [TtsClip]:`null` / 对不上 / 本条没点过朗读 ⇒ 与加这一块之前逐像素相同。
|
||||
* @param ttsPlayback 那条**播放通道**此刻的状态。朗读与语音副本共用它,所以朗读的键是
|
||||
* `ttsClipIdOf(message.id)` 而不是 `message.id` —— 两枚图标的「正在响」互不串台。
|
||||
* @param onSpeak 用户点了「朗读」:交给上层发请求(本组件不自己发)。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
@@ -197,11 +224,14 @@ fun MessageBubble(
|
||||
phase: TurnPhase = TurnPhase.Sent,
|
||||
legBadge: String? = null,
|
||||
voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle,
|
||||
ttsClip: TtsClip? = null,
|
||||
ttsPlayback: VoicePlaybackState = VoicePlaybackState.Idle,
|
||||
onDocumentClick: (DocumentRefDto) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
onEdit: (HistoryMessageDto) -> Unit = {},
|
||||
onDelete: (HistoryMessageDto) -> Unit = {},
|
||||
onVoiceToggle: (HistoryMessageDto) -> Unit = {},
|
||||
onSpeak: (HistoryMessageDto) -> Unit = {},
|
||||
) {
|
||||
val isUser = message.role == "user"
|
||||
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
|
||||
@@ -237,6 +267,20 @@ fun MessageBubble(
|
||||
// 那一行根本没被画出来。
|
||||
val timeCarrier = messageTimeCarrier(message, bubbles)
|
||||
|
||||
// —— 朗读(W75)——
|
||||
// 「这条消息有没有可念的正文」:判据与 VM 发请求用的是**同一个纯函数**([ttsTextOf]),
|
||||
// 所以「画了入口」与「发得出去」永远不会分叉。这里喂的是**已经解析好的** `bubbles`:
|
||||
// 它们就是屏幕上这些气泡,比让纯函数再解析一遍更贴近「用户看到的那段字」。
|
||||
// 只给她的消息(`assistant`)——主人的话不该用她的声音念回来,VM 里还有第二道闸。
|
||||
val speakable = !isUser && remember(bubbles) { ttsTextOf(bubbles) != null }
|
||||
// 本条自己的那份朗读状态:`messageId` 对不上的(用户点了别的一条)一律当没点过。
|
||||
val ownClip = ttsClip?.takeIf { it.messageId == message.id }
|
||||
val ownClipId = remember(message.id) { ttsClipIdOf(message.id) }
|
||||
val speakActive = ttsPlayback.isActiveFor(ownClipId)
|
||||
// 「正在准备」有两个来源:VM 那边请求还在飞(网络 / 合成,绝大多数时间在这里),以及播放通道
|
||||
// 正在取字节(本端落好的文件是直接给的,这一档只是一闪而过)。
|
||||
val speakPending = ownClip?.pending == true || ttsPlayback.isLoadingFor(ownClipId)
|
||||
|
||||
MessageGroup(
|
||||
isUser = isUser,
|
||||
footer = bubbleFooter(
|
||||
@@ -246,6 +290,10 @@ fun MessageBubble(
|
||||
shared = isSharedMessage(message),
|
||||
),
|
||||
onOpenMenu = openMenu,
|
||||
onSpeak = if (speakable) { { onSpeak(message) } } else null,
|
||||
speakActive = speakActive,
|
||||
speakPending = speakPending,
|
||||
ttsFailure = ownClip?.failure,
|
||||
) {
|
||||
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
|
||||
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
|
||||
@@ -277,19 +325,34 @@ fun MessageBubble(
|
||||
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
|
||||
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
|
||||
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
VoiceMessageRow(
|
||||
voice = voice,
|
||||
messageId = message.id,
|
||||
playback = voicePlayback,
|
||||
note = voiceNote(voice.status, message.content),
|
||||
alignEnd = isUser,
|
||||
onToggle = { onVoiceToggle(message) },
|
||||
modifier = side,
|
||||
)
|
||||
if (isVoiceMessage(message)) {
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
VoiceMessageRow(
|
||||
voice = voice,
|
||||
messageId = message.id,
|
||||
playback = voicePlayback,
|
||||
note = voiceNote(voice.status, message.content),
|
||||
alignEnd = isUser,
|
||||
onToggle = { onVoiceToggle(message) },
|
||||
modifier = side,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// status == "failed":这条**没有可播的声音**(入站转写失败 / 出站合成降级)。
|
||||
// 正文照常按文字渲染(下面那条路),这里只多画一行灰色小标 —— **不**画语音形态:
|
||||
// 画出来就是一个必然播不了的控件(见 isVoiceMessage 的 KDoc)。
|
||||
// 没有这一行,这一条与普通文字消息逐像素相同,看的人无从知道它本来是一段声音。
|
||||
voiceFallbackNote(voice)?.let { note ->
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
VoiceFallbackNote(text = note, alignEnd = isUser, modifier = side)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isContentlessUserMessage(message)) {
|
||||
@@ -500,7 +563,7 @@ sealed interface MessageTimeCarrier {
|
||||
/** 图片(`image` 非空才会画)。 */
|
||||
data object Image : MessageTimeCarrier
|
||||
|
||||
/** 语音形态(`voice` 键在才会画)。 */
|
||||
/** 语音那一块(`voice` 键在才会画:语音形态,或 `failed` 行的降级小标)。 */
|
||||
data object Voice : MessageTimeCarrier
|
||||
}
|
||||
|
||||
@@ -509,16 +572,18 @@ sealed interface MessageTimeCarrier {
|
||||
*
|
||||
* ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西
|
||||
*
|
||||
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音形态 → 正文气泡。
|
||||
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音那一块 → 正文气泡。
|
||||
* 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。
|
||||
*
|
||||
* 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
|
||||
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
|
||||
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
|
||||
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音形态 → 图片 → 附件芯片排。这一档就是
|
||||
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音那一块 → 图片 → 附件芯片排。这一档就是
|
||||
* **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡
|
||||
* `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来**
|
||||
* (主人 2026-10-07 验收时发现的缺口)。
|
||||
* 「语音那一块」按 `voice` 键判([hasVoice])而**不**按 [isVoiceMessage]:`failed` 行不画语音
|
||||
* 形态,但会画那行降级小标([VoiceFallbackNote])——它也占着一块,也照样能挂时间。
|
||||
* 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。
|
||||
*
|
||||
* ## 谁**不**走这一档
|
||||
@@ -544,7 +609,7 @@ fun messageTimeCarrier(
|
||||
if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody)
|
||||
// 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。
|
||||
return when {
|
||||
message.voice != null -> MessageTimeCarrier.Voice
|
||||
hasVoice(message) -> MessageTimeCarrier.Voice
|
||||
!message.image.isNullOrBlank() -> MessageTimeCarrier.Image
|
||||
message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments
|
||||
else -> null
|
||||
@@ -585,6 +650,9 @@ fun PendingBubble(
|
||||
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
|
||||
// 语音:正文是空的(本端不转写,用户说的话要等服务端 STT 之后才落库),
|
||||
// 所以这一条消息至少要有「这是语音、多长」这一枚可见凭据——与接收侧的语音形态同一个外观。
|
||||
item.voiceDurationMs?.let { durationMs -> VoiceClipLabel(durationMs) }
|
||||
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
|
||||
}
|
||||
}
|
||||
@@ -748,6 +816,10 @@ private fun MessageGroup(
|
||||
isUser: Boolean,
|
||||
footer: String? = null,
|
||||
onOpenMenu: (() -> Unit)? = null,
|
||||
onSpeak: (() -> Unit)? = null,
|
||||
speakActive: Boolean = false,
|
||||
speakPending: Boolean = false,
|
||||
ttsFailure: String? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -760,7 +832,7 @@ private fun MessageGroup(
|
||||
content()
|
||||
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
|
||||
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
|
||||
if (footer != null || onOpenMenu != null) {
|
||||
if (footer != null || onOpenMenu != null || onSpeak != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
|
||||
@@ -773,6 +845,31 @@ private fun MessageGroup(
|
||||
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
|
||||
)
|
||||
}
|
||||
// 「朗读」紧挨「⋯」:两枚都是这条消息的动作,分开放会让人以为是两个话题。
|
||||
// 与「⋯」同一个尺寸、同一条 `Icon + clickable` 纪律(理由见下面那段注释)。
|
||||
if (onSpeak != null) {
|
||||
Icon(
|
||||
imageVector = if (speakActive) MiuixIcons.Pause else MiuixIcons.Play,
|
||||
contentDescription = when {
|
||||
speakActive -> TTS_STOP_DESCRIPTION
|
||||
speakPending -> TTS_LOADING_DESCRIPTION
|
||||
else -> TTS_SPEAK_DESCRIPTION
|
||||
},
|
||||
// 正在响 ⇒ 主色(与语音形态那枚图标同一口径);其余是角标行那档灰。
|
||||
tint = if (speakActive) {
|
||||
MiuixTheme.colorScheme.primary
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
// 正在准备时**点不动**:这时候再发一次请求没有任何意义,用户想要的是
|
||||
// 「别再等了」——而那条路(取消在飞的合成)本端没有做,所以不给一个
|
||||
// 点了没反应的入口(见报告「未做」)。
|
||||
modifier = Modifier
|
||||
.size(MESSAGE_ACTION_ICON_SIZE)
|
||||
.clip(CircleShape)
|
||||
.clickable(enabled = !speakPending, onClick = onSpeak),
|
||||
)
|
||||
}
|
||||
if (onOpenMenu != null) {
|
||||
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
|
||||
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
|
||||
@@ -789,6 +886,18 @@ private fun MessageGroup(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 朗读没成功时那一句。挂在角标行**下面**、自成一行:塞进那一行会把来源角标挤成两截,
|
||||
// 而这句话是给用户看的一整句,不该跟角标抢横向空间。
|
||||
// 颜色用 `error` 而不是灰:它是一条**如实告知**(不是装饰),但也不弹窗、不打断——
|
||||
// 用户的眼睛本来就在他刚点的那条消息上。
|
||||
if (ttsFailure != null) {
|
||||
Text(
|
||||
text = ttsFailure,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ import top.yukonga.miuix.kmp.icon.extended.Pause
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = `voice` 键存在),不是内容。 */
|
||||
/**
|
||||
* 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = 这条画的是语音形态,
|
||||
* 见 `isVoiceMessage`),不是内容。
|
||||
*/
|
||||
const val VOICE_LABEL: String = "语音"
|
||||
|
||||
/** 加载中那一句(与 [VOICE_LABEL] 同一行,替换掉时长那一格)。 */
|
||||
@@ -49,8 +52,10 @@ private val VOICE_ICON_SIZE = 18.dp
|
||||
* 一条语音消息在**转写文本之外**的那个「语音」形态:`[播放/暂停] 语音 · 时长`。
|
||||
*
|
||||
* ## 画什么由三条契约判据决定(都在这一个函数里,不散落)
|
||||
* - **🎤 判据 = `voice` 键存在**:本组件只有拿到 `voice` 才会被调用(调用点在 [MessageBubble]),
|
||||
* 所以这里不认识正文里有没有 `[语音]` 字样;
|
||||
* - **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**:本组件只有在这条消息要画语音形态时
|
||||
* 才会被调用(调用点在 [MessageBubble],判据是 `isVoiceMessage`),所以这里既不认识正文里有
|
||||
* 没有 `[语音]` 字样、也不认识 `status`——`failed` 那一态(没有可播的声音)根本不走这条路,
|
||||
* 它由 `VoiceFallbackNote` 单独画一行灰色小标;
|
||||
* - **无副本 ⇒ 不画播放控件**([voiceAudio] 回 `null`):四个键(`name`/`mime`/`size`/`url`)
|
||||
* 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示
|
||||
* (时长与副本**无关**);
|
||||
@@ -171,3 +176,44 @@ fun VoiceMessageRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `status == "failed"` 那条消息**没有语音形态**,但要多说的一句话(`voice` 键在、没有可播的声音)。
|
||||
*
|
||||
* ## 为什么这一行必须存在
|
||||
* 这一态的消息正文照常按文字渲染(入站是服务端那行诚实提示,出站是合成降级后的原文本)。少了这一行,
|
||||
* 一条「她本想用语音、合成降级成文字」的消息与一条普通文字消息**逐像素相同**——看的人无从知道这条
|
||||
* 本来是一段声音,也看不出它与别的话有什么不一样。所以这枚小标不是装饰,它是这一态**唯一**的可见
|
||||
* 凭据([VoiceMessageRow] 在这里根本不画,见 `isVoiceMessage`)。
|
||||
*
|
||||
* ## 样式为什么抄 [VoiceMessageRow] 里 note 那一行
|
||||
* `footnote2` + `onSurfaceVariantSummary`:与语音形态旁边那几句说明(「没有识别出文字」/ 加载中 /
|
||||
* 时长)**同一档**。这三处说的都是「关于这段声音的一句脚注」,同一个外观才让人读成一类东西;
|
||||
* 换成正文那一档字级会被误读成消息内容的一部分,换成 `error` 又会把一次**降级**说成一次**失败**
|
||||
* ——降级本身是服务端做对的一件事(她的话一个字没丢,只是没变成声音)。
|
||||
*
|
||||
* ## 不判 `status`,也不拼文案
|
||||
* [text] 由调用方用纯函数 `voiceFallbackNote` 算好传进来(固定前半句 + 服务端 `fallbackReason` 原样)。
|
||||
* 本组件只负责画:判据与措辞都在 domain 层单测钉着,这里再判一次就多出「两处判据不一致」的可能
|
||||
* ——与 [VoiceMessageRow] 对 `note` 的处理同一条纪律。
|
||||
*
|
||||
* @param text 已经算好的那一整句(本组件不改一个字符)。
|
||||
* @param alignEnd 用户侧(气泡在右):整行贴右;否则贴左。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceFallbackNote(
|
||||
text: String,
|
||||
alignEnd: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.failureOf
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.isLoadingFor
|
||||
import top.yeij.cyrene.domain.chat.voiceElapsedLabel
|
||||
import top.yeij.cyrene.domain.chat.voiceReplyNote
|
||||
import top.yukonga.miuix.kmp.basic.Button
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.extended.Mic
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pause
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 输入栏那几个**语音**形态(录音中 / 待重发 / 她的语音回复),以及乐观气泡里那一枚「语音」。
|
||||
*
|
||||
* ## 为什么单独一个文件
|
||||
* 它们四个共用同一套尺寸与配色,而这个文件之外没人需要知道这些细节:`ChatScreen` 只负责接线,
|
||||
* `MessageBubble` 只调用最后一枚。放在一起,改一眼就能看全。
|
||||
*
|
||||
* ## 图标从哪来(查过 AAR,不是猜的)
|
||||
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚,**没有麦克风**;**extended** 包里有真的
|
||||
* `Mic` / `MicSlash`(`javap` 确认过 `MicKt.getMic(MiuixIcons)`),而本模块本来就在用 extended
|
||||
* (`VoiceBubble.kt` 的 `Play` / `Pause`、`ChatScreen.kt` 的 `File` / `Image`)。
|
||||
*
|
||||
* ## 读屏名字即设备验收的 `content-desc`
|
||||
* 四枚可点控件的 `contentDescription` 都抽成了常量([VOICE_RECORD_DESCRIPTION] 等):
|
||||
* `uiautomator dump` 靠它们定位,改文案等于改验收口径,两处一起改。
|
||||
*/
|
||||
|
||||
/** 「开始录音」那枚麦克风的读屏名字。 */
|
||||
const val VOICE_RECORD_DESCRIPTION: String = "按住说话(点按开始录音)"
|
||||
|
||||
/** 录音中那枚麦克风(点它 = 结束并发送)。 */
|
||||
const val VOICE_RECORD_STOP_DESCRIPTION: String = "结束录音并发送"
|
||||
|
||||
/** 录音中的「取消」。 */
|
||||
const val VOICE_RECORD_CANCEL_DESCRIPTION: String = "取消这段录音"
|
||||
|
||||
/** 待重发那一段的「重发」。 */
|
||||
const val VOICE_DRAFT_RETRY_DESCRIPTION: String = "重发这段语音"
|
||||
|
||||
/** 待重发那一段的「丢弃」。 */
|
||||
const val VOICE_DRAFT_DISCARD_DESCRIPTION: String = "丢弃这段语音"
|
||||
|
||||
/** 她的语音回复的播放 / 停止。 */
|
||||
const val VOICE_REPLY_PLAY_DESCRIPTION: String = "播放她的语音回复"
|
||||
const val VOICE_REPLY_STOP_DESCRIPTION: String = "停止播放她的语音回复"
|
||||
|
||||
/** 关掉她的语音回复那一张卡片。 */
|
||||
const val VOICE_REPLY_DISMISS_DESCRIPTION: String = "关闭这条语音回复"
|
||||
|
||||
/** 乐观气泡里那枚「语音」的字样(与接收侧的 [VOICE_LABEL] 同一个词,不另造)。 */
|
||||
const val VOICE_CLIP_LABEL: String = VOICE_LABEL
|
||||
|
||||
/**
|
||||
* 麦克风**没授权**时的如实告知。
|
||||
*
|
||||
* 说「到系统设置里允许」而不是再申请一次:本端只在第一次点麦克风时申请(平台规范),
|
||||
* 被拒之后重复申请只会被系统静默挡回(Android 不再弹框),那样用户点十次都毫无反应。
|
||||
*/
|
||||
const val VOICE_PERMISSION_DENIED_MESSAGE: String =
|
||||
"没有麦克风权限,到系统设置里允许后再试"
|
||||
|
||||
/**
|
||||
* 有权限、但麦克风**现在起不来**(被别的应用占用 / 设备不支持这组采样参数)。
|
||||
*
|
||||
* 与 [VOICE_PERMISSION_DENIED_MESSAGE] 分开:那一档用户要去设置页,这一档要等一会或换个应用;
|
||||
* 说成同一句就是把人引向一个解决不了问题的动作。
|
||||
*/
|
||||
const val VOICE_UNAVAILABLE_MESSAGE: String = "麦克风现在打不开,可能被别的应用占着"
|
||||
|
||||
/** 图标尺寸:与接收侧语音形态同一档。 */
|
||||
private val VOICE_ICON_SIZE = 18.dp
|
||||
|
||||
/** 卡片底色与圆角:与 `VoiceMessageRow` 那一枚同形(同一个产品里的语音长一个样)。 */
|
||||
private val VOICE_CARD_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
/**
|
||||
* 录音中那一行:`🎤 录音中 · 0:07 [取消] [发送]`。
|
||||
*
|
||||
* 时长由**已收到的字节数**反算(见 `VoiceRecorder`),所以屏幕上这个数字与发出去的音频长度
|
||||
* 严格一致。到 60 秒时 `VoiceRecorder` 会置「满了」标记、由调用方直接收尾——这里不做倒计时:
|
||||
* 一个还在数的计时器在「已经满了」那一刻是假话。
|
||||
*
|
||||
* 两枚按钮分开(不是「点头像就发」那种手势):录音是一条**有去无回**的动作(这段 bytes 一旦
|
||||
* 发出就落进服务端编排),取消必须和发送一样容易按到。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceRecordingRow(
|
||||
elapsedMs: Int,
|
||||
onCancel: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "录音中 · ${voiceElapsedLabel(elapsedMs)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_RECORD_CANCEL_DESCRIPTION },
|
||||
) {
|
||||
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
|
||||
}
|
||||
Button(onClick = onSend) { Text("发送") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一段**录好但没发出去**的语音:`🎤 语音 · 0:07 [重发] [丢弃]`。
|
||||
*
|
||||
* 它只在一件事之后出现:`voice_input` 没能上行(连接没就绪 / 写帧失败)。此时那段字节**只在
|
||||
* 内存里**,界面上必须给两条出路——重发(**复用同一个 `clientMsgId`**,服务端据此判重)或丢弃。
|
||||
* 不给「退回到输入栏」那种出路:语音没有可以退回到输入框的形态。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceDraftRow(
|
||||
draft: VoiceDraftInfo,
|
||||
onRetry: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(draft.durationMs)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
text = "重发",
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_RETRY_DESCRIPTION },
|
||||
)
|
||||
TextButton(
|
||||
text = "丢弃",
|
||||
onClick = onDiscard,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_DISCARD_DESCRIPTION },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 她的语音回复:`[▶] 她的语音回复 · 0:03 [×]` + 她说的话([VoiceReply.reply])。
|
||||
*
|
||||
* ## 只有文字那一档
|
||||
* `provider == "silent"`(或这一轮根本没有 audio)时**不画播放控件**,只画文字 + 一句
|
||||
* [voiceSilentNote](「这一轮她只回了文字,没有语音」)。契约的硬要求是「UI 别把它画成播放器」;
|
||||
* 补那句解释是为了让「她选择只用文字」与「播放器坏了」在屏幕上分得开。
|
||||
*
|
||||
* ## 可播放但播不了
|
||||
* 音频在、本机解码 / 落盘失败时**照样画**播放控件:点了会走到 [failureOf] 那一行如实说
|
||||
* 「播不了」——这比一个看起来什么都没有的控件诚实。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceReplyRow(
|
||||
reply: VoiceReply,
|
||||
playback: VoicePlaybackState,
|
||||
onTogglePlay: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val active = playback.isActiveFor(reply.id)
|
||||
val loading = playback.isLoadingFor(reply.id)
|
||||
val failure = playback.failureOf(reply.id)
|
||||
val text = reply.reply
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (reply.playable) {
|
||||
// 整枚可点(不只是 18dp 的图标),读屏名字说清「点它会怎样」。
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onTogglePlay)
|
||||
.semantics {
|
||||
contentDescription = if (active) {
|
||||
VOICE_REPLY_STOP_DESCRIPTION
|
||||
} else {
|
||||
VOICE_REPLY_PLAY_DESCRIPTION
|
||||
}
|
||||
}
|
||||
.padding(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "她的语音回复",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "她的语音回复",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
}
|
||||
// 「现在在干什么」与「点它会怎样」分开说(与接收侧语音形态同一条纪律):
|
||||
// 只有真的在取字节时才说「准备中…」,正在响时不冒充加载。
|
||||
if (loading) {
|
||||
Text(
|
||||
text = "准备中…",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_REPLY_DISMISS_DESCRIPTION },
|
||||
) {
|
||||
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
text?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
modifier = Modifier.padding(start = 2.dp, end = 8.dp),
|
||||
)
|
||||
}
|
||||
// 「只有文字」与「本机没准备好」是两件事,各说各的那句(判据是纯函数)。
|
||||
voiceReplyNote(silent = reply.silent, hasAudioFile = reply.file != null)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
failure?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观气泡里那一枚「语音」:`🎤 语音 · 0:07`。
|
||||
*
|
||||
* 用户按住说话之后,气泡**立刻**出现(与文本消息的乐观路径同一个 `pending` 列表、同一个回滚),
|
||||
* 只是正文还没到——转写要等服务端。所以这里只画媒体类型与时长,**不画播放控件**:
|
||||
* 本地已经发出去的那段音频不在播放通道里(它已经在服务端了,回放应当走历史消息那条路)。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceClipLabel(durationMs: Int, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(durationMs)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,36 @@ class VoicePlayer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 放一段**已经在手上**的音频(`voice_output` 那条路:base64 就在帧里,不需要下载)。
|
||||
*
|
||||
* 与 [toggle] 走**同一条通道**、同一套身份比对与释放纪律(同一台喇叭上永远只有一条语音在响),
|
||||
* 差别只有两个:音频不是「按消息取字节」而是由调用方现给([load]),而且键是
|
||||
* `voice_output` 那一帧自己铸的 [clipId](它不是历史消息,没有 `messageId` 可用)。
|
||||
*
|
||||
* 同样遵守「再点一下 = 停」:`clipId` 正在放 / 正在载入时,这一下是**停**。
|
||||
*
|
||||
* @param load 取这份本地文件(写缓存 / 解码都在里面,调用方应切到 IO)。
|
||||
*/
|
||||
fun toggleClip(clipId: String, load: suspend () -> Result<File>) {
|
||||
if (_state.value.isActiveFor(clipId)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
stop()
|
||||
_state.value = _state.value.pressed(clipId)
|
||||
loading = scope.launch {
|
||||
load().fold(
|
||||
onSuccess = { file ->
|
||||
if (_state.value.isActiveFor(clipId)) play(clipId, file)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.value = _state.value.failed(clipId, voicePlaybackFailureMessage(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
|
||||
fun stop() {
|
||||
loading?.cancel()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `voice_output` 在界面上的那一份「她的语音回复」(只管**这一条实时应答**,不进历史)。
|
||||
*
|
||||
* ## 为什么它不在消息列表里
|
||||
* 契约 v0.96 §3.15:这一帧**只定向回发起 `voice_input` 的那条连接**、**连接断了不补投**,
|
||||
* 而且回合的正文本来就会经流式 / `message_persisted` 落进消息列表。所以它不是一条消息,
|
||||
* 是「这一次语音回合的音频 + 她说的话」这一次性产物:放在输入栏上方一条卡片里,
|
||||
* 用户随时可以关掉([ChatViewModel.dismissVoiceReply]),它不参与排序、不占历史。
|
||||
*
|
||||
* ## [hasAudio] 与 [file] 是两件事
|
||||
* - [hasAudio]:**服务端**这一轮给了真音频(`provider != "silent"` 且 `audio` 非空白)。
|
||||
* 为假 ⇒ 界面上**一个播放控件都不画**(画了就是点下去必然失败的按钮)。
|
||||
* - [file]:音频在本机的那份文件。`hasAudio` 为真而它是 `null` = 本机解码 / 落盘失败
|
||||
* ——这一档**照样画**播放控件(点了会如实报「播不了」),**绝不**把本机的故障说成
|
||||
* 「她这一轮只回了文字」(见 `domain.chat.voiceReplyNote`)。
|
||||
*
|
||||
* @property id 播放通道的键:每条 `voice_output` 现铸一个(它不是历史消息,没有 `messageId`)。
|
||||
* @property reply 她说的那一句(音频念的就是它),见 `domain.chat.voiceReplyText`。
|
||||
* @property transcript 转写(载荷里的 `text`):那段音频被识别成什么;只作兜底与诊断。
|
||||
* @property format 服务端给的音频格式;本端**不照抄它去解码**(`MediaPlayer` 按文件头嗅探)。
|
||||
*/
|
||||
data class VoiceReply(
|
||||
val id: String,
|
||||
val reply: String?,
|
||||
val transcript: String?,
|
||||
val format: String?,
|
||||
val provider: String?,
|
||||
val hasAudio: Boolean,
|
||||
val file: File?,
|
||||
/**
|
||||
* 这一条还该不该**自动播放**(默认 true,播过就置假,见 [ChatViewModel.consumeVoiceReplyAutoPlay])。
|
||||
*
|
||||
* 为什么要这一位:这一帧**只在到达时**有意义(断线不补投),而到达的那一刻用户可能正在别的
|
||||
* 会话 / 别的页面里。若只按「音频落好了就放」来触发,等他切回来时会**突然响一段旧语音**
|
||||
* ——那不是他刚做的事。所以自动播放只认**第一次**落好的那一下,之后靠卡片上的播放键。
|
||||
*/
|
||||
val autoPlay: Boolean = true,
|
||||
) {
|
||||
|
||||
/** 能不能画「播放」那枚控件:这一轮**有**音频,而且它已经落到本机。 */
|
||||
val playable: Boolean get() = hasAudio && file != null
|
||||
|
||||
/** 这一轮是不是「没有音频」(= `provider == "silent"` 或 `audio` 缺席)。 */
|
||||
val silent: Boolean get() = !hasAudio
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import java.io.File
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
|
||||
/**
|
||||
* 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。
|
||||
*
|
||||
* ## 为什么它住在 VM 的状态里,而不是气泡自己的 `remember`
|
||||
* 一次朗读要经过一段**跨组合重建**的等待(发请求、等字节、落盘):转屏 / 滚出屏幕再滚回来
|
||||
* 都不该让已经落好的那段音频消失,也不该让「正在准备」这件事凭空重置。所以请求发起、失败分类、
|
||||
* 落盘结果都在 `ChatViewModel.speak` 里,气泡只是这份状态的视图。
|
||||
*
|
||||
* ## 一次只有一个:`ttsClip` 是**单格**
|
||||
* 与 [VoiceReply] 同一取舍:同一时刻用户点的是**一条**消息,多留几条既占内存又没人看。
|
||||
* 用户点了另一条 ⇒ 这一格被新的那条整个替换(旧那条的音频文件留在缓存里,下次点它命中不发请求)。
|
||||
*
|
||||
* ## 播放不在这一格里
|
||||
* 「正在响 / 已经停了」由**唯一的播放通道**(`ui.chat.VoicePlayer` 的 `state`)说,键是
|
||||
* [clipId](`tts:<messageId>`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、
|
||||
* 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道
|
||||
* `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事
|
||||
* 本来就不是一件事。
|
||||
*
|
||||
* @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。
|
||||
* @property clipId 播放通道上的键(`tts:<messageId>`):与语音副本的键(= 它的 `messageId`)
|
||||
* 分得开,于是「念这一条」与「放这条消息的语音副本」天然互斥(点一个停另一个)。
|
||||
* @property file 合成音频在本机的那份 WAV;`null` = 还没到手(请求在飞,或已经失败了)。
|
||||
* @property failure 上一次没成功时给用户看的那句话(`domain.chat.TtsFailure.message`);
|
||||
* `null` = 没失败过 / 还没结果。
|
||||
* @property retryable 再点一次**可能**有用吗(`domain.chat.TtsFailure.retryable`)。为假时图标
|
||||
* 仍然可点 —— 但那是「重来一次」而不是「重试」,文案上不提重试(见 `TtsFailure.Unconfigured`)。
|
||||
* @property autoPlay 这一次结果还该不该**自动起播**(默认 true,起播过就置假,见
|
||||
* `ChatViewModel.consumeTtsAutoPlay`)。
|
||||
*
|
||||
* 为什么要这一位:请求发出去之后用户可能已经切到别的会话 / 别的页面。若只按「文件到了就放」
|
||||
* 来触发,等他切回来时会**突然响一段刚才点的朗读** —— 那不是他此刻的动作。所以自动起播只认
|
||||
* **第一次**落好的那一下,之后靠那枚图标。
|
||||
*/
|
||||
data class TtsClip(
|
||||
val messageId: String,
|
||||
val clipId: String,
|
||||
val file: File?,
|
||||
val failure: String?,
|
||||
val retryable: Boolean,
|
||||
val autoPlay: Boolean = true,
|
||||
) {
|
||||
|
||||
/** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */
|
||||
val pending: Boolean get() = file == null && failure == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
|
||||
*
|
||||
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
|
||||
* 判据跨了三处状态(这一格的 [TtsClip]、那条唯一播放通道的 [VoicePlaybackState]、以及
|
||||
* `ttsClipIdOf` 铸的键),而它们谁都不该在自己的类里替对方下结论。
|
||||
*/
|
||||
sealed interface TtsSpeakIntent {
|
||||
|
||||
/** 这一条**正在响**,手上也有它的音频 ⇒ 这一下是「停」。 */
|
||||
data object Stop : TtsSpeakIntent
|
||||
|
||||
/** 其余一切(没在响、响的是别的、失败过、缓存已命中想再听一遍)⇒ 交给 VM。 */
|
||||
data object Speak : TtsSpeakIntent
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一下点击是「停」还是「(重新)念」。
|
||||
*
|
||||
* ## 为什么必须有这条判据:否则「停止朗读」是空操作
|
||||
* 图标在播放中画的是暂停符、读屏名字是 `TTS_STOP_DESCRIPTION`(见 `MessageBubble`),所以
|
||||
* 用户点它时**意思是停**。但播放中 `TtsClip.pending` 是假的(文件早就到手了),于是
|
||||
* `ChatViewModel.beginTtsSpeak` 的防抖放行、整格被换成新的 pending、缓存命中秒回、
|
||||
* 起播 effect 又因为「已经在放同一条」而跳过 `toggleClip` —— 三处各自都「没错」,合起来就是
|
||||
* 点了没反应。判据落在**播放通道**这一处(它是「有没有在响」的唯一真相),才不会有第二处
|
||||
* 各自解释「播放中」是什么意思。
|
||||
*
|
||||
* ## 为什么要 `file != null`
|
||||
* 「正在响」与「音频在手上」是两件事:通道处于 [VoicePlaybackState.Loading] 时它也算 active,
|
||||
* 而那一刻 [TtsClip.file] 可能还是 `null`(字节在 VM 那边取)。那时把点击解释成「停」会停掉
|
||||
* 一次**还没起播**的载入,而用户点它的本意是「念」——所以只有文件确实在手、通道确实在响时才判
|
||||
* [TtsSpeakIntent.Stop];其余一律 [TtsSpeakIntent.Speak],由 VM 的 pending 分支去防抖。
|
||||
*
|
||||
* @param playback 那条唯一播放通道此刻的状态(`VoicePlayer.state`)。
|
||||
* @param messageId 被点的那条消息。
|
||||
* @param clip 这一条自己的朗读格(`messageId` 对不上的要当没点过,判据里再核一次)。
|
||||
*/
|
||||
fun ttsSpeakIntent(
|
||||
playback: VoicePlaybackState,
|
||||
messageId: String,
|
||||
clip: TtsClip?,
|
||||
): TtsSpeakIntent {
|
||||
val file = clip?.takeIf { it.messageId == messageId }?.file
|
||||
return if (file != null && playback.isActiveFor(ttsClipIdOf(messageId))) {
|
||||
TtsSpeakIntent.Stop
|
||||
} else {
|
||||
TtsSpeakIntent.Speak
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import top.yeij.cyrene.data.repository.MessageIncrement
|
||||
|
||||
/**
|
||||
* 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。
|
||||
*
|
||||
@@ -18,11 +20,15 @@ internal enum class PersistedRoute {
|
||||
|
||||
/**
|
||||
* **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数**
|
||||
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,问服务端要那个数。
|
||||
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地问服务端要。
|
||||
*
|
||||
* 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。
|
||||
* 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处,
|
||||
* 于是红点必须靠用户开一次抽屉(那次静默重拉)才补上。
|
||||
* 于是红点必须靠用户开一次抽屉才补上。
|
||||
*
|
||||
* 「要」的是什么,W77 换过一次:原来是整表 `GET /sessions`,现在是**这条会话的消息增量**
|
||||
* `GET /messages?after=`(见 [incrementCursors] / [applyIncrement]);整表退回成「增量给不出
|
||||
* 可信答案」时的回落。判据本身不受影响——这一档该不该被窗口挡住,与问的是哪种口径无关。
|
||||
*/
|
||||
InactiveSession,
|
||||
}
|
||||
@@ -190,3 +196,117 @@ internal fun silentRefreshStep(
|
||||
*/
|
||||
internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle =
|
||||
SilentRefreshThrottle(lastPullAt = nowMillis)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// W77:非激活会话的静默刷新,从「整表重拉 GET /sessions」换成「前向增量 GET /messages?after=」
|
||||
//
|
||||
// 后端明确批了这一换(回执二十八第 3 条:`?after=` 毫秒 `createdAt` 早就在册)。换掉的理由是**量**:
|
||||
// 整表一次要回**所有**会话(每条一行预览 + 未读计数),而这里只需要回**真的变了的那一个**会话的新消息。
|
||||
// 下面三个纯函数就是这一换的全部判据,放在这里而不是 VM 里,理由与 [silentRefreshStep] 一样:
|
||||
// 纯函数才穷举得了、才不用在 JVM 上摆一台假服务器。
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 某个会话现在该从哪个 `createdAt` 起问增量(纯函数);`null` = **这一行问不了**。
|
||||
*
|
||||
* 游标就取 [SessionItem.lastMessageAt]——最近一次权威 `GET /sessions` 给的、本端已知最新一条的
|
||||
* `createdAt`——**不另立账本**:
|
||||
* - 服务端的 `after` 是 `created_at > ?`(**严格**大于),而 [SessionItem.lastMessageAt] 正是
|
||||
* 「已经见过的最新一条」⇒ 两边语义天然对齐,多立一个账本只会多一个会漂移的真相源;
|
||||
* - 增量自己会把 [SessionItem.lastMessageAt] 推到新游标([applyIncrement]),于是账本自洽地滚动,
|
||||
* 不需要第二处推进。
|
||||
*
|
||||
* 两种「问不了」都落到 `null`,它们其实是同一件事——**本端不知道这条会话看到哪儿了**:
|
||||
* - 行不在列表里(会话在别处新建、或本端那份列表还没拉到它)⇒ 没有游标可用;
|
||||
* - 行在但 `lastMessageAt` 为 `null`(本端认为它一条消息都没有,却收到了它的落库广播)⇒
|
||||
* 手里那份列表已经陈旧到不可信。
|
||||
*
|
||||
* 两种都由调用方回落到整表(唯一权威),见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*/
|
||||
internal fun incrementCursorFor(item: SessionItem?): Long? = item?.lastMessageAt
|
||||
|
||||
/**
|
||||
* 一批待问增量的会话 id → 各自的 `after` 游标(纯函数)。
|
||||
*
|
||||
* `null` = **这批里有一个问不了**([incrementCursorFor] 给了 `null`)⇒ 调用方**整批**回落整表。
|
||||
* 不混着来是刻意的:一半靠增量、一半靠整表,得到的那份列表没有任何一处能说得清「它是什么时刻的」,
|
||||
* 而未读面上最不能出的就是这种「说不清」。
|
||||
*
|
||||
* 返回 `emptyMap` = 这批本来就是空的(没有事要做),**不是**失败。
|
||||
*/
|
||||
internal fun incrementCursors(
|
||||
sessionIds: Collection<String>,
|
||||
sessions: List<SessionItem>,
|
||||
): Map<String, Long>? {
|
||||
if (sessionIds.isEmpty()) return emptyMap()
|
||||
val byId = sessions.associateBy { it.id }
|
||||
val cursors = LinkedHashMap<String, Long>(sessionIds.size)
|
||||
for (id in sessionIds) {
|
||||
cursors[id] = incrementCursorFor(byId[id]) ?: return null
|
||||
}
|
||||
return cursors
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次增量折进列表(纯函数):**只动那一行**,其余原样返回。
|
||||
*
|
||||
* ## 未读数怎么算:先过已读游标,再加
|
||||
* 增量条数**不是**直接往角标上加的,要先按本地已知的已读游标 [readAt](`readCursors[sessionId]`)筛一遍。
|
||||
* **过滤口径**与服务端同源:`session_read.go` 的 `unreadCountQuery` 是 `count(created_at > read_at)`,
|
||||
* 而 `read_at` 是**账号级单一游标**,可能比本端已知的最新消息还新——用户在这台机器上点开过它
|
||||
* ([SessionsViewModel.select] 推的就是「现在」),随后别的设备又发了消息。那一刻
|
||||
* `(lastMessageAt, readAt]` 之间的消息**服务端算已读**,一律加进角标就会多出来一个红点。所以只数
|
||||
* `createdAt > readAt` 的那几条。
|
||||
*
|
||||
* 已有的 [SessionItem.unreadCount] 直接保留相加是安全的:它来自服务端,而这次增量里的每一条都严格新于
|
||||
* [from](服务端的 `after` 就是 `created_at > ?`),两者没有交集,不会重复计数。
|
||||
*
|
||||
* ## 已知边界:同源的只是**过滤口径**,基数仍是上次整表的旧值
|
||||
* 相加的底数 [SessionItem.unreadCount] 来自上一次整表刷新,而 `read_at` 是账号级的、会被别的设备改写。
|
||||
* 断线期间他端把某个会话读掉时,那条 `session_read` 帧本地**永久收不到**(断线不补帧),于是底数偏大,
|
||||
* 这一行会一直多算那几条。这是**已接受的边界**,不是漏网:红点本来就只承诺「大概有几条新的」,漂移
|
||||
* 到下一次整表刷新(打开抽屉 / 初始化 / 任何一次回落)就自愈。
|
||||
*
|
||||
* 这里**不**去检测它(例如「[readAt] > [from] ⇒ 底数可疑 ⇒ 回落整表」):本端刚点开过这个会话时
|
||||
* `readAt` 本来就被推到「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量
|
||||
* 路径在正常使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门
|
||||
* 不如不装。
|
||||
*
|
||||
* ## 并发的那一份真相
|
||||
* [from] 不是「这次用了什么游标」的装饰:行上的 [SessionItem.lastMessageAt] **已经不等于** [from],
|
||||
* 就说明在这次请求飞在天上的时候,另一份更权威的结果(一次整表刷新、或另一次增量)已经推进过这一行。
|
||||
* 那些条数已经被别人数过一遍了,再加一次就是重复计数 ⇒ 整行不动。
|
||||
*
|
||||
* 一行都没被改动时返回**同一个列表实例**(与 `applyBusyFrame` 同一习惯):这条路径跑在后台,
|
||||
* 没有必要为了「什么都没变」让整个抽屉重组一遍。
|
||||
*
|
||||
* @param from 这次请求用的 `after` 游标。
|
||||
* @param readAt 本端已知的已读游标;`null` = 不知道(当作 `0`,即「一条都还没读过」)。
|
||||
* @param increment 升序的增量(`MessageRepository.loadIncrement` 的产物)。
|
||||
*/
|
||||
internal fun List<SessionItem>.applyIncrement(
|
||||
sessionId: String,
|
||||
from: Long,
|
||||
readAt: Long?,
|
||||
increment: MessageIncrement,
|
||||
): List<SessionItem> {
|
||||
if (increment.messages.isEmpty()) return this
|
||||
val floor = readAt ?: 0L
|
||||
val unseen = increment.messages.count { it.createdAt > floor }
|
||||
// 升序 ⇒ 最后一条就是最新的一条(`loadIncrement` 保证这一点)。
|
||||
val preview = increment.messages.last().content.toPreviewText().takeIf { it.isNotBlank() }
|
||||
var touched = false
|
||||
val updated = map { item ->
|
||||
if (item.id != sessionId || item.lastMessageAt != from) {
|
||||
item
|
||||
} else {
|
||||
touched = true
|
||||
item.copy(
|
||||
unreadCount = item.unreadCount + unseen,
|
||||
lastMessage = preview,
|
||||
lastMessageAt = increment.cursor,
|
||||
)
|
||||
}
|
||||
}
|
||||
return if (touched) updated else this
|
||||
}
|
||||
@@ -28,7 +28,8 @@ data class SessionItem(
|
||||
val lastMessage: String?,
|
||||
/**
|
||||
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
|
||||
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
|
||||
* 两处用:已读游标比对(见 [SessionItem.withReadCursor]),以及增量追尾的 `after` 游标
|
||||
* (见 `incrementCursorFor`,由 `applyIncrement` 推进到这次增量的 `cursor`)。不直接显示。
|
||||
*/
|
||||
val lastMessageAt: Long? = null,
|
||||
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
|
||||
@@ -82,9 +83,12 @@ data class SessionsUiState(
|
||||
*
|
||||
* **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带
|
||||
* `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**:
|
||||
* 收到非激活会话的落库广播就**节流地静默重拉**([requestSilentRefresh],窗口
|
||||
* [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次抽屉**就会出现
|
||||
* (S6,登记见 `devdocs/05-开发进度与交接.md`)。
|
||||
* 收到非激活会话的落库广播就**节流地问一次那条会话的消息增量**([requestSilentRefresh] →
|
||||
* [flushPendingIncrements],窗口 [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次
|
||||
* 抽屉**就会出现(S6,登记见 `devdocs/05-开发进度与交接.md`)。
|
||||
*
|
||||
* 取数走的是 `GET /messages?after=<ms>`(前向增量)而不是整表 `GET /sessions`——W77 换的,理由与回落
|
||||
* 规则都写在 [flushPendingIncrements] 上。整表仍是**唯一权威**,只是不再需要为一条新消息付它的价钱。
|
||||
*
|
||||
* ## WS 连接:全 App 共享一条,而**本类是它唯一的主人**
|
||||
* `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条
|
||||
@@ -136,6 +140,17 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
*/
|
||||
private var trailingRefreshJob: Job? = null
|
||||
|
||||
/**
|
||||
* 窗口里攒下的「要问增量的会话 id」(W77)。
|
||||
*
|
||||
* 为什么是一个**集合**而不是一个 id:节流窗口把同一段时间里的落库广播合并成一次拉取,而那几个
|
||||
* 会话**未必是同一个**(主会话说一句、另一个会话也说一句)。只记住最后一条的话,前面那个会话的
|
||||
* 红点就永远不会出现——那正是 W49 修过的同一类病,只是换了个成因。
|
||||
*
|
||||
* 只在 [requestSilentRefresh] / [flushPendingIncrements] 两处写;登出时清空(它属于上一个身份)。
|
||||
*/
|
||||
private val pendingIncrements = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。
|
||||
*
|
||||
@@ -201,6 +216,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
trailingRefreshJob?.cancel()
|
||||
trailingRefreshJob = null
|
||||
silentRefreshThrottle = SilentRefreshThrottle()
|
||||
// 攒下的会话 id 同理:它们是上一个身份的会话,重登后那些 id 要么不存在、
|
||||
// 要么属于别人;留着只会让重登后的第一次窗口拿旧 id 去问增量。
|
||||
pendingIncrements.clear()
|
||||
pulledForIdentity = null
|
||||
busyTurns = emptyMap()
|
||||
_state.value = SessionsUiState()
|
||||
@@ -316,42 +334,54 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流地静默重拉一次列表 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
|
||||
* 节流地静默对齐一次 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
|
||||
* 必须开一次抽屉才补上」(S6)。
|
||||
*
|
||||
* 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理
|
||||
* 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。
|
||||
*
|
||||
* ## 为什么是「重拉」而不是「本地把未读数加一」
|
||||
* `message_persisted` 帧**不带 `unreadCount`**:本地没有逐条时间线,自己数出来的数会与编辑 /
|
||||
* ## 为什么是「问服务端」而不是「本地把未读数加一」
|
||||
* `message_persisted` 帧**不带 `unreadCount`**:本端没有逐条时间线,靠帧自己数出来的数会与编辑 /
|
||||
* 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**,
|
||||
* 只是把「服务端才是唯一真源」这件事提前做一次 —— 走的是**与抽屉打开时那次完全相同**的路径
|
||||
* ([refresh],含已读游标遮罩),只是不需要用户先开一次抽屉。
|
||||
* 只是把「服务端才是唯一真源」这件事提前做一次。
|
||||
*
|
||||
* ## W77:问的是**那一条会话的增量**,不再是整表
|
||||
* 曾经的落点是 `refresh(silent = true)`(整表 `GET /sessions`),一次要为**所有**会话付一行预览 +
|
||||
* 未读计数的价钱,而真正变了的只有一个会话。现在落到 [flushPendingIncrements]:
|
||||
* `GET /messages?after=<已见最大 createdAt>`,只回那几条新消息。整表仍是唯一权威,但只在
|
||||
* 「增量给不出可信答案」时才请它出场(回落规则写在 [flushPendingIncrements] 上)。
|
||||
*
|
||||
* ## 三条纪律
|
||||
* 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次
|
||||
* 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。
|
||||
* 2. **静默**:`silent = true`,不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。
|
||||
* 失败时做什么**完全沿用既有行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;
|
||||
* 以及「网络异常,显示 x 分钟前的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,
|
||||
* 不该比用户自己打开抽屉时的那次刷新更吵。
|
||||
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。[refresh] 内部唯一会动
|
||||
* `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回主会话),
|
||||
* 那是既有的、正确的收敛,不是重置。
|
||||
* 2. **静默**:不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。失败时做什么**完全沿用既有
|
||||
* 行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;以及「网络异常,显示 x 分钟前
|
||||
* 的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,不该比用户自己打开抽屉时的那次
|
||||
* 刷新更吵。
|
||||
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。回落那条路([refresh])
|
||||
* 内部唯一会动 `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回
|
||||
* 主会话),那是既有的、正确的收敛,不是重置。
|
||||
*
|
||||
* @param cause 这次为什么要拉;决定它走不走窗口(见 [SilentRefreshCause])。
|
||||
* @param sessionId 这一帧说的是**哪个**会话;`null` = 没有具体会话([SilentRefreshCause.ContextChanged]),
|
||||
* 那一档只能走整表。窗口里攒下的 id 由 [pendingIncrements] 记着,因为一次窗口可能合并**几个不同**会话。
|
||||
*/
|
||||
private fun requestSilentRefresh(cause: SilentRefreshCause) {
|
||||
private fun requestSilentRefresh(cause: SilentRefreshCause, sessionId: String? = null) {
|
||||
val step = silentRefreshStep(cause, System.currentTimeMillis(), silentRefreshThrottle)
|
||||
silentRefreshThrottle = step.throttle
|
||||
// 没有具体会话时不留 id:那一次整表会把所有会话都对上,攒着的 id 一个也不缺。
|
||||
if (sessionId != null) pendingIncrements += sessionId
|
||||
when (step.action) {
|
||||
// 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。
|
||||
// (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档
|
||||
// 走到这里时 trailingPending 必为 false,补拉早跑完了。)
|
||||
//
|
||||
// 撤掉补拉**不等于**可以丢掉它攒下的 id:[pendingIncrements] 是独立的一份账本,
|
||||
// 这一趟会把它们一并做掉。
|
||||
SilentRefreshAction.PullNow -> {
|
||||
trailingRefreshJob?.cancel()
|
||||
trailingRefreshJob = null
|
||||
refresh(silent = true)
|
||||
flushPendingIncrements(fullTable = sessionId == null)
|
||||
}
|
||||
|
||||
// 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串
|
||||
@@ -365,15 +395,75 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
trailingRefreshJob = viewModelScope.launch {
|
||||
delay(step.delayMillis)
|
||||
silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis())
|
||||
refresh(silent = true)
|
||||
flushPendingIncrements(fullTable = false)
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。
|
||||
// (但这一帧的会话 id 已经在上面记进 [pendingIncrements] 了 —— 补拉不会漏掉它。)
|
||||
SilentRefreshAction.Ignore -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 [pendingIncrements] 里攒下的会话**逐个问一次消息增量**,问不了就整批回落整表(W77)。
|
||||
*
|
||||
* 这就是 W49 那条「非激活会话落了新消息 ⇒ 整表重拉 `GET /sessions`」的替代品:整表一次要回
|
||||
* **所有**会话(每条一行预览 + 未读计数),而这里只回**真的变了的那一个**会话的新消息。
|
||||
*
|
||||
* ## 回落的三条理由(都落到同一条路:整表)
|
||||
* 1. **问不了增量**:[incrementCursors] 给 `null` —— 行不在列表里,或本端不知道它看到哪儿了;
|
||||
* 2. **追不到尾**:`complete = false` —— 整个追尾里拿过整页(服务端的 `LIMIT` 切过一刀,那一刀
|
||||
* 可能劈开了同一毫秒,而服务端只给严格 `>`,被劈开的尾巴再也追不回来;此后的空页与不满页
|
||||
* 都说不清),或翻满了 `MessageRepository.MAX_INCREMENT_PAGES`。那时增量只覆盖了积压的一部分,
|
||||
* 拿它当「全部」就是把红点做小;
|
||||
* 3. **请求失败**:含会话已在别处被删掉的 `404` —— 那一档要靠整表把这一行从列表里去掉。
|
||||
*
|
||||
* 三条都是「增量给不出可信答案」⇒ 去问唯一权威。**一次批次最多回落一次**:整批共用这一个失败
|
||||
* 出口,否则 N 个会话同时失败就是 N 次整表。
|
||||
*
|
||||
* ## 刻意不做的一件事:不写快照
|
||||
* [refresh] 成功时会 `prefs.saveSnapshot`,这里**不写**。快照的类型是 `List<SessionSummaryDto>`
|
||||
* (整表响应),而增量只回了消息——拿它去拼一份 DTO 列表,缺的字段(`type` / `createdAt` /
|
||||
* `pinned` / `personaId`)只能靠猜,那等于用一个更差的东西覆盖掉一份好快照。代价是冷启动第一帧
|
||||
* 可能显示上一次整表时的未读与预览,紧接着 `init` 里那次 `refresh()` 就会把它修正。
|
||||
*
|
||||
* ## 也不顺带刷角色目录
|
||||
* [refresh] 会捎带 `loadPersonas()`,这条路径不会。角色目录的刷新点是 init / 新建 / 删除 / 抽屉
|
||||
* 打开(那几个都是用户动作或一次性事件),「别的会话来了条消息」从来不是它的刷新时机——此前它只是
|
||||
* 跟着整表搭了便车。W77 把便车取消,角色目录少掉的是几次没人要的请求。
|
||||
*
|
||||
* @param fullTable `true` = 这一趟没有具体会话(或已判明必须整表),直接走 [refresh]。
|
||||
*/
|
||||
private fun flushPendingIncrements(fullTable: Boolean) {
|
||||
val targets = pendingIncrements.toList()
|
||||
pendingIncrements.clear()
|
||||
if (fullTable) {
|
||||
refresh(silent = true)
|
||||
return
|
||||
}
|
||||
val cursors = incrementCursors(targets, _state.value.sessions)
|
||||
if (cursors == null) {
|
||||
refresh(silent = true)
|
||||
return
|
||||
}
|
||||
// 空批次 = 没有事要做(不是失败):不回落、也不发请求。
|
||||
if (cursors.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
for ((sessionId, from) in cursors) {
|
||||
val increment = container.messageRepository.loadIncrement(sessionId, after = from).getOrNull()
|
||||
if (increment == null || !increment.complete) {
|
||||
refresh(silent = true)
|
||||
return@launch
|
||||
}
|
||||
val readAt = readCursors[sessionId]
|
||||
_state.update {
|
||||
it.copy(sessions = it.sessions.applyIncrement(sessionId, from, readAt, increment))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 / 进入会话。
|
||||
*
|
||||
@@ -594,23 +684,24 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
* 两半共用同一条纪律:**本端哪里都不替服务端数未读**(逐条累加会与编辑 / 删除 / 别端操作漂移)。
|
||||
* 区别只在「那它靠什么显示」:
|
||||
* - [PersistedRoute.ActiveSession] ⇒ 就地清零 + 推进游标(这段一个字没改);
|
||||
* - [PersistedRoute.InactiveSession] ⇒ **节流地静默重拉**([requestSilentRefresh]),
|
||||
* 用服务端下发的 `unreadCount` 说话。这正是 W49 修的那一幕:此前这一档直接 `return`,于是
|
||||
* 「App 停在主会话、次级会话来了新消息」没有任何人会去发现,红点要等用户打开一次抽屉(那次
|
||||
* 静默重拉)才补上。
|
||||
* - [PersistedRoute.InactiveSession] ⇒ **节流地问一次这条会话的消息增量**([requestSilentRefresh]
|
||||
* 带上 `sessionId` → [flushPendingIncrements] → `MessageRepository.loadIncrement`),未读由
|
||||
* 「增量里越过已读游标的条数」说话(W77;W49 那一档原本整表重拉 `GET /sessions`)。
|
||||
* 这正是 W49 修的那一幕:此前这一档直接 `return`,于是「App 停在主会话、次级会话来了新消息」
|
||||
* 没有任何人会去发现,红点要等用户打开一次抽屉才补上。
|
||||
*
|
||||
* 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做),
|
||||
* 而「重拉一次列表」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
|
||||
* 而「问一次增量」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
|
||||
* 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的
|
||||
* 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。
|
||||
*/
|
||||
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
|
||||
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
|
||||
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,
|
||||
// 红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
|
||||
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地问一次**这条会话**的消息
|
||||
// 增量,红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
|
||||
PersistedRoute.InactiveSession ->
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
|
||||
|
||||
// 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。
|
||||
// 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时,
|
||||
@@ -634,9 +725,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
* 判据也共用 [persistedRoute](只是会话 id 由这一帧自己钉:它不带 `sessionId`)。
|
||||
*
|
||||
* 为什么必须在这里补一句:服务端的主动推送 Web 路由只发这一帧,**不补** `message_persisted`
|
||||
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流静默重拉」根本不会被
|
||||
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流刷新」根本不会被
|
||||
* 触发——抽屉里主会话的红点就停在她开口之前,直到用户自己开一次抽屉。这里复用**同一条**路
|
||||
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage]),
|
||||
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage],会话 id 取主会话),
|
||||
* 不新写取数路径,也不动节流判据本身。
|
||||
*
|
||||
* `id` 缺席 = 服务端那一刻**没有落库行** ⇒ 历史里没有这一行,未读面上没有任何东西要更新
|
||||
@@ -647,7 +738,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
val sessionId = SessionSummaryDto.MAIN_SESSION_ID
|
||||
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
|
||||
PersistedRoute.InactiveSession ->
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
|
||||
|
||||
// 屏幕上就是主会话:她这句话已经显示在聊天流里(ChatViewModel 那侧并入)⇒ 与
|
||||
// message_persisted 的同一档一样「看到即已读」,免得抽屉为她刚说的话亮一个红点。
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* `POST /api/v1/audio/speech` 的**运行时接线**:`ApiClientFactory.create`(主 REST client)→
|
||||
* [SpeechApi]。
|
||||
*
|
||||
* 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,
|
||||
* 与 [RestAttemptClientTest] 同一套做法,不引任何新依赖)。
|
||||
*
|
||||
* ## 它钉住的四条事实(每条都是判据,不是描述)
|
||||
* 1. 请求真的写出去,落到 `POST <baseUrl>api/v1/audio/speech` —— 路径**不带前导 `/`**,
|
||||
* 否则部署在子路径下的服务端地址会被整段丢掉([SpeechApi] 的 KDoc 写的就是这条)。
|
||||
* 2. 它带着 **owner Bearer 令牌**与 `X-API-Version: 1`:服务端是 owner-only 的双凭据门,
|
||||
* 本端只走 Bearer 那一支(主人 2026-10-26 拍板不设 API Key 输入框)。
|
||||
* 3. `sessionId` **有值时随体发出**——发音人由那条会话的生效人格决定,这是它唯一的来源。
|
||||
* 4. `sessionId` 为 `null` 时字段**整个缺席**(`explicitNulls = false`),而不是发一个
|
||||
* `"sessionId": null`:契约里「省略」与「显式空」是两件事。
|
||||
*/
|
||||
class SpeechApiTest {
|
||||
|
||||
private val backend = FakeSpeechBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
/** 与生产同形:主 REST client(带令牌与 401 策略),只是 baseUrl 指向本地假端点。 */
|
||||
private fun api(baseUrl: String = backend.baseUrl) = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = SpeechApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
)
|
||||
|
||||
/** 发一次真请求并把响应体关掉(`@Streaming` 的体必须由调用方关闭)。 */
|
||||
private suspend fun speech(dto: SpeechRequestDto) {
|
||||
val response = withTimeoutOrNull(TIMEOUT_MILLIS) { api().speech(dto) }
|
||||
response?.body()?.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the request really puts a POST api v1 audio speech on the wire`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好"))
|
||||
|
||||
// 请求行不含 HTTP 版本(HttpExchange 只给 method + URI),判据是「方法 + 路径」。
|
||||
assertEquals("POST /api/v1/audio/speech", backend.requestLines.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the request carries the owner bearer token and the api version`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好"))
|
||||
|
||||
val headers = backend.headers.single()
|
||||
assertEquals("Bearer $TOKEN", headers["authorization"])
|
||||
assertEquals("1", headers["x-api-version"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session id travels in the body because it decides the voice`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好", sessionId = SESSION_ID))
|
||||
|
||||
val raw = backend.bodies.single()
|
||||
val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw)
|
||||
assertEquals("你好", sent.text)
|
||||
assertEquals(SESSION_ID, sent.sessionId)
|
||||
assertTrue("sessionId 该随体发出,实际 $raw", raw.contains("\"sessionId\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null session id leaves the field out entirely instead of sending null`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好"))
|
||||
|
||||
val raw = backend.bodies.single()
|
||||
val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw)
|
||||
assertNull(sent.sessionId)
|
||||
// 「省略」与「显式 null」在契约里是两件事:后者会让服务端以为本端知道是哪个会话。
|
||||
assertFalse("sessionId 为 null 时不该出现这个键,实际 $raw", raw.contains("sessionId"))
|
||||
}
|
||||
|
||||
/** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**、请求头原文与请求体原文。 */
|
||||
private inner class FakeSpeechBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/audio/speech", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val requestLines = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
|
||||
val headers = CopyOnWriteArrayList<Map<String, String>>()
|
||||
val bodies = CopyOnWriteArrayList<String>()
|
||||
|
||||
@Volatile
|
||||
private var status = 200
|
||||
|
||||
@Volatile
|
||||
private var payload = WAV
|
||||
|
||||
fun reply(status: Int, body: ByteArray) {
|
||||
this.status = status
|
||||
this.payload = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requestLines += "${exchange.requestMethod} ${exchange.requestURI}"
|
||||
headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") }
|
||||
bodies += String(exchange.requestBody.readBytes(), Charsets.UTF_8)
|
||||
exchange.responseHeaders.add("Content-Type", "audio/wav")
|
||||
exchange.sendResponseHeaders(status, payload.size.toLong())
|
||||
exchange.responseBody.use { it.write(payload) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SESSION_ID = "web"
|
||||
const val TOKEN = "test-token"
|
||||
const val TIMEOUT_MILLIS = 10_000L
|
||||
|
||||
/** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */
|
||||
val WAV: ByteArray =
|
||||
"RIFF".toByteArray(Charsets.US_ASCII) +
|
||||
byteArrayOf(36, 0, 0, 0) +
|
||||
"WAVE".toByteArray(Charsets.US_ASCII) +
|
||||
ByteArray(40)
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,14 @@ import top.yeij.cyrene.domain.chat.VoiceStatus
|
||||
* 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。
|
||||
*
|
||||
* 要钉住的六件事:
|
||||
* 1. **七个键逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url`
|
||||
* —— 键名不许自己发明(形状错一个字母线上就是「不是语音」);
|
||||
* 1. **键名逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url`,
|
||||
* 外加**只在 `status = "failed"` 时**出现的第八个键 `fallbackReason`(后端 M57-M59 预告,
|
||||
* 契约 v0.98 的正式回执尚未到)—— 键名不许自己发明(形状错一个字母线上就是「不是语音」);
|
||||
* 2. **缺席与 `null` 是同一件事**:非语音消息整键缺席 ⇒ `null`(不是 `{}`,也不是别的哨兵);
|
||||
* 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉);
|
||||
* 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」);
|
||||
* 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢);
|
||||
* `fallbackReason` 反过来**不是**闭集——本端原样留着、不做任何映射;
|
||||
* 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。
|
||||
*/
|
||||
class VoiceDtoTest {
|
||||
@@ -67,6 +69,8 @@ class VoiceDtoTest {
|
||||
// url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。
|
||||
assertEquals("/api/v1/media/$name", voice.url)
|
||||
assertFalse(voice.url!!.startsWith("http"))
|
||||
// 转写成功的行不该有降级原因(服务端 `omitempty` 不下发)⇒ 解成 null,显示层据此不加小标。
|
||||
assertNull(voice.fallbackReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -184,6 +188,8 @@ class VoiceDtoTest {
|
||||
@Test
|
||||
fun `encoding a full voice keeps the seven key names`() {
|
||||
// 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。
|
||||
// 这里没传 `fallbackReason`(非 failed 行本来就不该有)⇒ `explicitNulls = false` 不写这个键,
|
||||
// 键集仍是七个。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(
|
||||
@@ -203,6 +209,43 @@ class VoiceDtoTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a fallback voice keeps the fallbackReason key`() {
|
||||
// 第八个键(后端 M57-M59 预告):只在 `status = "failed"` 时出现。它在 DTO 上是**真字段**,
|
||||
// 不是靠 `ignoreUnknownKeys` 吞掉的陌生键——否则出站降级那一行写不出去。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(
|
||||
source = VoiceSource.TTS,
|
||||
status = VoiceStatus.FAILED,
|
||||
fallbackReason = "tts_timeout",
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(setOf("source", "status", "fallbackReason"), encoded.keys)
|
||||
assertEquals("tts_timeout", encoded["fallbackReason"]!!.jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fallback voice decodes its reason and has no copy`() {
|
||||
// 降级行:没有可播的声音(四个副本键全缺席),原因原样留在 `fallbackReason` 里——
|
||||
// 本端**不映射、不猜闭集**(取值集合后端尚未定稿),显示层原样小字。
|
||||
val body = """
|
||||
{"id":"msg-10","sessionId":"web","role":"user","content":"想跟你说句话",
|
||||
"createdAt":1700000000009,
|
||||
"voice":{"source":"tts","status":"failed","fallbackReason":"tts_timeout"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(VoiceSource.TTS, voice.source)
|
||||
assertEquals(VoiceStatus.FAILED, voice.status)
|
||||
assertEquals("tts_timeout", voice.fallbackReason)
|
||||
assertNull("降级行没有可播的声音", voice.name)
|
||||
assertNull(voice.mime)
|
||||
assertNull(voice.size)
|
||||
assertNull(voice.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a voice with no copy omits the nullable keys`() {
|
||||
// `explicitNulls = false`(与后端 `omitempty` 同一口径):无副本时那四个键**不写出来**,
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 增量追尾(W77 `MessageRepository.loadIncrement`)的**数据层**。
|
||||
*
|
||||
* 与 `MessageAfterRepositoryTest` 同一套做法(真 OkHttp + 真 Retrofit + JDK 自带的
|
||||
* `com.sun.net.httpserver`,不引新依赖),只是假后端从「一份固定响应」换成**响应队列**:
|
||||
* 追尾是一次循环,翻页语义只有在「第一页满、第二页空」这种**按次序**的响应下才验得出来。
|
||||
*
|
||||
* 断言分两类,都不看实现细节:
|
||||
* - **线上证据**:每次请求的原始 URI(`after=` 只能有一个值,且随游标前进)、请求次数;
|
||||
* - **结论**:[MessageIncrement.complete] 与 `cursor` —— 这两项是调用方决定「要不要回落整表」的
|
||||
* 唯一依据,错了就是红点做小或做多。
|
||||
*/
|
||||
class MessageIncrementRepositoryTest {
|
||||
|
||||
private val backend = FakeMessagesBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MessageRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a short page is the tail and only the forward cursor goes on the wire`() = runBlocking {
|
||||
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 100).getOrThrow()
|
||||
|
||||
val uri = backend.uris.single()
|
||||
assertTrue(uri, uri.contains("after=100"))
|
||||
assertTrue(uri, uri.contains("limit=50"))
|
||||
assertFalse(uri, uri.contains("before="))
|
||||
assertFalse(uri, uri.contains("q="))
|
||||
// 不满一页 ⇒ 真的追到尾巴了,游标停在最新一条。
|
||||
assertTrue(increment.complete)
|
||||
assertEquals(200L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty first page is a clean tail`() = runBlocking {
|
||||
backend.enqueue(200, """{"data":[]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 300).getOrThrow()
|
||||
|
||||
// 第一页就空 ⇒ 一条新消息都没有,没有可少给的:这是真的追到尾巴,游标原地不动。
|
||||
assertEquals(1, backend.uris.size)
|
||||
assertTrue(backend.uris.single(), backend.uris.single().contains("after=300"))
|
||||
assertTrue(increment.complete)
|
||||
assertEquals(300L, increment.cursor)
|
||||
assertTrue(increment.messages.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty page after a full page is not proof of a clean tail`() = runBlocking {
|
||||
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
backend.enqueue(200, """{"data":[]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow()
|
||||
|
||||
// 满页 ⇒ 多问一次,第二次拿到空页。但客户端**分不出**这次空页是「干净的尾巴」还是
|
||||
// 「空页底下还压着同一毫秒的几条」:服务端只给严格 `> cursor`,被页边界劈开的那一毫秒
|
||||
// 再也追不回来。分不出来就只能说「没追完」,把「要不要回落整表」交回调用方。
|
||||
assertEquals(2, backend.uris.size)
|
||||
assertTrue(backend.uris[1], backend.uris[1].contains("after=200"))
|
||||
assertFalse(increment.complete)
|
||||
assertEquals(200L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short page after a full page is not proof of a clean tail either`() = runBlocking {
|
||||
// 页宽 2、积压 m1@500 / m2@500 / m3@500 / m4@600。第一页给 m1、m2 —— LIMIT 恰好把 500 那一
|
||||
// 毫秒劈开;第二页 after=500 只能给 > 500 的,于是 [m4@600]、不满页。不满只证明「> 500 的行
|
||||
// 就这一条」,对切点那一毫秒还有没有行一无所知:m3@500 既不 > 500,也就永远拿不到。
|
||||
// 空页档与不满页档是同一个洞的两个出口,都要说「没追完」。
|
||||
backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""")
|
||||
backend.enqueue(200, """{"data":[${message("m4", 600)}]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow()
|
||||
|
||||
assertEquals(2, backend.uris.size)
|
||||
assertTrue(backend.uris[1], backend.uris[1].contains("after=500"))
|
||||
assertFalse(increment.complete)
|
||||
assertEquals(600L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2", "m4"), increment.messages.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full page stuck on one millisecond is reported as incomplete`() = runBlocking {
|
||||
// 真后端的形状:m1 / m2 / m3 三条落在同一毫秒,页宽 2。第一页给 m1、m2,第二页问
|
||||
// `after=500` —— 严格 `>` 下后端只会回空页,m3@500 永远拿不到(这里不入队 m3:队列里
|
||||
// 那份空响应就是真后端在 `after=500` 时会给的答案)。
|
||||
backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""")
|
||||
backend.enqueue(200, """{"data":[]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 100, pageSize = 2).getOrThrow()
|
||||
|
||||
assertFalse(increment.complete)
|
||||
// 游标停在那一毫秒(推不动就不装作推得动)。
|
||||
assertEquals(500L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
|
||||
// 线上证据:第二次真的带上了 `after=500`,即「m3 拿不到」的那一问。
|
||||
assertEquals(2, backend.uris.size)
|
||||
assertTrue(backend.uris[1], backend.uris[1].contains("after=500"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the page budget stops the chase instead of chasing forever`() = runBlocking {
|
||||
// 永远满页:预算只有 1 页时必须停下来并如实说「没追完」。
|
||||
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
|
||||
val increment = repository()
|
||||
.loadIncrement("web", after = 0, pageSize = 2, maxPages = 1)
|
||||
.getOrThrow()
|
||||
|
||||
assertEquals(1, backend.uris.size)
|
||||
assertFalse(increment.complete)
|
||||
assertEquals(200L, increment.cursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session deleted elsewhere surfaces as session_not_found with 404`() = runBlocking {
|
||||
backend.enqueue(
|
||||
404,
|
||||
"""{"error":{"code":"session_not_found","message":"session \"web:gone\" not found"}}""",
|
||||
)
|
||||
|
||||
val error = repository().loadIncrement("web:gone", after = 0).exceptionOrNull() as CyreneException
|
||||
|
||||
val server = error.error as CyreneError.Server
|
||||
assertEquals("session_not_found", server.code)
|
||||
assertEquals(404, server.httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one increment costs one request and k messages instead of the whole table`() = runBlocking {
|
||||
// 10 个会话的整表长什么样(每条一行预览 + 未读计数):这是被换掉的那条路。
|
||||
val wholeTable = """{"data":[${sessions(10)}]}"""
|
||||
backend.enqueue(200, """{"data":[${message("new", 9_000)}]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 8_000).getOrThrow()
|
||||
|
||||
assertEquals(1, backend.uris.size)
|
||||
assertEquals(1, increment.messages.size)
|
||||
assertTrue(increment.complete)
|
||||
// 量级证据:增量回的字节数远小于整表(这里只有一条新消息 vs 十行会话预览)。
|
||||
val incrementBytes = backend.responseBytes.single()
|
||||
assertTrue(
|
||||
"increment=$incrementBytes wholeTable=${wholeTable.toByteArray().size}",
|
||||
incrementBytes * 4 < wholeTable.toByteArray().size,
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条 `GET /messages` 形状的消息(字段与 `HistoryMessageDto` 对齐,只留断言用得到的)。 */
|
||||
private fun message(id: String, createdAt: Long): String =
|
||||
"""{"id":"$id","sessionId":"web","role":"user","content":"内容","createdAt":$createdAt}"""
|
||||
|
||||
/** 一段 `GET /sessions` 形状的会话行(约 60 rune 预览 + 未读计数),只用于量级对比。 */
|
||||
private fun sessions(count: Int): String = (1..count).joinToString(",") { i ->
|
||||
val preview = "这是第 $i 个会话的最后一条消息预览,长度大致与真实数据同量级,用来量字节数。"
|
||||
"""{"id":"s$i","type":"secondary","title":"会话 $i","createdAt":${i * 1000},""" +
|
||||
""""pinned":false,"personaId":null,"unreadCount":$i,""" +
|
||||
""""lastMessage":{"id":"p$i","sessionId":"s$i","role":"assistant",""" +
|
||||
""""content":"$preview","createdAt":${i * 1000}}}"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**入队顺序**逐次回响应的假后端:追尾是一次循环,固定响应验不出翻页。
|
||||
*
|
||||
* 队列见底时重复最后一份 —— 这样「永远满页」那种用例只需要入队一次。
|
||||
*/
|
||||
private inner class FakeMessagesBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
/** 每次请求的原始 URI(含 query),按到达顺序。 */
|
||||
val uris = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 每次响应体(UTF-8)的字节数,按到达顺序 —— 量化对比用。 */
|
||||
val responseBytes = CopyOnWriteArrayList<Int>()
|
||||
|
||||
private val queue = ConcurrentLinkedQueue<Pair<Int, String>>()
|
||||
|
||||
private val last = java.util.concurrent.atomic.AtomicReference(200 to """{"data":[]}""")
|
||||
|
||||
fun enqueue(status: Int, body: String) {
|
||||
queue += status to body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
uris += exchange.requestURI.toString()
|
||||
val (status, body) = queue.poll() ?: last.get()
|
||||
last.set(status to body)
|
||||
val bytes = body.toByteArray()
|
||||
responseBytes += bytes.size
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,12 +88,13 @@ class MessageMergeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later page carrying voice upgrades the earlier copy of the same message`() {
|
||||
// 契约 v0.79(api-client §4.4 覆盖率):`voice` **只在四个 REST 读取面**填充,
|
||||
// `message_persisted` / 握手 `history` 回放**不带**它(入站语音的 voice 列是落库之后回填的)。
|
||||
// ⇒ 设计上「实时到达的语音先按普通文本显示,等下一次拉取自动升级成语音气泡」,
|
||||
// 而这条设计**完全依赖**下面这条既有规则:按 id 合并时**新到的那一份胜出**。
|
||||
// 这条用例存在的意义是:谁哪天把「incoming 胜出」改成「保留旧的」,语音气泡就永远不会出现。
|
||||
fun `a later copy carrying voice wins over the earlier copy of the same message`() {
|
||||
// 同一条消息会从**多路**到本端:落库帧、握手 `history` 回放、REST 分页、编辑帧。
|
||||
// 按 id 合并时「新到的那一份胜出」是这几路唯一的收口规则——不然后到的那份白到。
|
||||
// 契约 v0.97 之后 `voice` **每一条路都带**(后端 M44 `fb55769`:落库顺序 INSERT →
|
||||
// 写 voice 列 → notify),所以这条规则**不再是**「语音升级的唯一通路」;
|
||||
// 但它照旧必需:谁哪天把「incoming 胜出」改成「保留旧的」,编辑过的正文与任何后补字段
|
||||
// 都会永远不生效。
|
||||
val withoutVoice = msg("m1", 100, content = "明天几点出发")
|
||||
val withVoice = withoutVoice.copy(
|
||||
voice = VoiceDto(
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 「把一段文字念出来」的取字节层([SpeechClient]):发什么、收什么、落哪儿、失败算哪一档。
|
||||
*
|
||||
* 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,
|
||||
* 与 [top.yeij.cyrene.data.api.RestAttemptClientTest] 同一套做法,不引任何新依赖),落盘用真临时目录。
|
||||
*
|
||||
* ## 它钉住的六条事实
|
||||
* 1. **产物恒为 WAV,按字节嗅探**:`200` 却回 JSON 信封(网关/反代把错误包了一层的典型形态)
|
||||
* 绝不喂给 `MediaPlayer`,如实判 [SpeechNotAudioException]。这一条刻意让假端点回
|
||||
* `Content-Type: audio/wav` —— 头是**不可信**的,判据只落在字节上。
|
||||
* 2. **0 字节不算「一段静音」**,判解析失败(与 `MediaRepository` 同一口径)。
|
||||
* 3. **落盘位置是 `<cacheDir>/media/tts_<hash>.wav`**:不在 `documents/`(那是 FileProvider 唯一
|
||||
* 暴露出去的根),名字按 `(baseUrl, sessionId, text)` 内容寻址。
|
||||
* 4. **同一段文字一个请求都不发第二次**(用请求计数断言,不是「文件存在」)。
|
||||
* 5. **空文本连请求都不发**:服务端对它只会回 `400`,本端没必要走一次往返。
|
||||
* 6. **非 2xx 保留服务端那句 `code`**:`tts_persona_unmapped` / `tts_no_character` 这两档的文案
|
||||
* 完全不同,折成一句话就说不清了。
|
||||
* 7. **换服务器不是命中**(P2-2):同一个 `(sessionId, text)` 在另一台服务器上可能是另一个声音,
|
||||
* 而地址末尾多写一个 `/` 必须**还是**同一份缓存(`trimEnd('/')` 归一)。
|
||||
*/
|
||||
class SpeechClientTest {
|
||||
|
||||
private lateinit var cacheRoot: File
|
||||
private val backend = FakeSpeechBackend()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheRoot = Files.createTempDirectory("cyrene-tts").toFile()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
cacheRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
/** 与生产同形:令牌由 [top.yeij.cyrene.data.api.ApiClientFactory] 的拦截器注入。 */
|
||||
private fun client(baseUrl: String = backend.baseUrl) = SpeechClient(
|
||||
baseUrlProvider = { baseUrl },
|
||||
tokenProvider = { TOKEN },
|
||||
cacheDir = cacheRoot,
|
||||
)
|
||||
|
||||
private suspend fun speak(
|
||||
client: SpeechClient = client(),
|
||||
text: String = "你好",
|
||||
sessionId: String? = SESSION_ID,
|
||||
): Result<File>? = withTimeoutOrNull(TIMEOUT_MILLIS) { client.speak(text, sessionId) }
|
||||
|
||||
@Test
|
||||
fun `a real wav lands under media as a tts named file and comes back playable`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val file = speak()!!.getOrThrow()
|
||||
|
||||
assertEquals("media", file.parentFile?.name)
|
||||
assertTrue("缓存名该带 tts_ 前缀,实际 ${file.name}", file.name.startsWith("tts_"))
|
||||
assertTrue("缓存名该是 .wav,实际 ${file.name}", file.name.endsWith(".wav"))
|
||||
assertArrayEquals(WAV, file.readBytes())
|
||||
// 令牌确实是本层挂上去的那一条(owner Bearer,不是 API Key)。
|
||||
assertEquals("Bearer $TOKEN", backend.headers.single()["authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same text a second time sends no request at all`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val first = speak()!!.getOrThrow()
|
||||
val second = speak()!!.getOrThrow()
|
||||
|
||||
assertEquals(first, second)
|
||||
assertEquals("第二次该命中缓存,一个请求都不发", 1, backend.requestLines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a json envelope served with 200 is rejected instead of fed to the player`() = runBlocking {
|
||||
// 头照写 audio/wav —— 恰恰证明判据不能落在 Content-Type 上。
|
||||
backend.reply(200, """{"error":{"code":"tts_failed","message":"合成失败"}}""".toByteArray())
|
||||
|
||||
val failure = speak()!!.exceptionOrNull()
|
||||
|
||||
assertTrue("200 却不是 WAV 该判 SpeechNotAudioException,实际 $failure", failure is SpeechNotAudioException)
|
||||
// 绝不落盘:一份不是音频的缓存会在下一次「命中」并被喂给播放器。
|
||||
assertNoCacheFile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty body is a parse failure rather than a silent clip`() = runBlocking {
|
||||
backend.reply(200, ByteArray(0))
|
||||
|
||||
val failure = speak()!!.exceptionOrNull()
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error)
|
||||
assertNoCacheFile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a persona without a voice keeps the server code instead of flattening it`() = runBlocking {
|
||||
backend.reply(500, """{"error":{"code":"$CODE_PERSONA_UNMAPPED","message":"人格没有发音人映射"}}""".toByteArray())
|
||||
|
||||
val failure = speak()!!.exceptionOrNull() as CyreneException
|
||||
|
||||
val server = failure.error as CyreneError.Server
|
||||
assertEquals(500, server.httpStatus)
|
||||
assertEquals(CODE_PERSONA_UNMAPPED, server.code)
|
||||
assertNoCacheFile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank text fails locally without a round trip`() = runBlocking {
|
||||
val failure = speak(text = " \n ")!!.exceptionOrNull()
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error)
|
||||
assertTrue("空文本不该发请求", backend.requestLines.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two sessions with the same text are two different clips`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val web = speak(sessionId = "web")!!.getOrThrow()
|
||||
val phone = speak(sessionId = "phone")!!.getOrThrow()
|
||||
|
||||
// 发音人由会话的生效人格决定 ⇒ 同一段文字在不同会话里可以是两个声音,缓存名必须分开。
|
||||
assertFalse("不同会话的同一段文字不该撞同一个缓存名", web == phone)
|
||||
assertEquals(2, backend.requestLines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same session and text on another server is not a cache hit`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
val here = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
|
||||
|
||||
// 换一台服务器:会话 id 与文字都一样,但那边的人格映射 / 发音人 / 合成实现是它自己的。
|
||||
val elsewhere = FakeSpeechBackend()
|
||||
try {
|
||||
elsewhere.reply(200, WAV)
|
||||
val there = speak(client = client(baseUrl = elsewhere.baseUrl))!!.getOrThrow()
|
||||
|
||||
assertFalse("换服务器后不该命中上一台合成的那份 WAV(那是别人的声音)", here == there)
|
||||
assertEquals("这台各请求一次", 1, backend.requestLines.size)
|
||||
assertEquals("那台也请求了一次", 1, elsewhere.requestLines.size)
|
||||
} finally {
|
||||
elsewhere.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing slash on the base url is the same server`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val withSlash = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
|
||||
val without = speak(client = client(baseUrl = backend.baseUrl.trimEnd('/')))!!.getOrThrow()
|
||||
|
||||
// 地址末尾多写一个 `/` 不该让整份合成缓存作废(归一在 `cacheName` 里)。
|
||||
assertEquals(withSlash, without)
|
||||
assertEquals(1, backend.requestLines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wav sniffing looks at the bytes and nothing else`() {
|
||||
assertTrue(isWavBytes(WAV))
|
||||
assertFalse("11 字节连 RIFF 头都不完整", isWavBytes(WAV.copyOf(11)))
|
||||
assertFalse("空数组", isWavBytes(ByteArray(0)))
|
||||
// RIFX 是 RIFF 的大端变体,不是本端承诺的产物形状。
|
||||
assertFalse("RIFX 不是 WAVE", isWavBytes("RIFX".toByteArray() + WAV.copyOfRange(4, WAV.size)))
|
||||
// 头对了但第 8 字节起不是 WAVE:RIFF 容器里装的是别的东西。
|
||||
assertFalse("RIFF 里不是 WAVE", isWavBytes("RIFF".toByteArray() + ByteArray(WAV.size - 4)))
|
||||
}
|
||||
|
||||
private fun assertNoCacheFile() {
|
||||
val media = File(cacheRoot, "media")
|
||||
val leftovers = media.listFiles()?.filter { it.name.startsWith("tts_") }.orEmpty()
|
||||
assertTrue("失败时不该留下缓存文件,实际 $leftovers", leftovers.isEmpty())
|
||||
}
|
||||
|
||||
/** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**与请求头原文。 */
|
||||
private inner class FakeSpeechBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/audio/speech", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val requestLines = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
|
||||
val headers = CopyOnWriteArrayList<Map<String, String>>()
|
||||
|
||||
@Volatile
|
||||
private var status = 200
|
||||
|
||||
@Volatile
|
||||
private var payload = WAV
|
||||
|
||||
fun reply(status: Int, body: ByteArray) {
|
||||
this.status = status
|
||||
this.payload = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requestLines += "${exchange.requestMethod} ${exchange.requestURI}"
|
||||
headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") }
|
||||
exchange.requestBody.readBytes()
|
||||
// 一律回 audio/wav:判据必须落在字节上,本测试**故意**让头说谎。
|
||||
exchange.responseHeaders.add("Content-Type", "audio/wav")
|
||||
exchange.sendResponseHeaders(status, payload.size.toLong())
|
||||
exchange.responseBody.use { it.write(payload) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SESSION_ID = "web"
|
||||
const val TOKEN = "test-token"
|
||||
const val TIMEOUT_MILLIS = 10_000L
|
||||
const val CODE_PERSONA_UNMAPPED = "tts_persona_unmapped"
|
||||
|
||||
/** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */
|
||||
val WAV: ByteArray =
|
||||
"RIFF".toByteArray(Charsets.US_ASCII) +
|
||||
byteArrayOf(36, 0, 0, 0) +
|
||||
"WAVE".toByteArray(Charsets.US_ASCII) +
|
||||
ByteArray(40)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.chat.hasVoice
|
||||
import top.yeij.cyrene.domain.chat.isVoiceMessage
|
||||
import top.yeij.cyrene.domain.chat.voiceAudio
|
||||
import top.yeij.cyrene.domain.chat.voiceFallbackNote
|
||||
|
||||
/**
|
||||
* 落库帧上的 `voice`(契约 **v0.97**,后端 M44 `fb55769`;`failed` / `fallbackReason` 见 M57-M59)。
|
||||
*
|
||||
* ## 这条钉子钉的是「撤掉兜底」之后剩下的判据
|
||||
* W73 时期的旧口径是「`voice` 只在四个 REST 读取面填充,实时帧与握手回放缺席 ⇒ 实时到达的语音
|
||||
* 先按普通文本显示,等下一次拉取自动升级」。后端把落库顺序改成 **INSERT → 写 `voice` 列 →
|
||||
* notify** 之后,语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,
|
||||
* 于是那条兜底**整条撤掉**。判据剩下两条:`voice` 键存在(`hasVoice`)**且** `status != "failed"`
|
||||
* (`isVoiceMessage`)—— 降级行没有可播的声音,画成语音形态就是给一个点下去必然失败的按钮。
|
||||
*
|
||||
* 钉在这里的四件事,任何一件被将来的改动破坏都会让语音消息静默退回纯文本(或反过来,
|
||||
* 把一条降级行画成播不出来的语音):
|
||||
* 1. `message_persisted` 载荷里的 `voice` **真的被解出来**(`PersistedData` 只解 `message`,
|
||||
* 而生产 `Json` 是 `ignoreUnknownKeys = true` ⇒ 漏解不报错,只是判据永远为假);
|
||||
* 2. `channel_persisted` 同样——两条帧共用一个载荷类型;
|
||||
* 3. `voice` 键**缺席**仍是「不是语音」(不为「实时帧可能不带」留任何分支);
|
||||
* 4. `status = "failed"` 的降级行**不是**语音形态,但也不是纯文本:正文 + 一行降级小标
|
||||
* (`voiceFallbackNote`),且 `voiceAudio` 为 null ⇒ 没有播放控件。
|
||||
*
|
||||
* 帧形状照服务端实现:`data:{message:{…}}`(`channel_persisted` 同形;与 `message_edited`
|
||||
* 那条「`data` 就是消息本身」**不同**)。
|
||||
*/
|
||||
class WsEventPersistedVoiceTest {
|
||||
|
||||
private fun frame(type: String, data: String) = """{"type":"$type","sessionId":"web","data":$data}"""
|
||||
|
||||
private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
/** 一条真实的入站语音行:转写文本在 `content` 里,`voice` 四键齐全(有音频副本)。 */
|
||||
private val voiceRow = """
|
||||
{"id":"1371","sessionId":"web","role":"user","content":"明天几点出发",
|
||||
"voice":{"source":"asr","status":"transcribed","name":"$copyName","mime":"audio/ogg",
|
||||
"size":12345,"durationMs":3200,"url":"/api/v1/media/$copyName"},
|
||||
"createdAt":1791088571785}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
/** 微信那条真实的无副本形状:平台自带转写 ⇒ 只有 🎤 标记,四个音频键全缺席。 */
|
||||
private val noCopyRow = """
|
||||
{"id":"1372","sessionId":"web","role":"user","content":"晚上回来吃饭吗",
|
||||
"voice":{"source":"platform","status":"transcribed"},
|
||||
"createdAt":1791088571786}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
private val plainRow = """
|
||||
{"id":"1373","sessionId":"web","role":"user","content":"在的","createdAt":1791088571787}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
/** 我们自己合成的出站语音(后端 M57-M59):与入站语音**同形**,走同一条渲染路径。 */
|
||||
private val ttsRow = """
|
||||
{"id":"1375","sessionId":"web","role":"assistant","content":"早上好呀",
|
||||
"voice":{"source":"tts","status":"transcribed","name":"$copyName","mime":"audio/ogg",
|
||||
"size":12345,"durationMs":3200,"url":"/api/v1/media/$copyName"},
|
||||
"createdAt":1791088571789}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
/** 合成降级那一行:`status = "failed"` + `fallbackReason`,四个副本键全缺席(没有可播的声音)。 */
|
||||
private val fallbackRow = """
|
||||
{"id":"1374","sessionId":"web","role":"assistant","content":"想跟你说句话",
|
||||
"voice":{"source":"tts","status":"failed","fallbackReason":"tts_timeout"},
|
||||
"createdAt":1791088571788}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
@Test
|
||||
fun `a persisted frame carries voice back to the decoder`() {
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$voiceRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
val voice = message.voice!!
|
||||
assertEquals("asr", voice.source)
|
||||
assertEquals("transcribed", voice.status)
|
||||
assertEquals(copyName, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals(12_345L, voice.size)
|
||||
assertEquals(3_200, voice.durationMs)
|
||||
assertEquals("/api/v1/media/$copyName", voice.url)
|
||||
// 判据:键存在即语音——这条帧到达的那一刻就该是语音气泡,不需要任何后续拉取。
|
||||
assertTrue("落库帧上的语音必须在第一帧就判成语音", isVoiceMessage(message))
|
||||
assertEquals(copyName, voiceAudio(voice)?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a channel persisted frame carries voice too`() {
|
||||
// 隔离会话那条帧与 message_persisted 共用 `PersistedData`:给载荷加字段不许只覆盖其中一条。
|
||||
val event = WsEvent.parse(frame("channel_persisted", """{"message":$voiceRow}""")) as WsEvent.ChannelPersisted
|
||||
|
||||
val message = event.message!!
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals(3_200, message.voice?.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transcribed voice without an audio copy is still a voice message`() {
|
||||
// 「是语音」与「能不能播」是两件事:判据只看键在不在,播放控件另看四键是否齐全。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$noCopyRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals("platform", message.voice?.source)
|
||||
assertNull("没有副本就不许画播放控件", voiceAudio(message.voice!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent voice key still means not a voice message`() {
|
||||
// 判据只有一个方向上的推论:缺席 = 不是语音。这里刻意不留「实时帧可能不带,先按文本显示」
|
||||
// 那条分支——M44 之后缺席就只意味着「这条本来就不是语音」。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$plainRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
assertNull(event.message?.voice)
|
||||
assertFalse(isVoiceMessage(event.message!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edited frame keeps the voice of the message it replaces`() {
|
||||
// 编辑帧的 `data` **就是**消息本身(没有 `message` 包一层),但它同样是 `voice` 的读面之一:
|
||||
// 编辑正文不许把已渲染的语音气泡打回纯文本。
|
||||
val event = WsEvent.parse(
|
||||
"""{"type":"message_edited","sessionId":"web","data":$voiceRow}""",
|
||||
) as WsEvent.MessageEdited
|
||||
|
||||
val message = event.message!!
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals("明天几点出发", message.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tts fallback row is text with a note, not a voice bubble`() {
|
||||
// 合成降级那一行:键在(`hasVoice` 为真 ⇒ 不许当成「什么都没有」),但 `status = "failed"`
|
||||
// ⇒ 没有可播的声音,画语音形态就是给一个点下去必然失败的按钮。正文照旧 + 一行原样小标。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$fallbackRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
val voice = message.voice!!
|
||||
assertTrue("降级行仍来自一段声音", hasVoice(message))
|
||||
assertFalse("但它没有可播的声音,不许画成语音形态", isVoiceMessage(message))
|
||||
assertNull("没有副本 ⇒ 一个播放控件都不画", voiceAudio(voice))
|
||||
assertEquals("想跟你说句话", message.content)
|
||||
// 原因**原样**出现在小标里(不映射、不猜闭集)。
|
||||
assertNotNull(voiceFallbackNote(voice))
|
||||
assertTrue(voiceFallbackNote(voice)!!.contains("tts_timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synthesized voice row is an ordinary voice bubble`() {
|
||||
// `source = tts` 与 asr / platform 走**同一条**路:判据只看 `status`,不看 `source`。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$ttsRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
val voice = message.voice!!
|
||||
assertEquals("tts", voice.source)
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals(copyName, voiceAudio(voice)?.name)
|
||||
assertNull("合成成功的那条不该有降级小标", voiceFallbackNote(voice))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 下行帧 `voice_output`(契约 v0.96 §3.15)。
|
||||
*
|
||||
* 两个容易踩空的地方在这里钉死:
|
||||
* 1. **帧壳没有 `sessionId`**(契约明写,会话 id 在 `data` 里)——解析层必须把它提到帧上,
|
||||
* 否则 `isForSession` 会把本帧**放行给每一个会话的 VM**(空值 = 无法判定),切到别的会话
|
||||
* 时也会响一段本属于别的会话的语音;
|
||||
* 2. `text` / `reply` / `provider` 三个键**一个都不能丢**:`reply` 是她说的话、`text` 是转写、
|
||||
* `provider == "silent"` 是「这一轮没有音频」的唯一信号(判据在 `domain.chat`)。
|
||||
*/
|
||||
class WsEventVoiceOutputTest {
|
||||
|
||||
private val realFrame = """
|
||||
{"type":"voice_output","data":{"audio":"UklGRg==","format":"wav","text":"你今天在吗","reply":"我在呢","sessionId":"web","deviceId":"voiceassist:cyrene:v1","provider":"emotiontts"}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `the real frame decodes with every payload key`() {
|
||||
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
|
||||
|
||||
assertEquals("voice_output", event.type)
|
||||
assertEquals("UklGRg==", event.audio)
|
||||
assertEquals("wav", event.format)
|
||||
assertEquals("你今天在吗", event.text)
|
||||
assertEquals("我在呢", event.reply)
|
||||
assertEquals("voiceassist:cyrene:v1", event.deviceId)
|
||||
assertEquals("emotiontts", event.provider)
|
||||
// 帧壳没有轮次 / 序号(这一帧不属任何流式段)。
|
||||
assertNull(event.turnId)
|
||||
assertNull(event.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the payload session id is lifted onto the frame so the session gate works`() {
|
||||
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
|
||||
|
||||
assertEquals("web", event.sessionId)
|
||||
assertTrue("本会话放行", event.isForSession("web"))
|
||||
assertFalse("别的会话必须挡下(否则会在别的会话里响起来)", event.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silent frame keeps the marker and the text`() {
|
||||
val frame = """
|
||||
{"type":"voice_output","data":{"audio":"","format":"wav","text":"嗯","reply":"嗯,我在","sessionId":"web","provider":"silent"}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.VoiceOutput
|
||||
|
||||
assertEquals("silent", event.provider)
|
||||
assertEquals("", event.audio)
|
||||
assertEquals("嗯,我在", event.reply)
|
||||
assertEquals("web", event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without a payload still forms the event instead of vanishing`() {
|
||||
// 坏帧(没有 data):会话归属退化成「无法判定」,但「这一帧来过」必须留下痕迹——
|
||||
// 整帧丢掉就是「她回了语音而屏幕上什么都没有」,那比一张空卡片糟得多。
|
||||
val event = WsEvent.parse("""{"type":"voice_output"}""") as WsEvent.VoiceOutput
|
||||
|
||||
assertNull(event.audio)
|
||||
assertNull(event.reply)
|
||||
assertEquals("", event.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 上行帧 `voice_input`(契约 v0.96 §3.15)。
|
||||
*
|
||||
* 两个键是**纯增量**:[WsOutbound.VoiceInput.deviceId] 与 `clientMsgId`。骨架里本来只有
|
||||
* `audio` / `format` / `text` / `sessionId` 四个——那四者的线上形状在这里逐字钉死(老帧一个
|
||||
* 字节都不许变),新增的两个只在有值时才出现(与后端 `omitempty` 同义)。
|
||||
*
|
||||
* `clientMsgId` 与文本 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条。所以真正的
|
||||
* 「幂等」不在这一层(它只负责照发),而在 `ChatViewModel` + 纯函数 `voiceClientMsgIdFor`:
|
||||
* 重发复用同一个键(那边有穷举单测)。
|
||||
*/
|
||||
class WsOutboundVoiceTest {
|
||||
|
||||
@Test
|
||||
fun `the original four key frame is byte for byte what it was`() {
|
||||
assertEquals(
|
||||
"""{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web"}}""",
|
||||
WsOutbound.VoiceInput(audio = "QUJD", format = "wav", sessionId = "web").toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device id and client message id are carried next to the audio`() {
|
||||
val frame = WsOutbound.VoiceInput(
|
||||
audio = "QUJD",
|
||||
format = "wav",
|
||||
sessionId = "web",
|
||||
deviceId = "voiceassist:cyrene:v1",
|
||||
clientMsgId = "voice-9f3c",
|
||||
).toJson()
|
||||
|
||||
val expected = """{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web","deviceId":"voiceassist:cyrene:v1","clientMsgId":"voice-9f3c"}}"""
|
||||
|
||||
assertEquals(expected, frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omitting the idempotency key omits the key itself rather than sending null`() {
|
||||
val json = WsOutbound.VoiceInput(audio = "QUJD", format = "wav").toJson()
|
||||
|
||||
assertFalse("省略时不该出现 clientMsgId 键:$json", json.contains("clientMsgId"))
|
||||
assertFalse("省略时不该出现 deviceId 键:$json", json.contains("deviceId"))
|
||||
assertNull(WsOutbound.VoiceInput(audio = "QUJD").reqId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 语音**上行**那一半的纯函数(契约 v0.96 §3.15 ①):录音参数、WAV 封装、体积闸门、幂等键。
|
||||
*
|
||||
* 这一组钉的是「发出去的字节长什么样」——WAV 头一旦有一个字段写错,服务端的解码器与 STT
|
||||
* 会**静默**给出垃圾(或直接拒),而客户端这边完全看不出问题。所以头的每个字段都在这里逐个
|
||||
* 读出来断言,而不是只比长度。
|
||||
*/
|
||||
class VoiceClipTest {
|
||||
|
||||
// —— 换算:16 kHz / 单声道 / 16-bit ⇒ 1 ms = 32 字节 ——
|
||||
|
||||
@Test
|
||||
fun `one second of pcm is 32 000 bytes at the chosen parameters`() {
|
||||
assertEquals(32_000, VoiceClip.BYTES_PER_SECOND)
|
||||
assertEquals(32_000, VoiceClip.pcmBytesFor(1_000))
|
||||
assertEquals(1_000, VoiceClip.durationMillisOf(32_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration and byte count round trip for typical clips`() {
|
||||
assertEquals(1_920_000, VoiceClip.MAX_PCM_BYTES)
|
||||
assertEquals(VoiceClip.MAX_RECORDING_MILLIS, VoiceClip.durationMillisOf(VoiceClip.MAX_PCM_BYTES))
|
||||
// 不足一毫秒的零头一律向下取整(绝不把 0 字节说成 1 毫秒)。
|
||||
assertEquals(0, VoiceClip.durationMillisOf(31))
|
||||
assertEquals(0, VoiceClip.durationMillisOf(0))
|
||||
assertEquals(0, VoiceClip.durationMillisOf(-5))
|
||||
assertEquals(0, VoiceClip.pcmBytesFor(-5))
|
||||
}
|
||||
|
||||
// —— WAV 头:逐字段 ——
|
||||
|
||||
@Test
|
||||
fun `the wav header carries the riff wav fmt data chunks of pcm`() {
|
||||
val pcmBytes = 32_000
|
||||
val header = VoiceClip.wavHeader(pcmBytes)
|
||||
|
||||
assertEquals("RIFF-WAVE 头恒为 44 字节", 44, header.size)
|
||||
assertEquals("RIFF", header.ascii(0, 4))
|
||||
// RIFF 的 size 字段 = 文件总长 - 8(不含 "RIFF" 与 size 自己)。
|
||||
assertEquals(44 - 8 + pcmBytes, header.intLe(4))
|
||||
assertEquals("WAVE", header.ascii(8, 4))
|
||||
assertEquals("fmt ", header.ascii(12, 4))
|
||||
assertEquals("fmt 子块长度 = 16(PCM)", 16, header.intLe(16))
|
||||
assertEquals("audioFormat = 1(PCM)", 1, header.shortLe(20))
|
||||
assertEquals("单声道", 1, header.shortLe(22))
|
||||
assertEquals("采样率 16 kHz", 16_000, header.intLe(24))
|
||||
assertEquals("字节率 = 采样率 × 声道 × 位深/8", 32_000, header.intLe(28))
|
||||
assertEquals("块对齐 = 声道 × 位深/8", 2, header.shortLe(32))
|
||||
assertEquals("16-bit", 16, header.shortLe(34))
|
||||
assertEquals("data", header.ascii(36, 4))
|
||||
assertEquals("data 子块长度 = PCM 字节数", pcmBytes, header.intLe(40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toWav length is header plus pcm and the payload is untouched`() {
|
||||
val pcm = ByteArray(64) { (it + 1).toByte() }
|
||||
|
||||
val wav = VoiceClip.toWav(pcm)
|
||||
|
||||
assertEquals("字节数 = 44 + PCM", 44 + pcm.size, wav.size)
|
||||
assertEquals(44 - 8 + pcm.size, wav.intLe(4))
|
||||
assertEquals(pcm.size, wav.intLe(40))
|
||||
// 头之后一个字节都不许动(重采样 / 量化是录音机的事,这里只搬字节)。
|
||||
assertArrayEquals(pcm, wav.copyOfRange(44, wav.size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty pcm still yields a self consistent 44 byte wav`() {
|
||||
val wav = VoiceClip.toWav(ByteArray(0))
|
||||
|
||||
assertEquals(44, wav.size)
|
||||
assertEquals(44 - 8, wav.intLe(4))
|
||||
assertEquals(0, wav.intLe(40))
|
||||
assertEquals("RIFF", wav.ascii(0, 4))
|
||||
assertEquals("data", wav.ascii(36, 4))
|
||||
}
|
||||
|
||||
// —— 准入:空 / 太短 / 超限 / 可发 ——
|
||||
|
||||
@Test
|
||||
fun `an empty recording is rejected as empty rather than too short`() {
|
||||
assertEquals(VoiceClipPlan.Empty, VoiceClip.plan(ByteArray(0)))
|
||||
assertEquals(VOICE_EMPTY_MESSAGE, voiceClipRejection(VoiceClipPlan.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a few milliseconds of audio is rejected as too short`() {
|
||||
// 100 字节 = 3 ms:按下就松手的那一下。
|
||||
val plan = VoiceClip.plan(ByteArray(100))
|
||||
|
||||
assertTrue("极短录音判 TooShort,实际 $plan", plan is VoiceClipPlan.TooShort)
|
||||
assertEquals(3, (plan as VoiceClipPlan.TooShort).durationMs)
|
||||
assertEquals(VOICE_TOO_SHORT_MESSAGE, voiceClipRejection(plan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one second is the shortest acceptable clip`() {
|
||||
val plan = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(1_000)))
|
||||
|
||||
assertTrue("满一秒就该放行,实际 $plan", plan is VoiceClipPlan.Ready)
|
||||
assertEquals(1_000, (plan as VoiceClipPlan.Ready).durationMs)
|
||||
assertEquals(44 + 32_000, plan.wav.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one millisecond under the minimum is still rejected`() {
|
||||
val pcm = ByteArray(VoiceClip.pcmBytesFor(1_000) - 32) // 32 字节 = 1 ms
|
||||
|
||||
val plan = VoiceClip.plan(pcm)
|
||||
|
||||
assertTrue("999 ms 仍算太短,实际 $plan", plan is VoiceClipPlan.TooShort)
|
||||
assertEquals(999, (plan as VoiceClipPlan.TooShort).durationMs)
|
||||
}
|
||||
|
||||
// —— 体积闸门(含「等于」那一格)——
|
||||
|
||||
@Test
|
||||
fun `the byte ceiling equals the server's decoded inline cap`() {
|
||||
// 与 chat.image 解码后同一条线:契约没给 voice_input 的上限,本端按已知最严的那一档设计。
|
||||
assertEquals(ChatImageBudget.SERVER_MAX_DECODED_BYTES, VoiceClip.MAX_AUDIO_BYTES)
|
||||
assertEquals(5 * 1024 * 1024, VoiceClip.MAX_AUDIO_BYTES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clip exactly at the ceiling is accepted`() {
|
||||
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES)
|
||||
|
||||
val total = VoiceClip.totalBytesFor(pcm.size)
|
||||
|
||||
assertEquals("上界取等于(<=)", VoiceClip.MAX_AUDIO_BYTES, total)
|
||||
assertTrue(VoiceClip.withinLimit(total))
|
||||
assertTrue("恰好到顶应当放行", VoiceClip.plan(pcm) is VoiceClipPlan.Ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clip one byte over the ceiling is refused with a named reason`() {
|
||||
// 43 字节:total = 44 + 43 = MAX + 1。
|
||||
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES + 1)
|
||||
|
||||
val total = VoiceClip.totalBytesFor(pcm.size)
|
||||
|
||||
assertEquals(VoiceClip.MAX_AUDIO_BYTES + 1, total)
|
||||
assertFalse(VoiceClip.withinLimit(total))
|
||||
val plan = VoiceClip.plan(pcm)
|
||||
assertTrue("超限判 TooLarge,实际 $plan", plan is VoiceClipPlan.TooLarge)
|
||||
assertEquals(total, (plan as VoiceClipPlan.TooLarge).totalBytes)
|
||||
assertEquals(VOICE_TOO_LARGE_MESSAGE, voiceClipRejection(plan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 60 second recording stays well under the ceiling`() {
|
||||
// 录满长度只用到闸门的 36%:撞上它说明硬件给了异常的量,不是正常路径。
|
||||
val total = VoiceClip.totalBytesFor(VoiceClip.MAX_PCM_BYTES)
|
||||
|
||||
assertTrue(VoiceClip.withinLimit(total))
|
||||
assertEquals(1_920_044, total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rejection of a sendable clip is null`() {
|
||||
val ready = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(2_000)))
|
||||
|
||||
assertNull(voiceClipRejection(ready))
|
||||
}
|
||||
|
||||
// —— 录音中的时长文案 ——
|
||||
|
||||
@Test
|
||||
fun `the elapsed label starts at zero and pads seconds`() {
|
||||
assertEquals("0:00", voiceElapsedLabel(0))
|
||||
assertEquals("0:00", voiceElapsedLabel(999))
|
||||
assertEquals("0:07", voiceElapsedLabel(7_000))
|
||||
assertEquals("1:05", voiceElapsedLabel(65_000))
|
||||
// 负数(时钟回拨 / 调用方算错)不显示成 -1:-1。
|
||||
assertEquals("0:00", voiceElapsedLabel(-1_000))
|
||||
}
|
||||
|
||||
// —— 幂等键:重发必须复用(契约 v0.96:同一个非空值只承认第一条)——
|
||||
|
||||
@Test
|
||||
fun `a fresh recording mints a new prefixed key`() {
|
||||
val key = voiceClientMsgIdFor(null) { "9f3c" }
|
||||
|
||||
assertEquals("voice-9f3c", key)
|
||||
assertTrue("前缀写进键里,好让服务端与日志一眼看出这条是语音", key.startsWith(VOICE_CLIENT_MSG_ID_PREFIX))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying the same clip reuses its client message id`() {
|
||||
val draft = VoiceDraftInfo(clientMsgId = "voice-keep", durationMs = 1_800, totalBytes = 57_644)
|
||||
|
||||
// 用户连点三次「重发」:每一次都必须拿同一个键(否则服务端会把同一句话答三遍)。
|
||||
val attempts = List(3) {
|
||||
voiceClientMsgIdFor(draft) { error("重发不该铸新键") }
|
||||
}
|
||||
|
||||
assertEquals(listOf("voice-keep", "voice-keep", "voice-keep"), attempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different recording never inherits the old key`() {
|
||||
val old = VoiceDraftInfo("voice-old", durationMs = 1_200, totalBytes = 38_444)
|
||||
|
||||
// 丢弃之后再录一段:状态里那一格已经清掉 ⇒ 现铸新键(否则新录音会被服务端当成重发吞掉)。
|
||||
val fresh = voiceClientMsgIdFor(null) { "fresh" }
|
||||
|
||||
assertEquals("voice-fresh", fresh)
|
||||
assertTrue(fresh != old.clientMsgId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 从字节数组里取一段 ASCII(断言 WAV 头的四个 chunk 名用)。 */
|
||||
private fun ByteArray.ascii(offset: Int, length: Int): String =
|
||||
String(this, offset, length, Charsets.US_ASCII)
|
||||
|
||||
/** 小端有符号整数(WAV 头里全是 32 位小端)。 */
|
||||
private fun ByteArray.intLe(offset: Int): Int =
|
||||
(this[offset].toInt() and 0xFF) or
|
||||
((this[offset + 1].toInt() and 0xFF) shl 8) or
|
||||
((this[offset + 2].toInt() and 0xFF) shl 16) or
|
||||
((this[offset + 3].toInt() and 0xFF) shl 24)
|
||||
|
||||
/** 小端 16 位无符号整数。 */
|
||||
private fun ByteArray.shortLe(offset: Int): Int =
|
||||
(this[offset].toInt() and 0xFF) or ((this[offset + 1].toInt() and 0xFF) shl 8)
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -17,13 +18,15 @@ import top.yeij.cyrene.ui.chat.isContentlessUserMessage
|
||||
/**
|
||||
* 语音消息的**呈现判据**(纯函数,接收侧)。
|
||||
*
|
||||
* 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,五条口径逐条钉在这里:
|
||||
* 1. **🎤 判据 = `voice` 键存在**(`isVoiceMessage`)——**绝不**从正文里认媒体类型
|
||||
* (正文里可能出现用户自己打出的 `[语音]` 字样);
|
||||
* 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,六条口径逐条钉在这里:
|
||||
* 1. **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**(`isVoiceMessage`)——**绝不**从正文里
|
||||
* 认媒体类型(正文里可能出现用户自己打出的 `[语音]` 字样),也绝不把 `failed` 行画成语音形态
|
||||
* (那一行没有可播的声音,见 `isPlayableVoice` / `hasVoice`);
|
||||
* 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全);
|
||||
* 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充;
|
||||
* 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示;
|
||||
* 5. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**,
|
||||
* 5. **`failed` 行多一行灰色小标**(`voiceFallbackNote`):固定前半句 + 服务端 `fallbackReason` 原样;
|
||||
* 6. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**,
|
||||
* 绝不退回「自己拼一条 `/api/v1/media/<name>`」。
|
||||
*/
|
||||
class VoiceMessageTest {
|
||||
@@ -50,6 +53,7 @@ class VoiceMessageTest {
|
||||
mime: String? = "audio/ogg",
|
||||
size: Long? = 12_345,
|
||||
durationMs: Int? = 3_200,
|
||||
fallbackReason: String? = null,
|
||||
url: String? = "/api/v1/media/$copyName",
|
||||
) = VoiceDto(
|
||||
source = source,
|
||||
@@ -58,19 +62,22 @@ class VoiceMessageTest {
|
||||
mime = mime,
|
||||
size = size,
|
||||
durationMs = durationMs,
|
||||
fallbackReason = fallbackReason,
|
||||
url = url,
|
||||
)
|
||||
|
||||
// —— 1. 判据 = 键存在 ——
|
||||
// —— 1. 「来自一条语音」的判据 = 键存在(`hasVoice`)——
|
||||
|
||||
@Test
|
||||
fun `the voice key is the only criterion`() {
|
||||
assertTrue(isVoiceMessage(message(voice = voice())))
|
||||
assertFalse(isVoiceMessage(message(content = "在的")))
|
||||
fun `the voice key is the only criterion for coming from a voice`() {
|
||||
assertTrue(hasVoice(message(voice = voice())))
|
||||
assertFalse(hasVoice(message(content = "在的")))
|
||||
// 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发,
|
||||
// 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。
|
||||
assertFalse(isVoiceMessage(message(content = "[语音]")))
|
||||
assertFalse(isVoiceMessage(message(content = "语音")))
|
||||
assertFalse(hasVoice(message(content = "[语音]")))
|
||||
assertFalse(hasVoice(message(content = "语音")))
|
||||
assertTrue(hasVoice(message(content = "[语音]", voice = voice())))
|
||||
// 「要不要画语音形态」是**另一个**问题(见下面第 4b 节),但键在是它的前提。
|
||||
assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice())))
|
||||
}
|
||||
|
||||
@@ -163,6 +170,83 @@ class VoiceMessageTest {
|
||||
assertNull(voiceNote(VoiceStatus.EMPTY, "其实有字"))
|
||||
}
|
||||
|
||||
// —— 4b. failed 行:没有可播的声音,但仍然是「一条语音消息」——
|
||||
|
||||
@Test
|
||||
fun `a failed voice is not a voice bubble but still a voice message`() {
|
||||
// 判据拆成两个问题:hasVoice 答「这条来自一条语音吗」,isVoiceMessage 答「画不画语音形态」。
|
||||
// 合成降级 / 转写失败的行两问的答案是「是」「否」——正因如此才不能混用。
|
||||
val outbound = message(content = "想跟你说句话", voice = voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED))
|
||||
assertTrue(hasVoice(outbound))
|
||||
assertFalse(isVoiceMessage(outbound))
|
||||
// 入站转写失败同形:本端分不清(也不该分)这两个方向。
|
||||
assertFalse(isVoiceMessage(message(voice = voice(status = VoiceStatus.FAILED))))
|
||||
// 键缺席才是「不是语音消息」。
|
||||
assertFalse(hasVoice(message(content = "在的")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed voice is excluded by status, not by its copy keys`() {
|
||||
// 两个判据各判各的、互不顶替:voiceAudio 只看四键齐不齐(**不看** status),
|
||||
// isPlayableVoice 只看 status(**不看**四键)。所以「四键齐全但 status = failed」这种
|
||||
// 后端按契约不该发的形状,voiceAudio 照样能构造出来 —— 拦住语音形态的是 isVoiceMessage。
|
||||
val contradictory = voice(status = VoiceStatus.FAILED)
|
||||
assertNotNull(voiceAudio(contradictory))
|
||||
assertFalse(isPlayableVoice(contradictory))
|
||||
assertFalse(isVoiceMessage(message(voice = contradictory)))
|
||||
// 反向:四键不齐的 transcribed 行照样「可播」(画语音形态、只是没有播放控件)。
|
||||
val noCopy = voice(name = null, mime = null, size = null, url = null)
|
||||
assertNull(voiceAudio(noCopy))
|
||||
assertTrue(isPlayableVoice(noCopy))
|
||||
assertTrue(isVoiceMessage(message(voice = noCopy)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fallback reason is shown verbatim and never mapped`() {
|
||||
// 原样放进括号:后端换一个取值本端一个字符都不改(闭集还没定,映射比原样更难排查)。
|
||||
assertEquals(
|
||||
"这条语音没有可播放的声音(tts_timeout)",
|
||||
voiceFallbackNote(voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED, fallbackReason = "tts_timeout")),
|
||||
)
|
||||
assertEquals(
|
||||
"这条语音没有可播放的声音(budget_exceeded)",
|
||||
voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = "budget_exceeded")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed voice without a reason still says something`() {
|
||||
// 缺席 / 空白:只剩固定前半句。这一档同时是「空正文 + failed」的兜底 ——
|
||||
// 屏幕上至少还有这行小标,而不是一个什么都没有的空白。
|
||||
assertEquals(VOICE_FALLBACK_NOTE, voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = null)))
|
||||
assertEquals(VOICE_FALLBACK_NOTE, voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a failed voice gets a fallback note`() {
|
||||
assertNull(voiceFallbackNote(null))
|
||||
assertNull(voiceFallbackNote(voice()))
|
||||
assertNull(voiceFallbackNote(voice(status = VoiceStatus.EMPTY)))
|
||||
// 认不出的 status 也不加这一行:多画一句「没有可播放的声音」就是替服务端断言它没有。
|
||||
assertNull(voiceFallbackNote(voice(status = "future-status")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synthesized voice that went through is an ordinary voice bubble`() {
|
||||
// source = tts 与 asr / platform 走**同一条**路:判据只看 status,不看 source。
|
||||
val tts = voice(source = VoiceSource.TTS)
|
||||
assertTrue(isVoiceMessage(message(content = "早上好呀", voice = tts)))
|
||||
assertNotNull(voiceAudio(tts))
|
||||
assertNull(voiceFallbackNote(tts))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown status is played rather than silently swallowed`() {
|
||||
// fail-open:认不出的 status 一律当可播。这个方向错得便宜(多画一枚语音形态,最坏点了播不出来,
|
||||
// 有既有的失败文案兜着);反方向错得贵(把真有副本的语音静默吞成纯文字,用户再也点不到)。
|
||||
assertTrue(isVoiceMessage(message(voice = voice(status = "truncated"))))
|
||||
}
|
||||
|
||||
// —— 5. 相对路径接到当前服务器地址 ——
|
||||
|
||||
@Test
|
||||
@@ -244,6 +328,14 @@ class VoiceMessageTest {
|
||||
// ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。
|
||||
val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null))
|
||||
assertFalse(isContentlessUserMessage(empty))
|
||||
// `failed` 那一档同样不成立,只是理由不同:它**没有**语音形态,但有那行降级小标
|
||||
// (见 `voiceFallbackNote`)—— 同样不是「什么都没有」。判据因此挂在 `hasVoice` 上而**不是**
|
||||
// `isVoiceMessage` 上:两种落点都画得出东西。
|
||||
val failed = message(
|
||||
content = "",
|
||||
voice = voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED, fallbackReason = "tts_timeout"),
|
||||
)
|
||||
assertFalse(isContentlessUserMessage(failed))
|
||||
// 对照:同样空的正文,没有 voice 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。
|
||||
assertTrue(isContentlessUserMessage(message(content = "")))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* `voice_output` 的呈现判据(契约 v0.96 §3.15):两件事必须判对,否则 UI 一定画错。
|
||||
*
|
||||
* 1. **这一轮有没有真音频**——`provider == "silent"` 是明文降级信号(`audio` 空、`text`/`reply`
|
||||
* 有)。判错成「有」就会画出一个点下去必然失败的播放器;判错成「没有」则让本来能放的语音
|
||||
* 变成一段文字。
|
||||
* 2. **屏幕上该显示哪一句**——`reply` 是**她说的话**(音频念的就是它),`text` 是**转写**
|
||||
* (我们那段音频被识别成什么)。两者的顺序反过来,用户看到的就是自己说的话冒充她的回答。
|
||||
*/
|
||||
class VoiceOutputTest {
|
||||
|
||||
// —— provider 降级信号 ——
|
||||
|
||||
@Test
|
||||
fun `the silent provider is recognised literally`() {
|
||||
assertTrue(isSilentVoiceProvider(VoiceProvider.SILENT))
|
||||
// 机器可读码:容忍首尾空白与大小写,但不容忍别的词。
|
||||
assertTrue(isSilentVoiceProvider(" SILENT "))
|
||||
assertFalse(isSilentVoiceProvider("emotiontts"))
|
||||
assertFalse(isSilentVoiceProvider(""))
|
||||
assertFalse(isSilentVoiceProvider(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silent frames never count as having audio`() {
|
||||
// 契约:silent ⇒ audio 空。万一服务端还是塞了字节,也不能画播放器(它自己说这轮没发音)。
|
||||
assertFalse(voiceOutputHasAudio(audio = null, provider = VoiceProvider.SILENT))
|
||||
assertFalse(voiceOutputHasAudio(audio = "QUJD", provider = VoiceProvider.SILENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a normal provider needs actual bytes to be playable`() {
|
||||
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = "emotiontts"))
|
||||
// 没给 provider(老服务端 / 省略该键)、但有字节 ⇒ 能放。
|
||||
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = null))
|
||||
// 有 provider 却没字节 ⇒ 没有音频(空串与 null 同一档)。
|
||||
assertFalse(voiceOutputHasAudio(audio = "", provider = "emotiontts"))
|
||||
assertFalse(voiceOutputHasAudio(audio = null, provider = "emotiontts"))
|
||||
}
|
||||
|
||||
// —— text vs reply ——
|
||||
|
||||
@Test
|
||||
fun `reply wins because it is what she actually said`() {
|
||||
assertEquals("我在呢", voiceReplyText(reply = "我在呢", text = "你今天在吗"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing reply falls back to the transcript`() {
|
||||
assertEquals("你今天在吗", voiceReplyText(reply = null, text = "你今天在吗"))
|
||||
assertEquals("你今天在吗", voiceReplyText(reply = " ", text = "你今天在吗"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both missing means show nothing instead of inventing a line`() {
|
||||
assertNull(voiceReplyText(reply = null, text = null))
|
||||
assertNull(voiceReplyText(reply = "", text = " "))
|
||||
}
|
||||
|
||||
// —— 那一句说明:两档说的不是同一件事 ——
|
||||
|
||||
@Test
|
||||
fun `the silent note says only text this round`() {
|
||||
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = false))
|
||||
// silent 档即便本地碰巧有个文件也不该说「本机没准备好」。
|
||||
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local decode failure is reported as a local problem not as silence`() {
|
||||
assertEquals(VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE, voiceReplyNote(silent = false, hasAudioFile = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a playable reply needs no note`() {
|
||||
assertNull(voiceReplyNote(silent = false, hasAudioFile = true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* 「朗读」这条路上本端要说的那几句话与纯判据([ttsTextOf] / [ttsFailureOf] / [ttsClipIdOf])。
|
||||
*
|
||||
* 这个文件全是纯函数,不碰网络也不碰 Android:取字节在 `SpeechClientTest`(真 HTTP),
|
||||
* 图标与播放接线在界面层。
|
||||
*
|
||||
* ## 它钉住的四件事
|
||||
* 1. **念什么**:只念正文(`body`)——抬头(`head`)像字幕、旁白(`tail`)是舞台指示,都不念;
|
||||
* 一条消息切成多个气泡时按 `\n` 拼,空正文不产生空行。
|
||||
* 2. **能不能念**:「这条消息有没有可念的正文」是唯一判据,`ChatViewModel.speak`(发不发请求)与
|
||||
* `MessageBubble`(画不画图标)用的必须是同一个答案;`mode` 不是 `chat` 时念的是**原文**,
|
||||
* 因为那时气泡里放的就是原文、一个 markup 都不解析。
|
||||
* 3. **失败算哪一档**:只有服务端明确回了「配置缺失」类 `code` 才进
|
||||
* [TtsFailure.Unconfigured](重试永远不会成功,文案要说清缺什么、且不提重试),
|
||||
* 其余一切(网络、解析、401、`voice_unavailable` / `tts_failed` / `tts_empty`)都是
|
||||
* [TtsFailure.Soft]。
|
||||
* 4. **播放键**:`tts:<messageId>`,与语音副本的键(就是 `messageId`)分得开。
|
||||
*/
|
||||
class VoiceSpeakTest {
|
||||
|
||||
// ---- 念什么:气泡 → 文本 ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `bubbles are joined by a newline so the pause lands between paragraphs`() {
|
||||
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p>再见</p>")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty message has nothing to read out loud`() {
|
||||
assertNull(ttsTextOf(parse("")))
|
||||
assertNull(ttsTextOf(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narration alone is stage direction and is not read out loud`() {
|
||||
// 旁白整段进 head(首部依附),正文为空 ⇒ 没有可念的字。
|
||||
assertNull(ttsTextOf(parse("<p><narration>她笑了笑</narration></p>")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the speaker head is skipped and only the body is read`() {
|
||||
val bubbles = parse("<p><narration>她笑了笑</narration>你好</p>")
|
||||
|
||||
assertEquals("你好", ttsTextOf(bubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank bubble does not become a blank line`() {
|
||||
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p> </p><p>再见</p>")))
|
||||
}
|
||||
|
||||
// ---- 能不能念:一条消息 → 文本 ------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a chat message is read after the markup is stripped`() {
|
||||
assertEquals("你好\n再见", ttsTextOf(message(content = "<p>你好</p><p>再见</p>", mode = "chat")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message without a mode counts as a chat message`() {
|
||||
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = null)))
|
||||
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non chat mode is read verbatim because the bubble shows the raw text`() {
|
||||
// 非 chat 的 mode 下气泡里放的就是原文(MessageBubble 的 isChat 判据),
|
||||
// 所以朗读念的也必须是那同一段字,一个标签都不许吞。
|
||||
assertEquals("<p>你好</p>", ttsTextOf(message(content = "<p>你好</p>", mode = "roleplay")))
|
||||
assertEquals("你好", ttsTextOf(message(content = " 你好 ", mode = "roleplay")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message with nothing to say answers null in both modes`() {
|
||||
assertNull(ttsTextOf(message(content = "", mode = "chat")))
|
||||
assertNull(ttsTextOf(message(content = " ", mode = "roleplay")))
|
||||
assertNull(ttsTextOf(message(content = "<p><narration>她笑了笑</narration></p>", mode = "chat")))
|
||||
}
|
||||
|
||||
// ---- 失败算哪一档 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a persona without a voice mapping says so and is not worth retrying`() {
|
||||
val failure = ttsFailureOf(serverError(TTS_CODE_PERSONA_UNMAPPED))
|
||||
|
||||
assertEquals(TtsFailure.Unconfigured(TTS_CODE_PERSONA_UNMAPPED), failure)
|
||||
assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, failure.message)
|
||||
assertFalse("配置缺失类重试永远不会成功", failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session without any character says so instead of blaming the network`() {
|
||||
val failure = ttsFailureOf(serverError(TTS_CODE_NO_CHARACTER))
|
||||
|
||||
assertEquals(TTS_NO_CHARACTER_MESSAGE, failure.message)
|
||||
assertFalse(failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognized code is not guessed into the configuration bucket`() {
|
||||
// 本端认不出这个 code,就**不知道**它是配置缺失还是临时故障 ⇒ 不猜,落柔和档:
|
||||
// 判成「配置没配好」等于对用户说了一句本端没有依据的话。
|
||||
val failure = ttsFailureOf(serverError("tts_something_new"))
|
||||
|
||||
assertEquals(TtsFailure.Soft, failure)
|
||||
assertTrue(failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a configuration failure built from an unknown code still explains itself`() {
|
||||
// 兜底分支:万一将来有调用点直接构造了一个本端还不认识的配置缺失码,
|
||||
// 文案仍是「缺发音人」而不是「网络不好」,且照样不提重试。
|
||||
val failure = TtsFailure.Unconfigured("tts_something_new")
|
||||
|
||||
assertEquals(TTS_UNCONFIGURED_MESSAGE, failure.message)
|
||||
assertFalse(failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that is merely unavailable right now stays in the soft bucket`() {
|
||||
listOf("voice_unavailable", "tts_failed", "tts_empty").forEach { code ->
|
||||
val failure = ttsFailureOf(serverError(code))
|
||||
|
||||
assertEquals("$code 该是柔和档", TtsFailure.Soft, failure)
|
||||
assertEquals(TTS_SOFT_FAILURE_MESSAGE, failure.message)
|
||||
assertTrue("$code 过一会儿再点是有意义的", failure.retryable)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired token is soft because renewal can still save it`() {
|
||||
val failure = ttsFailureOf(CyreneException(CyreneError.Unauthorized(code = "", message = "")))
|
||||
|
||||
assertEquals(TtsFailure.Soft, failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network or parse failure is soft and does not pretend to know why`() {
|
||||
assertEquals(TtsFailure.Soft, ttsFailureOf(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))))
|
||||
assertEquals(TtsFailure.Soft, ttsFailureOf(IOException("boom")))
|
||||
// 非 CyreneException 一律柔和档:本端确实不知道断在哪一段。
|
||||
assertEquals(TtsFailure.Soft, ttsFailureOf(IllegalStateException("boom")))
|
||||
}
|
||||
|
||||
// ---- 播放键 ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the clip key is namespaced so it never collides with a voice copy`() {
|
||||
assertEquals("tts:m-1", ttsClipIdOf("m-1"))
|
||||
assertFalse("朗读与语音副本的键必须分得开", ttsClipIdOf("m-1") == "m-1")
|
||||
}
|
||||
|
||||
private fun serverError(code: String) = CyreneException(
|
||||
CyreneError.Server(code = code, message = "服务端说明", httpStatus = 500),
|
||||
)
|
||||
|
||||
private fun message(content: String, mode: String?) = HistoryMessageDto(
|
||||
id = "m-1",
|
||||
sessionId = "web",
|
||||
role = "assistant",
|
||||
content = content,
|
||||
mode = mode,
|
||||
createdAt = 0L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.yeij.cyrene.domain.share
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 批量投递的「声明」边界([ShareRules.declaresSendPreference])。
|
||||
*
|
||||
* 钉住的是一件**不许再走回去**的事:分享进来的 N 张图会落成 N 条消息(契约里 `chat.image` 是单图
|
||||
* 字段),而这 N 条是**一次**投递动作——它们之间绝不许互相打断 / 互相取消(`02` F4 的后端口径、
|
||||
* `05` 的多图自相打断一节)。一旦某一条在忙时声明了 `interrupt`,第 2 条就会把第 1 条刚起的回合打
|
||||
* 掉;声明 `parallel` 同样把「一次倒进去」变成「几条并行回合」。
|
||||
*
|
||||
* 纯函数 ⇒ 不需要组合界面、不需要设备,就能把**整批**(0 到 [ShareRules.MAX_IMAGES] - 1)逐条穷举。
|
||||
*/
|
||||
class ShareSendDeclarationTest {
|
||||
|
||||
@Test
|
||||
fun `share never declares on any message of the batch`() {
|
||||
(0 until ShareRules.MAX_IMAGES).forEach { index ->
|
||||
assertFalse(
|
||||
"分享的第 $index 条不许声明(声明就会打断同一批里的上一条)",
|
||||
ShareRules.declaresSendPreference(fromShare = true, index = index),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `share stays silent even past the image limit`() {
|
||||
// 超限那张本来就不发(见 truncatedImagesNotice),但判据对它也不许翻成 true:
|
||||
// 一旦有人把上限放宽而忘了这条,越界位置不该成为唯一声明的那一条。
|
||||
(ShareRules.MAX_IMAGES..ShareRules.MAX_IMAGES + 3).forEach { index ->
|
||||
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = index))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the typed send declares on its first message only`() {
|
||||
assertTrue(
|
||||
"手打的第一条仍按「她忙时发消息」偏好投递",
|
||||
ShareRules.declaresSendPreference(fromShare = false, index = 0),
|
||||
)
|
||||
(1 until ShareRules.MAX_IMAGES).forEach { index ->
|
||||
assertFalse(
|
||||
"手打的第 $index 条声明就是在打断自己刚起的上一轮",
|
||||
ShareRules.declaresSendPreference(fromShare = false, index = index),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single message batch is the same rule`() {
|
||||
// 只有一条时两种入口的差别正是全部差别:手打声明、分享不声明。
|
||||
assertTrue(ShareRules.declaresSendPreference(fromShare = false, index = 0))
|
||||
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
|
||||
/**
|
||||
* 接线:`ChatViewModel.emitPlannedMessages` 逐条投递时喂给 [ShareRules.declaresSendPreference] 的
|
||||
* 那两个入参(`fromShare` 与 `index`)。
|
||||
*
|
||||
* ## 判据在 `ShareSendDeclarationTest`,这里钉的是「喂进去的是什么」
|
||||
* 判据本身(`!fromShare && index == 0`)已被 `ShareSendDeclarationTest` 在裸整数上穷举过。本测试补的
|
||||
* 是另一半:VM 那个 `forEachIndexed` 的 `index` **是 [ImageSendPlan.messages] 里的位置**,不是选图
|
||||
* 顺序、也不是 `imagesToSend` 的下标。两者在超限截断时会不一样——`images` 给了 7 张而 `messages` 只有
|
||||
* [ShareRules.MAX_IMAGES] 条,若哪天把声明算在「原始选图位置」上,就会有一条落在这次根本不发的消息上。
|
||||
*
|
||||
* ## 为什么不是「直接调 VM」
|
||||
* `ChatViewModel` 要一个 [top.yeij.cyrene.app.AppContainer],本项目的单元测试没有 fake container /
|
||||
* fake SessionsApi 这套基建(登记在 `05` 的遗留里),所以 VM 里的编排穿不过去。这里退一步钉住**它喂进去
|
||||
* 的形状**;调用点自身靠结构守:`fromShare` 是必填参数(没有默认值)、判据只有 [ShareRules] 一处。
|
||||
*/
|
||||
class ShareDeclarationWiringTest {
|
||||
|
||||
private fun images(n: Int): List<String> = (1..n).map { "content://img$it" }
|
||||
|
||||
/** VM 里那一段:`plan.messages.forEachIndexed { index, _ -> …declaresSendPreference(fromShare, index) }`。 */
|
||||
private fun declarations(fromShare: Boolean, plan: ImageSendPlan): List<Boolean> =
|
||||
plan.messages.indices.map { index -> ShareRules.declaresSendPreference(fromShare, index) }
|
||||
|
||||
@Test
|
||||
fun `a share batch declares nothing at any position of the plan`() {
|
||||
// 0 = 只有正文的分享(也走这条路,同样不声明);1..MAX_IMAGES 逐档穷举。
|
||||
(0..ShareRules.MAX_IMAGES).forEach { n ->
|
||||
val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList())
|
||||
assertEquals("N=$n 的切分至少一条消息", n.coerceAtLeast(1), plan.messages.size)
|
||||
assertFalse(
|
||||
"分享 N=$n 的任何一条都不许声明(声明就会打断同一批里的上一条)",
|
||||
declarations(fromShare = true, plan).any { it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the typed send declares on the first planned message only`() {
|
||||
(0..ShareRules.MAX_IMAGES).forEach { n ->
|
||||
val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList())
|
||||
assertEquals(
|
||||
"手打 N=$n:只有第一条按「她忙时发消息」偏好投递",
|
||||
List(plan.messages.size) { index -> index == 0 },
|
||||
declarations(fromShare = false, plan),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated images never shift the declaration onto a message outside the batch`() {
|
||||
val plan = imageSendPlan(
|
||||
text = "看这个",
|
||||
images = images(ShareRules.MAX_IMAGES + 3),
|
||||
documents = emptyList(),
|
||||
)
|
||||
// 超限那几张本来就不发(见 truncatedImagesNotice),声明只按真正发出去的条数算。
|
||||
assertEquals(ShareRules.MAX_IMAGES, plan.messages.size)
|
||||
assertEquals(3, plan.truncatedImages)
|
||||
|
||||
assertEquals(ShareRules.MAX_IMAGES, declarations(fromShare = true, plan).size)
|
||||
assertFalse(declarations(fromShare = true, plan).any { it })
|
||||
assertEquals(
|
||||
List(ShareRules.MAX_IMAGES) { index -> index == 0 },
|
||||
declarations(fromShare = false, plan),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.domain.chat.TTS_CODE_NO_CHARACTER
|
||||
import top.yeij.cyrene.domain.chat.TTS_CODE_PERSONA_UNMAPPED
|
||||
import top.yeij.cyrene.domain.chat.TTS_NO_CHARACTER_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.TTS_PERSONA_UNMAPPED_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.TTS_SOFT_FAILURE_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
|
||||
/**
|
||||
* 朗读那一格([ChatUiState.ttsClip])的**状态机**,纯函数部分。
|
||||
*
|
||||
* VM 本身拖不进 JVM 测试(它要 `AppContainer`、`viewModelScope`,工程里也没有 coroutines-test),
|
||||
* 所以这条路上能钉、也最该钉的判据都被抽成了纯函数:放行与否在 [beginTtsSpeak],落位在
|
||||
* [applyTtsOutcome],这一下点击是「停」还是「念」在 [ttsSpeakIntent],离开这一屏时作废在
|
||||
* [dropPendingTtsAutoPlay]。六件事各自一组:
|
||||
*
|
||||
* 1. **防抖**:同一条正在飞时再点一次必须被忽略(否则每点一次就是一次合成请求);
|
||||
* 2. **重放 / 重试放行**:已经落好的是「再放一遍」、失败过的是「用户重试」,都不算在飞;
|
||||
* 3. **过期结果丢弃**:结果回来时这一格已经换人了,一个字节都不许写回去 ——
|
||||
* 否则会出现「点了 B,界面在 A 下面报错」;
|
||||
* 4. **失败不抬横幅**:朗读失败只落在这一格里,`error` 必须原样是 null
|
||||
* (那是发送失败 / 队列满的通道,抬上去会盖住真错误);
|
||||
* 5. **播放中再点 = 停**(P1-1):判据只认那条唯一的播放通道,不认 `pending`;
|
||||
* 6. **离开这一屏就作废还没到手的自动起播**(P2-1),而且结果晚到不许把它抬回真。
|
||||
*/
|
||||
class TtsSpeakStateTest {
|
||||
|
||||
private val messageId = "m-1"
|
||||
private val clipId = ttsClipIdOf(messageId)
|
||||
private val audio = File("tts_m-1.wav")
|
||||
|
||||
private fun server(code: String, status: Int = 500) =
|
||||
CyreneException(CyreneError.Server(code, "boom", status))
|
||||
|
||||
// —— 放行与否 ——
|
||||
|
||||
@Test
|
||||
fun `a click on an idle state starts a pending clip`() {
|
||||
val begun = ChatUiState().beginTtsSpeak(messageId, clipId)
|
||||
|
||||
assertNotNull("空闲时点朗读必须放行", begun)
|
||||
val clip = begun!!.ttsClip!!
|
||||
assertEquals(messageId, clip.messageId)
|
||||
assertEquals(clipId, clip.clipId)
|
||||
assertNull("还没结果", clip.file)
|
||||
assertNull("还没失败", clip.failure)
|
||||
assertTrue("这一格必须是 pending(图标据此画成正在准备)", clip.pending)
|
||||
assertTrue("字节到手那一刻要起播一次", clip.autoPlay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second click while the request is in flight is ignored`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
assertNull("同一条正在飞时再点一次必须被忽略", pending.beginTtsSpeak(messageId, clipId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a click after the bytes landed is a replay, not a duplicate`() {
|
||||
val played = ChatUiState().applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
val replay = played.beginTtsSpeak(messageId, clipId)
|
||||
|
||||
assertNotNull("已经落好的是「再放一遍」,不是重复请求", replay)
|
||||
assertNull("重放要重新等一次结果(缓存命中不会真发请求)", replay!!.ttsClip!!.file)
|
||||
assertTrue("再放一遍也必须重新起播", replay.ttsClip.autoPlay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a click after a failure is the user retrying`() {
|
||||
val failed = ChatUiState().applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline")))
|
||||
|
||||
val retry = failed.beginTtsSpeak(messageId, clipId)
|
||||
|
||||
assertNotNull("失败过的那一条必须放行(用户再点一次就是那次重试)", retry)
|
||||
assertNull("重试把上一次的文案清掉,否则「准备中」下面还挂着上一句失败", retry!!.ttsClip!!.failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking another message replaces the single slot`() {
|
||||
val other = "m-2"
|
||||
val pendingA = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val pendingB = pendingA.beginTtsSpeak(other, ttsClipIdOf(other))!!
|
||||
|
||||
assertEquals("这一格只有一份:换人就整格替换", ttsClipIdOf(other), pendingB.ttsClip!!.clipId)
|
||||
assertEquals(other, pendingB.ttsClip.messageId)
|
||||
}
|
||||
|
||||
// —— 结果落位 ——
|
||||
|
||||
@Test
|
||||
fun `success lands the file and keeps autoplay armed`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
val clip = done.ttsClip!!
|
||||
|
||||
assertSame(audio, clip.file)
|
||||
assertNull(clip.failure)
|
||||
assertFalse("播放交给那条唯一的通道,这一格不需要知道走到哪了", clip.retryable)
|
||||
assertTrue("渲染层的起播闸门只看这一位:置假就永远不响", clip.autoPlay)
|
||||
assertFalse("有文件就不再是 pending", clip.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a result for a message the user has already left is discarded`() {
|
||||
val other = "m-2"
|
||||
val pendingB = ChatUiState().beginTtsSpeak(other, ttsClipIdOf(other))!!
|
||||
|
||||
// A 的结果晚到了:这一格已经是 B。
|
||||
assertSame(
|
||||
"过期的成功结果不许写回去",
|
||||
pendingB,
|
||||
pendingB.applyTtsOutcome(messageId, clipId, Result.success(audio)),
|
||||
)
|
||||
assertSame(
|
||||
"过期的失败结果更不许写回去(否则会点了 B、在 A 下面报错)",
|
||||
pendingB,
|
||||
pendingB.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a result arriving after the slot was cleared is discarded`() {
|
||||
val cleared = ChatUiState()
|
||||
|
||||
assertSame(cleared, cleared.applyTtsOutcome(messageId, clipId, Result.success(audio)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unconfigured failure names what is missing and says nothing about retrying`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val unmapped = pending.applyTtsOutcome(
|
||||
messageId,
|
||||
clipId,
|
||||
Result.failure(server(TTS_CODE_PERSONA_UNMAPPED)),
|
||||
).ttsClip!!
|
||||
val noCharacter = pending.applyTtsOutcome(
|
||||
messageId,
|
||||
clipId,
|
||||
Result.failure(server(TTS_CODE_NO_CHARACTER)),
|
||||
).ttsClip!!
|
||||
|
||||
assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, unmapped.failure)
|
||||
assertEquals(TTS_NO_CHARACTER_MESSAGE, noCharacter.failure)
|
||||
assertFalse("配置缺失这一档重试永远不会成功", unmapped.retryable)
|
||||
assertFalse(noCharacter.retryable)
|
||||
assertNull("失败就是失败:不许留着上一次的文件", unmapped.file)
|
||||
assertFalse("失败没有可起播的东西", unmapped.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient failure stays soft and retryable`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val offline = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))).ttsClip!!
|
||||
val expired = pending.applyTtsOutcome(
|
||||
messageId,
|
||||
clipId,
|
||||
Result.failure(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"))),
|
||||
).ttsClip!!
|
||||
|
||||
assertEquals(TTS_SOFT_FAILURE_MESSAGE, offline.failure)
|
||||
assertTrue(offline.retryable)
|
||||
// 令牌过期续期后还能用 ⇒ 也是柔和档,不许说成「配置没配好」。
|
||||
assertEquals(TTS_SOFT_FAILURE_MESSAGE, expired.failure)
|
||||
assertTrue(expired.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neither outcome raises the page banner`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val failed = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline")))
|
||||
val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
// 朗读是一次附带的小动作:它失败不该盖住真正的发送失败 / 队列满提示。
|
||||
assertNull("失败只挂在这条消息下面,不抬成横幅", failed.error)
|
||||
assertNull(done.error)
|
||||
}
|
||||
|
||||
// —— 这一下点击是「停」还是「念」(P1-1)——
|
||||
//
|
||||
// 缺陷现场:图标在播放中画的是暂停符、读屏名字是「停止朗读」,可点击直通 `viewModel.speak`,
|
||||
// 而 VM 的防抖只认 `pending`(播放中文件早就到手 ⇒ 不是 pending ⇒ 放行),起播 effect 又会
|
||||
// 因为「已经在放同一条」跳过 `toggleClip` —— 于是「停」这一步根本不存在。
|
||||
|
||||
/** 已经落好文件的那一格(= 用户点过一次、字节到手了)。 */
|
||||
private fun landed(): TtsClip =
|
||||
ChatUiState()
|
||||
.beginTtsSpeak(messageId, clipId)!!
|
||||
.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
.ttsClip!!
|
||||
|
||||
@Test
|
||||
fun `clicking the clip that is already playing is a stop`() {
|
||||
val clip = landed()
|
||||
|
||||
val intent = ttsSpeakIntent(VoicePlaybackState.Playing(clipId), messageId, clip)
|
||||
|
||||
assertEquals("播放中再点一次必须是「停」,否则停止朗读是空操作", TtsSpeakIntent.Stop, intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking while the channel is still loading this clip is a stop too`() {
|
||||
val clip = landed()
|
||||
|
||||
// 文件已在手、通道正把它交给 MediaPlayer:这一下的意思同样是「别放了」。
|
||||
assertEquals(
|
||||
TtsSpeakIntent.Stop,
|
||||
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clip that is not the one playing is a new speak`() {
|
||||
val clip = landed()
|
||||
|
||||
// 正在响的是这条消息的**语音副本**(键 = messageId),不是它的朗读(键 = tts:messageId)。
|
||||
assertEquals(
|
||||
"响的是别的键 ⇒ 这一下是念(并且会停掉那一条,见 VoicePlayer.toggleClip)",
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Playing(messageId), messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an idle channel with the file in hand is a replay`() {
|
||||
val clip = landed()
|
||||
|
||||
assertEquals(
|
||||
"放完 / 停掉之后再点同一条是「再放一遍」",
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Idle, messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a channel loading this clip without the file yet is not a stop`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!.ttsClip!!
|
||||
|
||||
// 文件还没到手:交给 VM 的 pending 分支去防抖,而不是在这一层把一次没起播的载入停掉。
|
||||
assertEquals(
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, pending),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure on the channel is a retry rather than a stop`() {
|
||||
val clip = landed()
|
||||
|
||||
assertEquals(
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Failed(clipId, "boom"), messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 离开这一屏就作废还没到手的自动起播(P2-1)——
|
||||
|
||||
@Test
|
||||
fun `leaving the screen disarms an autoplay that has not landed yet`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val left = pending.dropPendingTtsAutoPlay()
|
||||
|
||||
assertFalse("人走了,结果回来时不该突然响", left.ttsClip!!.autoPlay)
|
||||
assertEquals("作废只动闸门,请求照旧在飞", clipId, left.ttsClip.clipId)
|
||||
assertTrue(left.ttsClip.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a result landing after the screen was left does not rearm autoplay`() {
|
||||
val left = ChatUiState().beginTtsSpeak(messageId, clipId)!!.dropPendingTtsAutoPlay()
|
||||
|
||||
val done = left.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
// 关键回归:结果落地时把 `autoPlay` 抬回真的话,切回来照样会响(等于没修)。
|
||||
assertFalse("作废过的那一次,结果晚到也不许把闸门抬回真", done.ttsClip!!.autoPlay)
|
||||
assertSame(audio, done.ttsClip.file)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving the screen never interrupts a clip whose bytes already landed`() {
|
||||
val landed = ChatUiState()
|
||||
.beginTtsSpeak(messageId, clipId)!!
|
||||
.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
assertSame("文件已在手 ⇒ 闸门要么跑过了、要么正在起播:一个字节都不许动", landed, landed.dropPendingTtsAutoPlay())
|
||||
val empty = ChatUiState()
|
||||
assertSame("空格子同理", empty, empty.dropPendingTtsAutoPlay())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.repository.MessageIncrement
|
||||
|
||||
/**
|
||||
* W77 的判据层:非激活会话的静默刷新从「整表重拉 `GET /sessions`」换成「`GET /messages?after=`」
|
||||
* 之后,**什么时候问得了增量、问回来的数怎么折进列表**。
|
||||
*
|
||||
* 与 `InactiveSessionPullTest` 同一套理由:这三件都是纯函数,穷举得了,不用在 JVM 上摆一台假服务器
|
||||
* (那条路由 `MessageIncrementRepositoryTest` 从线上证据那一侧验)。VM 里剩下的只有「拼装」——
|
||||
* 取游标、逐个发请求、回落 —— 每一步的分支判据都在下面。
|
||||
*/
|
||||
class SessionIncrementTest {
|
||||
|
||||
private fun item(
|
||||
id: String = "web",
|
||||
lastMessageAt: Long? = null,
|
||||
unreadCount: Int = 0,
|
||||
lastMessage: String? = null,
|
||||
) = SessionItem(
|
||||
id = id,
|
||||
title = "标题",
|
||||
isMain = id == "web",
|
||||
pinned = false,
|
||||
lastMessage = lastMessage,
|
||||
lastMessageAt = lastMessageAt,
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
|
||||
private fun message(id: String, createdAt: Long, content: String = "内容") = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = "assistant",
|
||||
content = content,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun increment(
|
||||
messages: List<HistoryMessageDto>,
|
||||
cursor: Long = messages.lastOrNull()?.createdAt ?: 0L,
|
||||
complete: Boolean = true,
|
||||
) = MessageIncrement(messages = messages, cursor = cursor, complete = complete)
|
||||
|
||||
// —— 游标从哪来 ——
|
||||
|
||||
@Test
|
||||
fun `no row means no cursor`() {
|
||||
// 会话在别处新建 / 本端那份列表还没拉到它:不知道它看到哪儿了,只能回落整表。
|
||||
assertNull(incrementCursorFor(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row without a last message is not a cursor either`() {
|
||||
// 本端认为它一条消息都没有,却收到了它的落库广播 ⇒ 手里那份列表已经陈旧到不可信。
|
||||
assertNull(incrementCursorFor(item(lastMessageAt = null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cursor is the last message time already on the row`() {
|
||||
// 不另立账本:`after` 是严格大于,而 `lastMessageAt` 正是「已见的最新一条」。
|
||||
assertEquals(500L, incrementCursorFor(item(lastMessageAt = 500)))
|
||||
}
|
||||
|
||||
// —— 一批 id → 一批游标 ——
|
||||
|
||||
@Test
|
||||
fun `an empty batch is nothing to do, not a failure`() {
|
||||
assertEquals(emptyMap<String, Long>(), incrementCursors(emptyList(), listOf(item(lastMessageAt = 1))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every id in the batch gets its own cursor`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100), item(id = "s2", lastMessageAt = 200))
|
||||
|
||||
assertEquals(mapOf("web" to 100L, "s2" to 200L), incrementCursors(listOf("web", "s2"), sessions))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown id rolls the whole batch back to the table`() {
|
||||
// 一半靠增量、一半靠整表得到的列表说不清是哪个时刻的 —— 未读面上最不能出这种「说不清」。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
assertNull(incrementCursors(listOf("web", "gone"), sessions))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one row without a cursor rolls the whole batch back too`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100), item(id = "s2", lastMessageAt = null))
|
||||
|
||||
assertNull(incrementCursors(listOf("web", "s2"), sessions))
|
||||
}
|
||||
|
||||
// —— 折进列表 ——
|
||||
|
||||
@Test
|
||||
fun `an empty increment changes nothing and keeps the same instance`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
assertSame(sessions, sessions.applyIncrement("web", from = 100, readAt = null, increment = increment(emptyList())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the target row moves and the cursor advances to the increment tail`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 100, unreadCount = 2, lastMessage = "旧预览"),
|
||||
item(id = "s2", lastMessageAt = 900, unreadCount = 7, lastMessage = "别人的预览"),
|
||||
)
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 300, "她<action>轻轻</action>说"))),
|
||||
)
|
||||
|
||||
val web = updated.first { it.id == "web" }
|
||||
// 已有的 unreadCount 直接相加是安全的:增量的每一条都严格新于 from,两者没有交集。
|
||||
assertEquals(4, web.unreadCount)
|
||||
assertEquals(300L, web.lastMessageAt)
|
||||
// 预览取最新一条,且剥掉 `<action>` 这类标记(与整表那条路同一个 `toPreviewText`)。
|
||||
assertEquals("她 轻轻 说", web.lastMessage)
|
||||
// 别的行一个字节都不动。
|
||||
assertEquals(sessions[1], updated.first { it.id == "s2" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `messages at or before the read cursor are already read on the server`() {
|
||||
// 与服务端**同源的只是过滤口径**(`count(created_at > read_at)`),不是基数:
|
||||
// `read_at` 是账号级单一游标,这台机器点开过它(游标推到「现在」)之后别的设备又发了消息,
|
||||
// 那段区间服务端算已读 —— 加进角标就多出一个红点,所以先按 readAt 筛。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = 250,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 250), message("m3", 300))),
|
||||
)
|
||||
|
||||
// 只有 300 那一条越过游标。
|
||||
assertEquals(1, updated.single().unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read elsewhere while offline over-counts until the next whole table`() {
|
||||
// 已接受的边界(见 `applyIncrement` 的 KDoc),钉在这里是为了它不会被当成「已修好」:
|
||||
// 底数 `unreadCount` 来自上一次整表刷新,而 `read_at` 是账号级的。断线期间他端把这个会话
|
||||
// 读掉了,那条 `session_read` 帧本地**永久收不到**(断线不补帧)⇒ 本端 readAt 还是 null,
|
||||
// 整批都进了角标(4 + 3 = 7),而服务端此刻算 0。漂移到下一次整表刷新自愈,这里不改行为。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100, unreadCount = 4))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 300), message("m3", 400))),
|
||||
)
|
||||
|
||||
assertEquals(7, updated.single().unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown read cursor counts everything in the increment`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 300))),
|
||||
)
|
||||
|
||||
assertEquals(2, updated.single().unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row already advanced by a more authoritative result is left alone`() {
|
||||
// 请求飞在天上时,一次整表刷新(或另一次增量)已经推进过这一行 ⇒ 那些条数已经被数过一遍了,
|
||||
// 再加一次就是重复计数。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 500, unreadCount = 3))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200))),
|
||||
)
|
||||
|
||||
assertSame(sessions, updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank newest message clears the preview the same way the table would`() {
|
||||
// 与 `toSessionItem`(整表那条路)同一个口径:内容折叠后为空 ⇒ 预览为 null,不留一句空话。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100, lastMessage = "旧预览"))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200, " "))),
|
||||
)
|
||||
|
||||
assertNull(updated.single().lastMessage)
|
||||
assertEquals(200L, updated.single().lastMessageAt)
|
||||
}
|
||||
}
|
||||
@@ -474,8 +474,11 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&docum
|
||||
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
|
||||
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
|
||||
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
|
||||
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。
|
||||
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。
|
||||
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
|
||||
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
|
||||
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
|
||||
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
|
||||
|
||||
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
|
||||
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
|
||||
|
||||
@@ -281,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
|
||||
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
|
||||
return "<redacted sha256:{}>".format(digest)
|
||||
|
||||
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
|
||||
BODY_SUMMARY_STRING_MAX = 200
|
||||
|
||||
def summarize_body(raw) -> dict | None:
|
||||
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
|
||||
|
||||
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
|
||||
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
|
||||
数组长度),只把超长字符串换成 `<N chars>`。
|
||||
|
||||
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
|
||||
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
|
||||
也不记大块内容。
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {"bytes": len(raw), "note": "not json"}
|
||||
return _summarize_value(parsed)
|
||||
|
||||
def _summarize_value(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _summarize_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_summarize_value(item) for item in value]
|
||||
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
|
||||
return "<{} chars>".format(len(value))
|
||||
return value
|
||||
|
||||
|
||||
class JsonlLog:
|
||||
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
|
||||
@@ -288,11 +319,17 @@ class JsonlLog:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.lock = threading.Lock()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
self.dir = os.path.dirname(path)
|
||||
os.makedirs(self.dir, exist_ok=True)
|
||||
|
||||
def append(self, record: dict) -> None:
|
||||
line = json.dumps(record, ensure_ascii=False)
|
||||
with self.lock:
|
||||
# 目录**每次写都补一次**:README 说「删掉 `out/` 即清空」,而人在服务跑着的时候删掉
|
||||
# 它就再也不会自己回来——之后每一条请求都会在写日志时抛 FileNotFoundError,
|
||||
# 表现成「假后端活着但什么都不回」(W76 实测踩到)。重建目录只是一次 stat,
|
||||
# 代价可以忽略;让它自己长回来,比要求人先停服务再删要可靠。
|
||||
os.makedirs(self.dir, exist_ok=True)
|
||||
with open(self.path, "a", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(line + "\n")
|
||||
|
||||
@@ -599,11 +636,15 @@ class Store:
|
||||
with self.lock:
|
||||
self.downstream_count += 1
|
||||
|
||||
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers) -> dict:
|
||||
"""把收到的请求头**记下来**(鉴权头不校验,但要看得见)。
|
||||
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
|
||||
"""把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
|
||||
|
||||
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
|
||||
也没有理由把它原文写进一个可能被 commit 的文件。
|
||||
|
||||
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
|
||||
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
|
||||
`out/http.jsonl` 里读得出来,不用再去猜。
|
||||
"""
|
||||
recorded_headers = {}
|
||||
for key, value in headers.items():
|
||||
@@ -623,6 +664,9 @@ class Store:
|
||||
"query": query,
|
||||
"headers": recorded_headers,
|
||||
}
|
||||
summary = summarize_body(body)
|
||||
if summary is not None:
|
||||
record["body"] = summary
|
||||
self.http_log.append(record)
|
||||
with self.lock:
|
||||
self.http_count += 1
|
||||
@@ -1046,17 +1090,38 @@ class Handler(BaseHTTPRequestHandler):
|
||||
query = parse_qs(parts.query, keep_blank_values=True)
|
||||
return parts.path, query
|
||||
|
||||
def handle_one_request(self):
|
||||
"""每个请求进来时**先把 body 缓存清掉**。
|
||||
|
||||
`protocol_version` 是 HTTP/1.1,同一条 keep-alive 连接上的多个请求由**同一个 Handler 实例**
|
||||
依次处理(`handle_one_request` 循环),所以 `_read_body` 的缓存必须按请求隔离。少了这一步,
|
||||
第 2 个请求会拿到第 1 个请求的 body(摘要记成上一份),而它自己那份**根本没人从 `rfile` 读走**
|
||||
——残留字节留在内核缓冲里,被下一个请求的请求行一并吃掉,`BaseHTTPRequestHandler` 于是回
|
||||
`501 Unsupported method ('{"sessionId":...}POST')`。客户端表现成「3 张图只投出去 2 张」,
|
||||
而真正的锅在这里。
|
||||
"""
|
||||
self._body_read = False
|
||||
self._body_cache = b""
|
||||
super().handle_one_request()
|
||||
|
||||
def _read_body(self):
|
||||
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
|
||||
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
|
||||
if getattr(self, "_body_read", False):
|
||||
return self._body_cache
|
||||
self._body_read = True
|
||||
self._body_cache = b""
|
||||
length = self.headers.get("Content-Length")
|
||||
if not length:
|
||||
return b""
|
||||
return self._body_cache
|
||||
try:
|
||||
size = int(length)
|
||||
except ValueError:
|
||||
return b""
|
||||
return self._body_cache
|
||||
if size <= 0:
|
||||
return b""
|
||||
return self.rfile.read(size) or b""
|
||||
return self._body_cache
|
||||
self._body_cache = self.rfile.read(size) or b""
|
||||
return self._body_cache
|
||||
|
||||
def _parse_json_body(self):
|
||||
raw = self._read_body()
|
||||
@@ -1103,7 +1168,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def _handle(self, method: str):
|
||||
path, query = self._split()
|
||||
self.store.record_http(self._label(), method, path, query, self.headers)
|
||||
# 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
|
||||
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
|
||||
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
|
||||
body = (
|
||||
self._read_body()
|
||||
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
|
||||
else b""
|
||||
)
|
||||
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
|
||||
try:
|
||||
if path == "/ws/chat":
|
||||
self._handle_ws(method, query)
|
||||
|
||||
Reference in new issue
Block a user