merge: W62 —— 语音消息接收侧(voice DTO + 语音气泡 + 播放;无副本不画播放键、时长缺席不估算、实时帧不等 voice)
This commit is contained in:
commit
9ced7444bd
20 files changed
+2330
-8
No files matched your search
@@ -15,6 +15,7 @@ import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.DocumentsApi
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
@@ -23,6 +24,7 @@ import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.DocumentRepository
|
||||
import top.yeij.cyrene.data.repository.MediaRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.MetaRepository
|
||||
import top.yeij.cyrene.data.repository.PersonaRepository
|
||||
@@ -186,6 +188,34 @@ class AppContainer(context: Context) {
|
||||
|
||||
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
|
||||
|
||||
private var cachedMediaApi: MediaApi? = null
|
||||
private var cachedMediaBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 语音副本端点(`GET /api/v1/media/{name}`,契约 §1.2 **U19**);与 [documentsApi] **同一套门**:
|
||||
* `owner|admin` **必须 Bearer**(与图片读侧「名字即凭据」刻意相反 —— 语音是私密内容)。
|
||||
*
|
||||
* 同样与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理):`voice.url` 是**服务端相对路径**,
|
||||
* 只有落在**同一台后端**上才取得到那份副本。
|
||||
*/
|
||||
val mediaApi: MediaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedMediaApi
|
||||
if (cached != null && cachedMediaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedMediaBaseUrl = baseUrl
|
||||
cachedMediaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val mediaRepository: MediaRepository get() = MediaRepository(mediaApi)
|
||||
|
||||
private var cachedAdminApi: AdminApi? = null
|
||||
private var cachedAdminBaseUrl: String? = null
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Streaming
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* 语音副本的字节读取(契约 **v0.79 已实现**,api-client.md §1.2 **U19**
|
||||
* `GET /api/v1/media/{name}`)。
|
||||
*
|
||||
* ## 鉴权:**必须** Bearer `owner|admin`
|
||||
* 与 `GET /api/v1/documents/{name}` 同一套门,与图片读侧「128-bit 名即凭据」**刻意相反**
|
||||
* ——语音是私密内容,那个 URL 不能直接贴进 `<audio src>`。Bearer 不在本接口上做任何事:
|
||||
* [ApiClientFactory.create] 按 **OkHttpClient** 挂 `AuthInterceptor`,只要本接口的实例是从
|
||||
* 那里建的(`AppContainer.mediaApi` 的写法),请求就自带令牌、并享受 401 单飞续期。
|
||||
*
|
||||
* ## 为什么参数是一整条 URL 而不是 `@Path name`
|
||||
* 契约把相对路径 `/api/v1/media/<name>` 定成**服务端唯一的派生字段**(`voice.url`),并明写
|
||||
* 客户端**绝不自己拼路径**。所以调用方把**服务端给的那条路径接到当前服务器地址上**
|
||||
* (纯函数 `domain.chat.voiceMediaUrl`)再传进来——本接口一个字都不拼,只发出去。
|
||||
*
|
||||
* @see MediaRepository
|
||||
*/
|
||||
interface MediaApi {
|
||||
|
||||
/**
|
||||
* 取一条语音副本的字节。
|
||||
*
|
||||
* 响应**不是信封**(就是音频本身),所以返回 [ResponseBody] 由仓库层读字节并负责关闭。
|
||||
* [Streaming] 让 Retrofit 不把响应体一次性读进内存。
|
||||
*
|
||||
* ⚠️ 服务端的 `416`(`Range` 不可满足)是**标准库直写**的 `text/plain` + `Content-Range`,
|
||||
* **不套** `{"error":{…}}`(契约 §0.3 / §4.5)——本端不发 `Range`、也不拿 `error.code`
|
||||
* 去解析它,`ApiErrorMapper` 对这种非信封响应的处理是「说不出就不编」。
|
||||
*/
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun download(@Url url: String): Response<ResponseBody>
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
|
||||
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
|
||||
*
|
||||
* ⚠️ [voice] 是这条「一处覆盖三条路」的**例外**:它只在四个 REST 读取面填充,实时帧与握手回放
|
||||
* 上缺席(理由写在 [voice] 的 KDoc 里)。
|
||||
*/
|
||||
@Serializable
|
||||
data class HistoryMessageDto(
|
||||
@@ -59,6 +62,26 @@ data class HistoryMessageDto(
|
||||
* 回放 / `channel_persisted`(本端不消费最后一条)。
|
||||
*/
|
||||
val turnId: String? = null,
|
||||
/**
|
||||
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]);
|
||||
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事,判据只有一个:这个键在不在。
|
||||
*
|
||||
* ## ⚠️ 覆盖率(契约 v0.79 如实):四个 REST 读取面才有,实时帧没有
|
||||
* **有** `voice` 的:`GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
|
||||
* `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与
|
||||
* `message_edited` 广播帧。
|
||||
*
|
||||
* **没有** `voice` 的:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放
|
||||
* ——入站语音的 `voice` 列是**落库之后**回填的,而落库帧在**落库那一刻**发出,天生拿不到
|
||||
* 这个值。所以**不要在实时帧上等 `voice`**(本端也刻意不为它加任何重试 / 轮询):实时到达的
|
||||
* 语音消息先按普通文本显示(转写文本本来就在 [content] 里),**等下一次拉取自动升级**成语音气泡。
|
||||
* 这是设计,不是缺陷。
|
||||
*
|
||||
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
|
||||
* 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一
|
||||
* 诚实的表达(与 [documents] 回一条 `unreadable` 不同)。
|
||||
*/
|
||||
val voice: VoiceDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**,
|
||||
* `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。
|
||||
*
|
||||
* ## 🎤 判据 = 本键存在
|
||||
* `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。
|
||||
* 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]`
|
||||
* 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。
|
||||
* 判据落在一处:`domain.chat.isVoiceMessage`。
|
||||
*
|
||||
* ## 三态 `status`(闭集)
|
||||
* `transcribed`(正文即转写文本)/ `empty`(转写成功但没识别出文字)/ `failed`(没拿到转写文本
|
||||
* ——**正文是服务端给的那行中文诚实提示**)。客户端**按 `status` 分支,绝不按正文文案分支**;
|
||||
* 设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
|
||||
* 无字节一律只进服务端日志),所以本端**不猜**细分。
|
||||
*
|
||||
* ## `name` / `mime` / `size` / `url` 四者同生共死
|
||||
* 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。
|
||||
* 反过来说:**四个键没凑齐就不要画播放控件**——不能给一个点下去必然失败的按钮。
|
||||
* 判据落在一处:`domain.chat.voiceAudio`。一个真实的无副本例子是微信(`weixin-ilink`):
|
||||
* 平台自带转写就**不白下载那份音频**,于是 `source` 是 `platform` 而四个键全缺席
|
||||
* (**只有 🎤 标记**,不可回放)。
|
||||
*
|
||||
* ## `durationMs` 只有渠道如实给出时才有
|
||||
* 服务端「读不出来就省略键,**绝不估算**」。本端同理:**缺席就不显示时长**
|
||||
* (不拿 size / 码率反算,也不显示 `0:00` 冒充)。所以它是可空的,且 `<= 0` 一并当缺席
|
||||
* (见 `domain.chat.voiceDurationLabel`)。
|
||||
*
|
||||
* ## `url` 是**服务端相对路径**,客户端绝不自己拼
|
||||
* 取值形如 `/api/v1/media/<name>`,由服务端一处拼出(**唯一派生字段**)。
|
||||
* 客户端要取字节时把它接到**当前服务器地址**后面(`domain.chat.voiceMediaUrl`)——
|
||||
* 与图片那条「保留 path、替换 host」同一取向,但这里服务端给的就是相对路径,不需要换 host。
|
||||
*/
|
||||
@Serializable
|
||||
data class VoiceDto(
|
||||
/**
|
||||
* `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的
|
||||
* `voice_item.text`)。见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
|
||||
*/
|
||||
val source: String = "",
|
||||
/**
|
||||
* `"transcribed"` / `"empty"` / `"failed"`(闭集三态,见 `domain.chat.VoiceStatus`)。
|
||||
*
|
||||
* 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值
|
||||
* (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。
|
||||
*/
|
||||
val status: String = "",
|
||||
/** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */
|
||||
val name: String? = null,
|
||||
/** 副本的 MIME(按存储名扩展名映射)。与 [name] / [size] / [url] 同生共死。 */
|
||||
val mime: String? = null,
|
||||
/** 副本的字节数。与 [name] / [mime] / [url] 同生共死;**不用它反算时长**。 */
|
||||
val size: Long? = null,
|
||||
/** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */
|
||||
val durationMs: Int? = null,
|
||||
/** 副本的服务端相对路径(`/api/v1/media/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
|
||||
val url: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import top.yeij.cyrene.domain.chat.voiceMediaUrl
|
||||
import java.io.File
|
||||
|
||||
/** 本端一次读得下、也放得动的语音副本上限,超过就如实说「放不了」(见 [VoiceTooLargeException])。 */
|
||||
class VoiceTooLargeException : Exception("voice copy too large")
|
||||
|
||||
/** 服务端只给了相对路径,而本端目前拿不到一个能接上去的服务器地址(未登录 / 地址为空)。 */
|
||||
class VoiceUrlUnavailableException : Exception("voice media url unavailable")
|
||||
|
||||
/**
|
||||
* 语音副本的取字节层(`GET /api/v1/media/{name}` 的第一个调用方)。
|
||||
*
|
||||
* 与 [DocumentRepository] 同一个形状:响应不是信封(就是音频本身),所以不用 `apiCall`——
|
||||
* 成功即读字节,失败才走 [ApiErrorMapper](`404 media_not_found` / `401`)。
|
||||
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
|
||||
*
|
||||
* [url] 由调用方拼好(服务端给的**相对路径** + 当前服务器地址,纯函数
|
||||
* [voiceMediaUrl]):本层**不自己拼路径**——契约把那条路径定成服务端唯一的派生字段。
|
||||
* 因此这里也不做形态校验:拼不出可信地址的调用方根本不会走到这里([VoiceStore] 先判)。
|
||||
*/
|
||||
class MediaRepository(
|
||||
private val api: MediaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取回一条语音副本的字节。
|
||||
*
|
||||
* 读到超过 [MAX_VOICE_BYTES] 就判 [VoiceTooLargeException]:语音副本本就不该大,
|
||||
* 而「一次读进内存」这条路上必须有一把尺子(与文档 / 图片同一个做法)。
|
||||
* **0 字节不算成功**——那会让下一次播放「读缓存命中」一份什么都没有的文件。
|
||||
*/
|
||||
suspend fun download(url: String): Result<ByteArray> = withContext(dispatcher) {
|
||||
try {
|
||||
val response = api.download(url)
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> body.use { stream ->
|
||||
val bytes = readBounded(stream.byteStream(), MAX_VOICE_BYTES)
|
||||
when {
|
||||
bytes == null -> Result.failure(VoiceTooLargeException())
|
||||
bytes.isEmpty() -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.success(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 2xx 却没有响应体:与 apiCall 同一口径,判解析失败而不是「拿到了空文件」。
|
||||
response.isSuccessful -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.failure(
|
||||
CyreneException(ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* 上限取附件那一把尺子(20 MB):语音副本远小于它,而这里要防的只是「一次读进内存」
|
||||
* 这条路上没有上界。**不新造一个数字**——多一个自定义上限就多一处会漂移的口径。
|
||||
*/
|
||||
val MAX_VOICE_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音副本的「取字节 + 落盘缓存」层(播放前那一步的**唯一**取数入口)。
|
||||
*
|
||||
* ## 缓存:`<cacheDir>/media/`,复用 [DocumentCache] 的三条规则
|
||||
* - **名字规则 / 命中判定 / 原子落盘**全部复用 [DocumentCache](同一条 `isSafeName`、
|
||||
* `isHit`(非空才算命中)、`write`(先写 `.part` 再改名))——只在缓存根下换一个目录名。
|
||||
* - **特意不落进 `documents/`**:那个目录是 `FileProvider`(`res/xml/file_paths.xml`)**唯一**
|
||||
* 暴露出去的根,「用其它应用打开 / 保存到下载目录」两条出口都从它取文件。语音本片只做
|
||||
* 收 / 放,不提供「交给别的 App」这条出口,所以**不把暴露面扩大一个字节**。
|
||||
* - 目录名是 `media`,与 `voice.url` 里的 `/api/v1/media/` 同源,便于排查时对得上号。
|
||||
*
|
||||
* ## 为什么缓存(而不是每次现下)
|
||||
* 服务端副本是**内容寻址**的(名字由内容决定)+ `Cache-Control: private, max-age=86400,
|
||||
* immutable` ⇒ 同一个名字的字节永不改变,「文件在且非空」就是永远有效的命中。重复播放因此
|
||||
* **一个请求都不发**,离线也能重听刚听过的那条。代价是缓存目录里多占几十 KB~几百 KB 一份,
|
||||
* 而它在 `cacheDir` 下:系统需要空间时可以回收,本端被回收之后下次播放重新下载(**不是**错误)。
|
||||
*
|
||||
* ## 它不做什么
|
||||
* 不拼路径([voiceMediaUrl] 在调用前判,拿不到可信地址就直接失败)、不做形态校验、不猜 MIME、
|
||||
* **不拿 `size` 反算时长**(时长只认服务端给的 `durationMs`)。
|
||||
*/
|
||||
class VoiceStore(
|
||||
/**
|
||||
* 取一条副本的字节(未命中缓存时调一次)。
|
||||
*
|
||||
* 收成一个 lambda 而不是直接收 [MediaRepository]:本类因此可以在单测里用临时目录 + 假下载器
|
||||
* 跑完,不必造一个 HTTP 替身(与 [ImageCacheStore] 的 `download` 参数同一手法)。
|
||||
* 生产路径上它就是 `container.mediaRepository::download`。
|
||||
*/
|
||||
private val download: suspend (String) -> Result<ByteArray>,
|
||||
private val cacheDir: File,
|
||||
private val baseUrlProvider: () -> String,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取这条语音的字节并确保它已落进缓存;返回**可直接交给 `MediaPlayer` 的那份文件**。
|
||||
*
|
||||
* 顺序是刻意的:**先拼地址、再查缓存**。地址拼不出来(未登录 / 地址为空)就如实失败,
|
||||
* 绝不因为「本地碰巧缓存过」而放行——那会让一条本端根本没有服务器归属的语音照旧能放,
|
||||
* 而调用方拿不到任何异常信号。
|
||||
*/
|
||||
suspend fun load(audio: VoiceAudio): Result<File> = withContext(dispatcher) {
|
||||
val url = voiceMediaUrl(audio.url, baseUrlProvider())
|
||||
?: return@withContext Result.failure(VoiceUrlUnavailableException())
|
||||
// ⚠️ 刻意**不用** `DocumentCache.fileFor`:那个函数的缓存根写死成 `documents/`
|
||||
// (FileProvider 那一层),传别的目录进去会拼成 `<cacheDir>/media/documents/<name>`。
|
||||
// 这里只用它的**名字判据**,路径自己拼。
|
||||
if (!DocumentCache.isSafeName(audio.name)) {
|
||||
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回别的名字。
|
||||
return@withContext Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
}
|
||||
val file = File(directory(cacheDir), audio.name)
|
||||
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
|
||||
|
||||
val bytes = download(url).getOrElse { return@withContext Result.failure(it) }
|
||||
try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(file)
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——**绝不**退回「把字节直接喂给
|
||||
// 播放器」那条路:那需要另一条落盘位置,而缓存目录写不进去时多半写得进的地方也没有。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 缓存根下的目录名(与 `voice.url` 里的 `/api/v1/media/` 同源,便于对号)。 */
|
||||
const val DIRECTORY_NAME: String = "media"
|
||||
|
||||
/** 语音副本的缓存目录:`<cacheDir>/media`。 */
|
||||
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceTooLargeException
|
||||
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
|
||||
|
||||
/**
|
||||
* `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得;
|
||||
* `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`)。
|
||||
*/
|
||||
object VoiceSource {
|
||||
|
||||
const val ASR: String = "asr"
|
||||
|
||||
const val PLATFORM: String = "platform"
|
||||
}
|
||||
|
||||
/**
|
||||
* `voice.status` 的闭集(契约 §4.4,**三态**)。
|
||||
*
|
||||
* 与 [DocumentStatus] 同一条纪律:**只认闭集里的取值,认不出的不猜**——但两者收尾的方式不同。
|
||||
* 文档的未知 `status` 要**原样显示**(那是给用户看的结论),语音的未知 `status` 只是
|
||||
* 「本端不多说一句话」(正文由服务端给,见 [voiceNote])。
|
||||
*/
|
||||
object VoiceStatus {
|
||||
|
||||
/** 正文**就是**转写文本。 */
|
||||
const val TRANSCRIBED: String = "transcribed"
|
||||
|
||||
/** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */
|
||||
const val EMPTY: String = "empty"
|
||||
|
||||
/**
|
||||
* 没拿到转写文本 ⇒ **正文是服务端给的那行中文诚实提示**,照常显示它。
|
||||
*
|
||||
* 设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节
|
||||
* 一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
|
||||
*/
|
||||
const val FAILED: String = "failed"
|
||||
}
|
||||
|
||||
/**
|
||||
* 「转写成功但没识别出文字」时本端补的那一句。
|
||||
*
|
||||
* 为什么这句是**本端的**而不是服务端的原话:这一态的语义由契约定义(§4.4 的三态之一),而
|
||||
* `empty` 的正文按定义就是空的——不补一句的话,一条「微信语音、没有音频副本、也没转出文字」
|
||||
* 的消息在屏幕上**只剩一个「语音」标签**,看的人无从知道发生了什么。这不是替服务端改文案
|
||||
* (那句话说的是**这一态的语义**,不是失败原因),也**不覆盖**任何正文。
|
||||
*/
|
||||
const val VOICE_EMPTY_NOTE: String = "没有识别出文字"
|
||||
|
||||
/**
|
||||
* 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。
|
||||
*
|
||||
* 与取字节失败([voicePlaybackFailureMessage])分开:两者要用户做的事不一样——字节没拿到是
|
||||
* 「网络 / 过期 / 服务端没有这份副本」,可以再点一次;这一段是「字节在手上,本机解不开」
|
||||
* (格式不支持、文件损坏),再点一次多半还是同样的结果。
|
||||
*/
|
||||
const val VOICE_UNPLAYABLE_MESSAGE: String = "这段语音本机播不了"
|
||||
|
||||
/**
|
||||
* 一条语音的**可播放副本**:四个键(`name` / `mime` / `size` / `url`)齐全时才构造得出来。
|
||||
*
|
||||
* 存在的理由是把「能不能播」这件事**收成一个类型**,而不是让 UI / 播放器各自去测四个 `!= null`:
|
||||
* 拿到 [VoiceAudio] 就等于「有副本、可以画播放控件、可以发请求」;拿不到就**一个播放控件都不画**
|
||||
* (不能给一个点下去必然失败的按钮)。
|
||||
*
|
||||
* @property durationMs 从 [VoiceDto] 原样带过来;`null` = 服务端没给,**不显示时长**。
|
||||
*/
|
||||
data class VoiceAudio(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
val url: String,
|
||||
val durationMs: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 🎤 **判据 = `voice` 键存在**(契约 §4.4)。
|
||||
*
|
||||
* 只有一个条件,而且**永远只有这一个**:`content` 里有没有 `[语音]` 这类字样与本判据无关
|
||||
* (QQ 的占位标记自 v0.67 起不再下发,别人也可以自己打出一模一样的字)。
|
||||
*/
|
||||
fun isVoiceMessage(message: HistoryMessageDto): Boolean = message.voice != null
|
||||
|
||||
/**
|
||||
* 这条语音的**可播放副本**;四个键没凑齐(或名字 / MIME / URL 是空白)就回 `null`。
|
||||
*
|
||||
* 服务端保证「四者同生共死」,本端仍然逐项检查:**这条判据的下游是「给不给播放控件」**,
|
||||
* 而契约里那份保证一旦被某个部署打破,唯一的后果就是用户点到一个必然失败的按钮
|
||||
* ——宁可少画一个控件,也不要画一个假的。
|
||||
*
|
||||
* `size` 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西
|
||||
* (尤其**不反算时长**,见 [voiceDurationLabel])。
|
||||
*/
|
||||
fun voiceAudio(voice: VoiceDto?): VoiceAudio? {
|
||||
if (voice == null) return null
|
||||
val name = voice.name?.takeIf { it.isNotBlank() } ?: return null
|
||||
val mime = voice.mime?.takeIf { it.isNotBlank() } ?: return null
|
||||
val url = voice.url?.takeIf { it.isNotBlank() } ?: return null
|
||||
val size = voice.size ?: return null
|
||||
return VoiceAudio(name = name, mime = mime, size = size, url = url, durationMs = voice.durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 时长文案(纯函数):`m:ss`(如 `0:03` / `1:05`)。
|
||||
*
|
||||
* **缺席就不显示**是契约的硬要求(服务端「读不出来就省略键,绝不估算」,本端同理):
|
||||
* - `null` ⇒ 回 `null`(调用方因此**一个字符都不画**,更不会出现 `0:00` 那种冒充);
|
||||
* - `<= 0` ⇒ 也回 `null`:`0` 毫秒的语音不存在,把服务端的一个异常取值显示成 `0:00`
|
||||
* 就是拿一个编出来的数字冒充事实;
|
||||
* - **不足一秒**(`1..999`)⇒ 也回 `null`:`m:ss` 只能把它显示成 `0:00`,而 `0:00` 与
|
||||
* 「没有时长」在屏幕上是同一个样子——与其显示一个看起来像没读到的时间,不如什么都不显示
|
||||
* (渠道也不会真的发这种语音);
|
||||
* - 不设上限:语音消息不会长到需要小时制,超过 99 分钟就照实显示 `124:03`(**不折算**成
|
||||
* 「2:04:03」——那是另一种自己发明的表达)。
|
||||
*/
|
||||
fun voiceDurationLabel(durationMs: Int?): String? {
|
||||
if (durationMs == null || durationMs < 1_000) return null
|
||||
val totalSeconds = durationMs / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一条在**正文之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
|
||||
*
|
||||
* 只对 `status == "empty"` 且**正文为空**时成立:
|
||||
* - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补);
|
||||
* - `failed`:正文**就是**服务端那行诚实提示,替它加一句等于替它改文案——**不做**;
|
||||
* - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾**
|
||||
* (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话);
|
||||
* - 未知 `status`(含空串):**不猜**,与 `failed` 一样只显示正文。
|
||||
*/
|
||||
fun voiceNote(status: String, content: String): String? =
|
||||
if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null
|
||||
|
||||
/**
|
||||
* 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。
|
||||
*
|
||||
* 契约明写 `url` 是唯一的派生字段(`/api/v1/media/<name>`),**客户端绝不自己拼路径**——所以
|
||||
* 本函数只做「保留 path、换成当前 host」这一件事,与 [ImageHostRebase] 同一取向
|
||||
* (图片那条要换的是**别人给过的绝对地址**里的 host,这里要接的是**服务端给的相对路径**)。
|
||||
*
|
||||
* 判据取最小容忍度,任何一条不满足就回 `null`(调用方据此如实报「暂时播不了」,**绝不**
|
||||
* 退回「自己拼一条 `/api/v1/media/<name>`」——那正是契约禁止的那件事):
|
||||
* - [url] 必须以 `/api/v1/media/` 开头,且后面**恰好**是一段名字:非空、不是 `.` / `..`、
|
||||
* 不含 `/` `\` `%` `?` `#` 与 NUL;
|
||||
* - [baseUrl] 必须能解出 `scheme://authority`(空串 / 没有 host 都解不出)。
|
||||
*
|
||||
* 用的是 [baseUrl] 的 scheme + authority(所以 `http → https`、端口变更都跟着走),**不用**
|
||||
* baseUrl 里的路径:这条地址挂在根路径上,部署形态本来就没有路径前缀这一说。
|
||||
*/
|
||||
fun voiceMediaUrl(url: String, baseUrl: String): String? {
|
||||
val name = url.removePrefix(MEDIA_PREFIX)
|
||||
if (!url.startsWith(MEDIA_PREFIX) || !isSafeMediaName(name)) return null
|
||||
val origin = originOf(baseUrl) ?: return null
|
||||
return origin + url
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 `documentLoadFailureMessage` / `imageLoadFailureMessage` 同一纪律:服务端能说清的用它的
|
||||
* 原话,本端能分类的分开说,**绝不把失败渲染成「静音」**——一条点了没反应的语音消息在用户眼里
|
||||
* 与「坏了」没有区别。
|
||||
*
|
||||
* 三档本地失败分开写,因为它们要用户做的事不一样:地址拼不出来 = 本端环境不对(重新登录 / 换
|
||||
* 服务器);太大 = 这条本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
|
||||
*/
|
||||
fun voicePlaybackFailureMessage(e: Throwable): String = when (e) {
|
||||
is VoiceUrlUnavailableException -> "当前服务器地址不可用,暂时播不了这条语音"
|
||||
is VoiceTooLargeException -> "这条语音太大,本机放不了"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时播不了这条语音"
|
||||
is CyreneException -> when {
|
||||
// ⚠️ 刻意**不**走 `toDocumentFailure()`:那个映射只认文档那四个码(`document_not_found`),
|
||||
// `media_not_found` 在它眼里是「未知码」⇒ 会把「服务端上已经没有这条语音了」说成
|
||||
// 「语音加载失败,media not found」。这一档本端说得准,就说得准一点。
|
||||
e.error.isMediaNotFound() -> "服务端上已经没有这条语音了"
|
||||
else -> "语音加载失败," + e.error.userMessage
|
||||
}
|
||||
|
||||
else -> "语音加载失败,请稍后重试"
|
||||
}
|
||||
|
||||
/** 服务端对「名字非法 / 穿越 / 不存在」统一回这一个码(U19:**不区分**,不给探测面)。 */
|
||||
private const val MEDIA_NOT_FOUND_CODE = "media_not_found"
|
||||
|
||||
private fun CyreneError.isMediaNotFound(): Boolean =
|
||||
this is CyreneError.Server && code == MEDIA_NOT_FOUND_CODE
|
||||
|
||||
/** 服务端相对路径的前缀(契约 §1.2 U19)。 */
|
||||
private const val MEDIA_PREFIX = "/api/v1/media/"
|
||||
|
||||
/**
|
||||
* 媒体名是不是「恰好一段」:不许有分隔符、百分号编码、查询串,也不许是 `.` / `..`。
|
||||
*
|
||||
* 与 `ImageHostRebase` 的同名判据同一条理由:这个字符串来自**别人给的**数据,
|
||||
* 看不懂的形态就不该被我们拼进一条请求地址。
|
||||
*/
|
||||
private fun isSafeMediaName(name: String): Boolean {
|
||||
if (name.isEmpty() || name == "." || name == "..") return false
|
||||
return name.none { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }
|
||||
}
|
||||
|
||||
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
|
||||
private fun originOf(baseUrl: String): String? {
|
||||
val rest = when {
|
||||
baseUrl.startsWith("http://", ignoreCase = true) -> baseUrl.substring("http://".length)
|
||||
baseUrl.startsWith("https://", ignoreCase = true) -> baseUrl.substring("https://".length)
|
||||
else -> return null
|
||||
}
|
||||
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, end)
|
||||
if (authority.isBlank()) return null
|
||||
return baseUrl.take(baseUrl.length - rest.length) + authority
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 语音播放的**状态**(纯数据,可单测)。
|
||||
*
|
||||
* ## 为什么要有它,而不是让每个气泡各自 `remember { mutableStateOf(false) }`
|
||||
* 同一时刻**只能有一条语音在响**(一台设备一个喇叭,两条语音叠着放一定是 bug),而
|
||||
* 「哪一条在响」是**跨气泡**的事实:用户点了另一条,前一条必须停下来并释放解码器。
|
||||
* 于是这份状态由**一条播放通道**持有,气泡只是它的视图。四个态刻意只有四个:
|
||||
*
|
||||
* - [Idle]:什么都没在放(也是「停下来之后」唯一的终点);
|
||||
* - [Loading]:字节在取(首次播放要先下载;第二次起走本地缓存,一闪而过);
|
||||
* - [Playing]:真的在响(`MediaPlayer` 已经 `start()` 过,还没走完 / 还没被停);
|
||||
* - [Failed]:**这一次尝试**没成功,[Failed.message] 是给用户看的那句话。
|
||||
*
|
||||
* [Failed] 携带的是**这条消息**的失败(不是全局横幅):用户点的是这一条,出问题的是这一条。
|
||||
* 它**不粘**:再点一次就从 [Loading] 重新开始(见 [pressed])。
|
||||
*
|
||||
* ## 每条转换都带 `messageId`,因为回调会迟到
|
||||
* `MediaPlayer` 的回调(prepared / completion / error)与取字节的协程都可能**在用户已经改主意
|
||||
* 之后**才到达。所以每一次转换都要**比对目标**:过期的回调**一个都不许**改状态
|
||||
* ([prepared] / [failed] / [finished] 的同一条纪律)。这不是防御性编程的洁癖——
|
||||
* 少了它就会出现「播放器已经换了、界面还显示上一条在响」。
|
||||
*/
|
||||
sealed interface VoicePlaybackState {
|
||||
|
||||
/** 没有被这条通道占着的消息(初始态,也是停 / 出错 / 放完之后的终点)。 */
|
||||
data object Idle : VoicePlaybackState
|
||||
|
||||
/** 正在取这一条的字节(下载 / 读缓存)。 */
|
||||
data class Loading(val messageId: String) : VoicePlaybackState
|
||||
|
||||
/** 这一条正在响。 */
|
||||
data class Playing(val messageId: String) : VoicePlaybackState
|
||||
|
||||
/** 这一条的上一次尝试没成;[message] 是给用户看的那句话(见 `voicePlaybackFailureMessage`)。 */
|
||||
data class Failed(val messageId: String, val message: String) : VoicePlaybackState
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条消息此刻正被这条播放通道占着吗([VoicePlaybackState.Loading] 或
|
||||
* [VoicePlaybackState.Playing])。
|
||||
*
|
||||
* [VoicePlaybackState.Failed]**不算**:它表示这条已经不在放,用户再点一下就是「重试」。
|
||||
*/
|
||||
fun VoicePlaybackState.isActiveFor(messageId: String): Boolean = when (this) {
|
||||
is VoicePlaybackState.Loading -> this.messageId == messageId
|
||||
is VoicePlaybackState.Playing -> this.messageId == messageId
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** 这条消息**正在响**吗(只认 [VoicePlaybackState.Playing],加载中不算)。 */
|
||||
fun VoicePlaybackState.isPlayingFor(messageId: String): Boolean =
|
||||
this is VoicePlaybackState.Playing && messageId == this.messageId
|
||||
|
||||
/**
|
||||
* 这条消息**正在取字节**吗(只认 [VoicePlaybackState.Loading])。
|
||||
*
|
||||
* 与 [isPlayingFor] 分开是必须的:「正在加载」与「正在响」对用户是两件事,界面上那句
|
||||
* 「加载中…」只能在**真的在加载**时出现——混着用会让一条 30 秒的语音在整段播放期间都写着
|
||||
* 「加载中…」。
|
||||
*/
|
||||
fun VoicePlaybackState.isLoadingFor(messageId: String): Boolean =
|
||||
this is VoicePlaybackState.Loading && messageId == this.messageId
|
||||
|
||||
/**
|
||||
* 这条消息上一次尝试失败时那句提示;不是它 / 没失败过就回 `null`(气泡据此决定画不画那一行)。
|
||||
*/
|
||||
fun VoicePlaybackState.failureOf(messageId: String): String? =
|
||||
(this as? VoicePlaybackState.Failed)?.takeIf { it.messageId == messageId }?.message
|
||||
|
||||
/**
|
||||
* 用户点了这条消息的播放 / 暂停。
|
||||
*
|
||||
* - 点的是**正在放 / 正在加载的那一条** ⇒ 回 [VoicePlaybackState.Idle](= 停,调用方据此停下
|
||||
* 并释放;「再点一下取消」对加载中同样成立——用户不该被一个慢下载困住);
|
||||
* - 点的是别的(或上一次失败的)那一条 ⇒ 换成它的 [VoicePlaybackState.Loading]。
|
||||
*
|
||||
* 有一条**调用方职责**不在状态里:进入 [VoicePlaybackState.Loading] 之前必须把上一条真的停掉
|
||||
* (见 `ui.chat.VoicePlayer.toggle`)。纯函数只说「状态应该变成什么」。
|
||||
*/
|
||||
fun VoicePlaybackState.pressed(messageId: String): VoicePlaybackState =
|
||||
if (isActiveFor(messageId)) VoicePlaybackState.Idle else VoicePlaybackState.Loading(messageId)
|
||||
|
||||
/**
|
||||
* 字节到手、`MediaPlayer` 已经 `start()` 过:`Loading(同一条)` ⇒ `Playing(同一条)`。
|
||||
*
|
||||
* 目标不对(用户已经点了别的 / 点了停)或当前不是 `Loading` 时**原样返回**——迟到的回调不许
|
||||
* 把界面从「另一条在响」拽回来。
|
||||
*/
|
||||
fun VoicePlaybackState.prepared(messageId: String): VoicePlaybackState =
|
||||
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
|
||||
VoicePlaybackState.Playing(messageId)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字节 / 起播失败:`Loading(同一条)` ⇒ `Failed(同一条, message)`;其余情况原样返回。
|
||||
*
|
||||
* 只从 [VoicePlaybackState.Loading] 出发是刻意的:**正在响的那一条**不会被一个迟到的失败
|
||||
* 回调说成失败(`Playing` 阶段的解码错误走 [finished] 那条路,由播放器自己先停下再报)。
|
||||
*/
|
||||
fun VoicePlaybackState.failed(messageId: String, message: String): VoicePlaybackState =
|
||||
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
|
||||
VoicePlaybackState.Failed(messageId, message)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/** 放完了:`Playing(同一条)` ⇒ [VoicePlaybackState.Idle](播完就回初始外观,不留痕迹)。 */
|
||||
fun VoicePlaybackState.finished(messageId: String): VoicePlaybackState =
|
||||
if (isPlayingFor(messageId)) VoicePlaybackState.Idle else this
|
||||
|
||||
/**
|
||||
* 停下来(用户点了停 / 换了另一条 / 离开会话 / 组件销毁)⇒ [VoicePlaybackState.Idle]。
|
||||
*
|
||||
* **任何态都收**,包括 [VoicePlaybackState.Failed]:失败那句话的生命周期与这一次尝试绑定,
|
||||
* 换了目标或页面重进就不该再挂着。
|
||||
*/
|
||||
val VoicePlaybackState.silenced: VoicePlaybackState get() = VoicePlaybackState.Idle
|
||||
@@ -63,6 +63,7 @@ import top.yeij.cyrene.core.prefs.accountFingerprint
|
||||
import top.yeij.cyrene.core.session.ChatSessionSlots
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheStore
|
||||
import top.yeij.cyrene.data.repository.ImageCacheStore
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||
@@ -386,6 +387,22 @@ fun ChatScreen(
|
||||
}
|
||||
var imageTarget by remember { mutableStateOf<ImageSource?>(null) }
|
||||
|
||||
// —— 语音播放(接收侧)——
|
||||
// 取字节的缓存层:服务端相对路径(`voice.url`)接到**当前服务器地址**上再发请求,字节落在
|
||||
// `cacheDir/media/`(按存储名内容寻址 ⇒ 重复播放一个请求都不发)。`baseUrlProvider` 每次现读:
|
||||
// 换服务器必然重进本组合,而 `mediaApi` 那条客户端也按 baseUrl 重建(见 AppContainer)。
|
||||
val voiceStore = remember(container) {
|
||||
VoiceStore(
|
||||
download = container.mediaRepository::download,
|
||||
cacheDir = container.appContext.cacheDir,
|
||||
baseUrlProvider = { container.authManager.currentBaseUrl },
|
||||
)
|
||||
}
|
||||
// 一条播放通道归**这一个会话**:换会话 = 换实例 = 上一条立刻停并释放;离开聊天页(本组合被
|
||||
// 丢弃)时 `onDispose` 释放(MediaPlayer 不 release 会一直占着解码器,见 VoicePlayer 的 KDoc)。
|
||||
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
|
||||
val voicePlayback by voicePlayer.state.collectAsState()
|
||||
|
||||
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
|
||||
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
|
||||
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
|
||||
@@ -615,6 +632,9 @@ fun ChatScreen(
|
||||
// 自己的外观,①②⑤ 与今天逐字相同。判定是纯函数,接线层不自己猜。
|
||||
phase = state.phaseOf(item.message),
|
||||
legBadge = state.legBadgeForMessage(item.message),
|
||||
// 语音播放(接收侧):状态来自**这一个会话**的那条播放通道,
|
||||
// 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。
|
||||
voicePlayback = voicePlayback,
|
||||
onDocumentClick = { ref ->
|
||||
previewNotice = null
|
||||
previewTarget = DocumentTarget.of(ref)
|
||||
@@ -629,6 +649,7 @@ fun ChatScreen(
|
||||
// 各自用同一份产品口径的纯函数判定。
|
||||
onEdit = viewModel::startEdit,
|
||||
onDelete = viewModel::startDelete,
|
||||
onVoiceToggle = voicePlayer::toggle,
|
||||
)
|
||||
|
||||
is ChatDisplayItem.Pending -> PendingBubble(
|
||||
|
||||
@@ -38,6 +38,9 @@ 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.TurnPhase
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.isVoiceMessage
|
||||
import top.yeij.cyrene.domain.chat.voiceNote
|
||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
@@ -82,6 +85,10 @@ private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传
|
||||
*
|
||||
* 判据刻意**窄**,只有 `user` 消息才可能成立:
|
||||
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
|
||||
* - **语音消息一律不成立**(`voice != null`):语音的转写文本可能为空(`status = empty`,
|
||||
* 或 `transcribed` 却什么都没有),但那条消息**不是什么都没有** —— 它有一个语音形态
|
||||
* (`VoiceMessageRow` 会把它画出来)。少了这一条,一条「微信语音、没认出发音、也没有音频副本」
|
||||
* 的消息会被扣上「内容没有被传过来」这顶帽子,而那句话是**假的**;
|
||||
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
|
||||
* 顺手改会波及所有助手气泡,不在本次范围内。
|
||||
*
|
||||
@@ -94,7 +101,9 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
message.content.isBlank() &&
|
||||
message.image.isNullOrBlank() &&
|
||||
message.documents.isEmpty() &&
|
||||
message.thinking.isNullOrBlank()
|
||||
message.thinking.isNullOrBlank() &&
|
||||
// 判据只有一处(domain.chat.isVoiceMessage):绝不在这里另写一个 `voice != null`。
|
||||
!isVoiceMessage(message)
|
||||
|
||||
/**
|
||||
* 一条落库消息渲染成一个或多个气泡:
|
||||
@@ -103,6 +112,9 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* - 其它 mode 原样整条渲染,不解析标签;
|
||||
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
|
||||
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
|
||||
* - `voice` 非空时(🎤 判据 = **这个键存在**,契约 §4.4)在正文之外多画一枚语音形态
|
||||
* ([VoiceMessageRow]:播放 / 暂停 + 时长,有副本时才有播放控件);正文照常渲染
|
||||
* ——转写文本 / 服务端那行诚实提示都在里面,一个字都不改;
|
||||
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
|
||||
* [ImageHostRebase](见 [imageBaseUrl]);
|
||||
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
|
||||
@@ -136,6 +148,10 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* 回复侧在 [StreamingBubble] 上挂一次,用户就能把它们配起来。
|
||||
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
|
||||
* @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。
|
||||
* @param voicePlayback 那条**播放通道**此刻的状态(`ui.chat.VoicePlayer` 的 `state`)。只有指出
|
||||
* 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认
|
||||
* [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。
|
||||
* @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
@@ -143,10 +159,12 @@ fun MessageBubble(
|
||||
imageBaseUrl: String = "",
|
||||
phase: TurnPhase = TurnPhase.Sent,
|
||||
legBadge: String? = null,
|
||||
voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle,
|
||||
onDocumentClick: (DocumentRefDto) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
onEdit: (HistoryMessageDto) -> Unit = {},
|
||||
onDelete: (HistoryMessageDto) -> Unit = {},
|
||||
onVoiceToggle: (HistoryMessageDto) -> Unit = {},
|
||||
) {
|
||||
val isUser = message.role == "user"
|
||||
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
|
||||
@@ -186,6 +204,19 @@ fun MessageBubble(
|
||||
)
|
||||
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
|
||||
?.let { ChatImage(it, onImageClick) }
|
||||
message.voice?.let { voice ->
|
||||
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
|
||||
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
|
||||
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
|
||||
VoiceMessageRow(
|
||||
voice = voice,
|
||||
messageId = message.id,
|
||||
playback = voicePlayback,
|
||||
note = voiceNote(voice.status, message.content),
|
||||
alignEnd = isUser,
|
||||
onToggle = { onVoiceToggle(message) },
|
||||
)
|
||||
}
|
||||
if (isContentlessUserMessage(message)) {
|
||||
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
|
||||
ContentlessNote(onLongPress = openMenu)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.failureOf
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.isLoadingFor
|
||||
import top.yeij.cyrene.domain.chat.voiceAudio
|
||||
import top.yeij.cyrene.domain.chat.voiceDurationLabel
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pause
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = `voice` 键存在),不是内容。 */
|
||||
const val VOICE_LABEL: String = "语音"
|
||||
|
||||
/** 加载中那一句(与 [VOICE_LABEL] 同一行,替换掉时长那一格)。 */
|
||||
private const val VOICE_LOADING_LABEL = "加载中…"
|
||||
|
||||
/** 播放控件那枚图标的读屏名字 —— 同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
|
||||
const val VOICE_PLAY_DESCRIPTION: String = "播放语音"
|
||||
|
||||
/** 正在放 / 加载中时那枚图标的读屏名字(点它 = 停)。 */
|
||||
const val VOICE_STOP_DESCRIPTION: String = "停止播放语音"
|
||||
|
||||
/** 图标尺寸:与消息角标那枚「⋯」同一档,整条语音形态因此不比一条角标高多少。 */
|
||||
private val VOICE_ICON_SIZE = 18.dp
|
||||
|
||||
/**
|
||||
* 一条语音消息在**转写文本之外**的那个「语音」形态:`[播放/暂停] 语音 · 时长`。
|
||||
*
|
||||
* ## 画什么由三条契约判据决定(都在这一个函数里,不散落)
|
||||
* - **🎤 判据 = `voice` 键存在**:本组件只有拿到 `voice` 才会被调用(调用点在 [MessageBubble]),
|
||||
* 所以这里不认识正文里有没有 `[语音]` 字样;
|
||||
* - **无副本 ⇒ 不画播放控件**([voiceAudio] 回 `null`):四个键(`name`/`mime`/`size`/`url`)
|
||||
* 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示
|
||||
* (时长与副本**无关**);
|
||||
* - **缺席就不显示时长**([voiceDurationLabel] 回 `null`):绝不显示 `0:00` 冒充,也不拿
|
||||
* `size` 反算。
|
||||
*
|
||||
* ## 三处文案各自归谁
|
||||
* - **正文**(转写 / 服务端的诚实提示)由 [MessageBubble] 照常渲染,本组件一个字符都不改、
|
||||
* 也不隐藏它;
|
||||
* - [note](`status = empty` 且正文为空时那句「没有识别出文字」)由调用方用纯函数
|
||||
* `voiceNote` 算好传进来 —— 本组件不判 `status`;
|
||||
* - [playback] 里这条消息的失败提示([failureOf])**只在失败时出现一行**,颜色用
|
||||
* `colorScheme.error`:可见但不夸张(不弹窗、不占整行、不挡住正文)。
|
||||
*
|
||||
* ## 图标从哪来(查过 AAR,不是猜的)
|
||||
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚(`Close` / `Search` / `SearchCleanup` /
|
||||
* `Sidebar` / `Check` / `ArrowRight` / `ArrowUpDown`),**没有播放 / 暂停字形**;但 **extended**
|
||||
* 包里有真的 `Play` / `Pause`(`javap` 确认过 `PlayKt.getPlay(MiuixIcons)`),而本模块本来就在用
|
||||
* extended(`MessageBubble.kt` 的 `MiuixIcons.More`)。所以这里用真的字形,**不**自绘几何图形:
|
||||
* 自绘一个三角形在扫读上并不比官方图标便宜,还要自己承担「像不像播放键」的风险。
|
||||
* 官方文档写 `MiuixIcons.Xxx` 的用法对人不对包,以 AAR 为准 —— 这两枚就是 extended 包里的。
|
||||
*
|
||||
* ## 点哪儿
|
||||
* **整枚可点**(不只是那 18dp 的图标):读屏与手指都不必瞄准;图标只是「这里能按」的可见凭据
|
||||
* (与 [LegBadge] 「看得出可点」同一条思路)。
|
||||
*
|
||||
* @param note 正文之外要多说的一句(没有就传 `null`)。
|
||||
* @param playback 这条通道的当前状态(由调用方用 [rememberVoicePlayer] 那一个实例驱动)。
|
||||
* @param onToggle 用户点了这枚语音:交给上层真正去取字节 / 放 / 停(本组件不发请求)。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceMessageRow(
|
||||
voice: VoiceDto,
|
||||
messageId: String,
|
||||
playback: VoicePlaybackState,
|
||||
note: String? = null,
|
||||
alignEnd: Boolean = false,
|
||||
onToggle: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val audio = voiceAudio(voice)
|
||||
val active = playback.isActiveFor(messageId)
|
||||
val loading = playback.isLoadingFor(messageId)
|
||||
val failure = playback.failureOf(messageId)
|
||||
val duration = voiceDurationLabel(voice.durationMs)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.then(
|
||||
if (audio == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier
|
||||
.clickable(onClick = onToggle)
|
||||
// 可点的那一层(整枚)自己报「点它会发生什么」;里面的图标因此只是
|
||||
// 一笔装饰(`contentDescription = null`),读屏不会读到第二个焦点。
|
||||
.semantics {
|
||||
contentDescription = if (active) VOICE_STOP_DESCRIPTION else VOICE_PLAY_DESCRIPTION
|
||||
}
|
||||
},
|
||||
)
|
||||
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (audio != null) {
|
||||
Icon(
|
||||
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.size(VOICE_ICON_SIZE)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = VOICE_LABEL,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
when {
|
||||
// 「现在在干什么」与「点它会怎样」分开说:图标回答后者,这一格回答前者。
|
||||
// ⚠️ 只有**真的在取字节**时才说「加载中…」:`active` 还包含「正在响」,
|
||||
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」。
|
||||
loading -> Text(
|
||||
text = VOICE_LOADING_LABEL,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
|
||||
duration != null -> Text(
|
||||
text = duration,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
note?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
failure?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.chat.VOICE_UNPLAYABLE_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.failed
|
||||
import top.yeij.cyrene.domain.chat.finished
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.prepared
|
||||
import top.yeij.cyrene.domain.chat.pressed
|
||||
import top.yeij.cyrene.domain.chat.silenced
|
||||
import top.yeij.cyrene.domain.chat.voiceAudio
|
||||
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 「同一时刻只有一条语音在响」的那条**播放通道**(接收侧:播放别人发来的语音)。
|
||||
*
|
||||
* ## 用什么放(不引任何新依赖)
|
||||
* 系统 **`android.media.MediaPlayer`**(平台 API,API 1 起就有)。选它而不是 ExoPlayer / Media3:
|
||||
* 项目纪律是加依赖要先问,而这里要的只是「放一段 ogg / wav / mp3 的本地文件」,平台的解码器够用。
|
||||
* 音源是**本地文件**([load] 的产物):字节由 `VoiceStore` 取一次、落在 `cacheDir/media/`,
|
||||
* 于是播放本身不联网、不随时间抖动,也**不必**把 Bearer 头塞进播放器(`MediaPlayer` 的头部参数
|
||||
* 不会走 401 续期那条路,那才是真的会出事的地方)。
|
||||
*
|
||||
* ## 生命周期:三处必须停下来并释放(`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路)
|
||||
* 1. **开始播另一条**:[toggle] 在任何新播放之前先 [stop](旧的那条停下并释放,含正在下载的
|
||||
* 那一次)——所以「一条没放完又点另一条」不会出现两条叠着响;
|
||||
* 2. **离开会话 / 换会话**:调用方用 [rememberVoicePlayer](它按 `sessionKey` 换实例并在
|
||||
* `onDispose` 里 [release]),换会话 = 换实例 = 旧的那条立刻停;
|
||||
* 3. **组件销毁**:同上,`onDispose` 是组合被丢弃时唯一可靠的时刻([release] 幂等,可重复调)。
|
||||
*
|
||||
* ⚠️ **本片没做的两件事(如实)**:
|
||||
* - **不申请音频焦点**(`AudioManager` 那套):需要 `Context` 与「焦点被抢走」的处理,属于
|
||||
* 「播放器该有的完整行为」,与本期「收得到、放得出来」不是一档。后果是主人自己在放音乐时
|
||||
* 语音会叠在上面;
|
||||
* - **App 退到后台不停播**(要接 `LocalLifecycleOwner` 需要 `lifecycle-runtime-compose`,
|
||||
* 本期不加依赖)。语音都很短,影响面是「切走那一两秒还在响」。
|
||||
*
|
||||
* ## 失败:可见、但不夸张
|
||||
* 两档失败都会进 [VoicePlaybackState.Failed](气泡据此把按钮变回「未播」并显示**一行**短提示):
|
||||
* 取字节失败(网络 / 404 / 地址拼不出来,文案来自 [voicePlaybackFailureMessage])与解码起播失败
|
||||
* ([VOICE_UNPLAYABLE_MESSAGE])。**绝不静默**——一条点了没反应的语音在用户眼里就是坏的。
|
||||
*
|
||||
* ## 不缓存字节(这一层)
|
||||
* 缓存是 [VoiceStore] 的事(按存储名落 `cacheDir/media/`,命中就不发请求)。本类只负责「拿到
|
||||
* 文件 → 放 → 放完/停 → 释放」,因此它不认识网络,也不认识缓存策略。
|
||||
*
|
||||
* @param scope 驱动的协程作用域(调用方给,通常是组合的 `rememberCoroutineScope`)。
|
||||
* @param load 取这条语音的**本地文件**(未命中缓存时内部会下载并落盘)。
|
||||
*/
|
||||
class VoicePlayer(
|
||||
private val scope: CoroutineScope,
|
||||
private val load: suspend (VoiceAudio) -> Result<File>,
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow<VoicePlaybackState>(VoicePlaybackState.Idle)
|
||||
|
||||
/** 这条通道此刻的状态(气泡与按钮都看它)。状态机本身是纯函数,见 `domain.chat.VoicePlayback`。 */
|
||||
val state: StateFlow<VoicePlaybackState> = _state.asStateFlow()
|
||||
|
||||
/** 正在取字节的那次协程;停下 / 换目标时一并取消(用户不该被一个慢下载困住)。 */
|
||||
private var loading: Job? = null
|
||||
/** 当前这一台 `MediaPlayer`(null = 没有)。回调里的身份比对都以它为准。 */
|
||||
private var player: MediaPlayer? = null
|
||||
|
||||
/**
|
||||
* 用户点了这条消息的播放 / 暂停。
|
||||
*
|
||||
* 判据全部走纯函数:拿不到 [VoiceAudio](**无副本** ⇒ 四个键没凑齐)就**什么都不做**
|
||||
* ——正常路径上那时气泡根本不画播放控件,这里是第二道闸(同一个判据,不在 UI 之外再猜一次)。
|
||||
*/
|
||||
fun toggle(message: HistoryMessageDto) {
|
||||
val audio = voiceAudio(message.voice) ?: return
|
||||
val id = message.id
|
||||
// 再点一下 = 停(加载中也算:那表示「别下了」)。
|
||||
if (_state.value.isActiveFor(id)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
stop()
|
||||
_state.value = _state.value.pressed(id)
|
||||
loading = scope.launch {
|
||||
load(audio).fold(
|
||||
onSuccess = { file ->
|
||||
// 这一帧还该不该放:用户可能已经点了停 / 点了别的(状态已不是 Loading(id))。
|
||||
if (_state.value.isActiveFor(id)) play(id, file)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.value = _state.value.failed(id, voicePlaybackFailureMessage(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
|
||||
fun stop() {
|
||||
loading?.cancel()
|
||||
loading = null
|
||||
releasePlayer()
|
||||
// ⚠️ `silenced` 而不是「只清 Playing」:失败那句话也要跟着走——换了目标 / 离开页面之后,
|
||||
// 屏幕上不该还挂着上一条的失败。
|
||||
_state.value = _state.value.silenced
|
||||
}
|
||||
|
||||
/** 组件销毁 / 离开会话时调(等价于 [stop],单列一个名字是为了让调用点的意图看得见)。 */
|
||||
fun release() = stop()
|
||||
|
||||
private fun play(messageId: String, file: File) {
|
||||
val mp = MediaPlayer()
|
||||
player = mp
|
||||
mp.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
mp.setOnPreparedListener { prepared ->
|
||||
// 身份比对:已经换掉 / 已经 release 的那个实例,回调一律不改状态。
|
||||
if (player !== prepared) return@setOnPreparedListener
|
||||
prepared.start()
|
||||
_state.value = _state.value.prepared(messageId)
|
||||
}
|
||||
mp.setOnCompletionListener { done ->
|
||||
if (player !== done) return@setOnCompletionListener
|
||||
releasePlayer()
|
||||
_state.value = _state.value.finished(messageId)
|
||||
}
|
||||
mp.setOnErrorListener { broken, _, _ ->
|
||||
if (player !== broken) return@setOnErrorListener true
|
||||
releasePlayer()
|
||||
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
|
||||
// true = 这次错误已经处理(不让它再走 onCompletion,也不弹系统默认处理)。
|
||||
true
|
||||
}
|
||||
try {
|
||||
mp.setDataSource(file.absolutePath)
|
||||
// prepareAsync 而不是 prepare:同步那一版会占着主线程等解码器,异步的失败同样会走到
|
||||
// onError(format 不支持 / 文件损坏 / 已被清空都是这一档)。
|
||||
mp.prepareAsync()
|
||||
} catch (_: Exception) {
|
||||
// setDataSource / prepareAsync 在状态不对、文件读不动时**同步抛**:如实报「播不了」。
|
||||
releasePlayer()
|
||||
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前那台播放器拆干净:先摘掉全部监听(免得 `stop()` / `release()` 触发的回调再改状态),
|
||||
* 再 `stop()`(未 prepare 完时它会抛 `IllegalStateException`,所以包在 `runCatching` 里),
|
||||
* 最后 `release()`。
|
||||
*/
|
||||
private fun releasePlayer() {
|
||||
val current = player ?: return
|
||||
player = null
|
||||
current.setOnPreparedListener(null)
|
||||
current.setOnCompletionListener(null)
|
||||
current.setOnErrorListener(null)
|
||||
runCatching { current.stop() }
|
||||
runCatching { current.release() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为**当前这段组合**(通常是一个会话的聊天页)记住一条播放通道,并在它被丢弃时释放。
|
||||
*
|
||||
* 两条纪律写在这里,因为它们是本类唯一的使用方式:
|
||||
* - **[sessionKey] 变了就换一条通道**:换会话 = 换通道 = 上一条立刻停(`remember` 换实例 →
|
||||
* `DisposableEffect` 的旧 effect 走 `onDispose` → [VoicePlayer.release]);
|
||||
* - **[load] 变化不重建通道**:它由 `rememberUpdatedState` 顶着(每次重组拿到最新那个 lambda),
|
||||
* 否则重组期间的一次实例更替会把正在放的那条停掉。
|
||||
*/
|
||||
@Composable
|
||||
fun rememberVoicePlayer(
|
||||
sessionKey: Any?,
|
||||
load: suspend (VoiceAudio) -> Result<File>,
|
||||
): VoicePlayer {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentLoad by rememberUpdatedState(load)
|
||||
val player = remember(sessionKey) { VoicePlayer(scope) { audio -> currentLoad(audio) } }
|
||||
DisposableEffect(player) {
|
||||
onDispose { player.release() }
|
||||
}
|
||||
return player
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.domain.chat.VoiceSource
|
||||
import top.yeij.cyrene.domain.chat.VoiceStatus
|
||||
|
||||
/**
|
||||
* `voice` 键的解码钉子(契约 **v0.67 登记、v0.79 已实现**,api-client.md §4.4 与 §1.2 U19)。
|
||||
*
|
||||
* 形状来源就是契约里那两处(示例 JSON + 语义段),**逐键手写**(没有 v0.79 的真实抓包);
|
||||
* 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。
|
||||
*
|
||||
* 要钉住的六件事:
|
||||
* 1. **七个键逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url`
|
||||
* —— 键名不许自己发明(形状错一个字母线上就是「不是语音」);
|
||||
* 2. **缺席与 `null` 是同一件事**:非语音消息整键缺席 ⇒ `null`(不是 `{}`,也不是别的哨兵);
|
||||
* 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉);
|
||||
* 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」);
|
||||
* 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢);
|
||||
* 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。
|
||||
*/
|
||||
class VoiceDtoTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
/** 契约 §4.4 的示例逐字(含四个副本键 + `durationMs`)。 */
|
||||
private val transcribed = """
|
||||
{
|
||||
"id": "msg-1",
|
||||
"sessionId": "web",
|
||||
"role": "user",
|
||||
"content": "明天几点出发",
|
||||
"createdAt": 1700000000000,
|
||||
"voice": {
|
||||
"source": "asr",
|
||||
"status": "transcribed",
|
||||
"name": "$name",
|
||||
"mime": "audio/ogg",
|
||||
"size": 12345,
|
||||
"durationMs": 3200,
|
||||
"url": "/api/v1/media/$name"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun decode(body: String) = json.decodeFromString(HistoryMessageDto.serializer(), body)
|
||||
|
||||
@Test
|
||||
fun `a transcribed voice decodes the seven keys verbatim`() {
|
||||
val voice = decode(transcribed).voice!!
|
||||
|
||||
assertEquals(VoiceSource.ASR, voice.source)
|
||||
assertEquals(VoiceStatus.TRANSCRIBED, voice.status)
|
||||
assertEquals(name, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals(12345L, voice.size)
|
||||
assertEquals(3200, voice.durationMs)
|
||||
// url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。
|
||||
assertEquals("/api/v1/media/$name", voice.url)
|
||||
assertFalse(voice.url!!.startsWith("http"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message without the voice key decodes to null, not to an empty object`() {
|
||||
val body = """
|
||||
{"id":"msg-2","sessionId":"web","role":"user","content":"在的","createdAt":1700000000001}
|
||||
""".trimIndent()
|
||||
|
||||
assertNull("🎤 判据 = 键存在:缺席必须是 null", decode(body).voice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null is the same thing as an absent key`() {
|
||||
// 契约说 `voice` 是**指针**、非语音消息整键缺席(不是 null);真收到 null 也按「没有」处理。
|
||||
val body = """
|
||||
{"id":"msg-3","sessionId":"web","role":"user","content":"在的","createdAt":1700000000002,
|
||||
"voice":null}
|
||||
""".trimIndent()
|
||||
|
||||
assertNull(decode(body).voice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice with no audio copy has all four copy keys absent`() {
|
||||
// 微信(weixin-ilink):平台自带转写就不白下载那份音频 ⇒ 只有 🎤 标记,四个键全缺席。
|
||||
// ⚠️ 时长与副本**无关**:渠道给了就留着(所以这条夹具带着 durationMs,而四个副本键照样全缺)。
|
||||
val body = """
|
||||
{"id":"msg-4","sessionId":"web","role":"user","content":"在忙吗","createdAt":1700000000003,
|
||||
"voice":{"source":"platform","status":"transcribed","durationMs":5000}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(VoiceSource.PLATFORM, voice.source)
|
||||
assertNull(voice.name)
|
||||
assertNull(voice.mime)
|
||||
assertNull(voice.size)
|
||||
assertNull(voice.url)
|
||||
assertEquals(5000, voice.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy keeps the four keys together including size zero and a missing duration`() {
|
||||
// durationMs 缺席(服务端读不出来时不估算)——**其余四个键照样齐全**。
|
||||
val body = """
|
||||
{"id":"msg-5","sessionId":"web","role":"user","content":"","createdAt":1700000000004,
|
||||
"voice":{"source":"asr","status":"empty","name":"$name","mime":"audio/ogg",
|
||||
"size":0,"url":"/api/v1/media/$name"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(name, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals("size 为 0 也照样解码(本端不拿它推导任何东西)", 0L, voice.size)
|
||||
assertEquals("/api/v1/media/$name", voice.url)
|
||||
assertNull("缺席 ≠ 0:必须是 null,才能让界面「一个字符都不画」", voice.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration reported by the channel is kept as is`() {
|
||||
val body = """
|
||||
{"id":"msg-6","sessionId":"web","role":"user","content":"","createdAt":1700000000005,
|
||||
"voice":{"source":"asr","status":"transcribed","durationMs":60000}}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(60_000, decode(body).voice!!.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the three statuses and both sources are kept verbatim, and an unknown one is not guessed`() {
|
||||
val cases = listOf(
|
||||
VoiceStatus.TRANSCRIBED to VoiceSource.ASR,
|
||||
VoiceStatus.EMPTY to VoiceSource.ASR,
|
||||
VoiceStatus.FAILED to VoiceSource.PLATFORM,
|
||||
// 认不出的取值:原样保留(本端不猜、不报错、不丢——显示层对它们与本端不认识时同形)。
|
||||
"future-status" to "future-source",
|
||||
)
|
||||
|
||||
cases.forEach { (status, source) ->
|
||||
val body = """
|
||||
{"id":"msg-7","sessionId":"web","role":"user","content":"x","createdAt":1700000000006,
|
||||
"voice":{"source":"$source","status":"$status"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(status, voice.status)
|
||||
assertEquals(source, voice.source)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown extra key inside voice does not break the message`() {
|
||||
val body = """
|
||||
{"id":"msg-8","sessionId":"web","role":"user","content":"x","createdAt":1700000000007,
|
||||
"voice":{"source":"asr","status":"transcribed","waveform":[1,2,3]}}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(VoiceStatus.TRANSCRIBED, decode(body).voice!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty voice object decodes as present`() {
|
||||
// 契约说不会下发 `{}`(「不是 {} 也不是 null」),真收到时本端只认「键存在」这一条判据
|
||||
// ⇒ 它算一条语音消息(四个副本键全缺 ⇒ 不画播放控件)。**刻意**在这里钉住这个选择:
|
||||
// 「键在不在」是契约给的唯一判据,本端不额外发明一条「对象够不够完整」的判据。
|
||||
val body = """
|
||||
{"id":"msg-9","sessionId":"web","role":"user","content":"","createdAt":1700000000008,
|
||||
"voice":{}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice
|
||||
assertTrue(voice != null)
|
||||
assertEquals("", voice!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a full voice keeps the seven key names`() {
|
||||
// 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(
|
||||
source = VoiceSource.ASR,
|
||||
status = VoiceStatus.TRANSCRIBED,
|
||||
name = name,
|
||||
mime = "audio/ogg",
|
||||
size = 12345,
|
||||
durationMs = 3200,
|
||||
url = "/api/v1/media/$name",
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(
|
||||
setOf("source", "status", "name", "mime", "size", "durationMs", "url"),
|
||||
encoded.keys,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a voice with no copy omits the nullable keys`() {
|
||||
// `explicitNulls = false`(与后端 `omitempty` 同一口径):无副本时那四个键**不写出来**,
|
||||
// 而不是写成 `null`——否则服务端与别的客户端会看到一个「有键但值是 null」的陌生形状。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(source = VoiceSource.PLATFORM, status = VoiceStatus.TRANSCRIBED),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(setOf("source", "status"), encoded.keys)
|
||||
assertEquals(VoiceSource.PLATFORM, encoded["source"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* `GET /api/v1/media/{name}` 的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带的 HTTP 服务端
|
||||
* (与 `DocumentRepositoryTest` / `ImageCacheTest` 同一手法,不引新依赖)。
|
||||
*
|
||||
* 形状与状态码来源:契约 **v0.79** 的 §1.2 **U19** 一行(`200` / `206` / `304` / `401` / `403` /
|
||||
* `404` / `416`;`Content-Type` 是音频类型;`404 media_not_found`)与 §4.5。
|
||||
* **没有真实抓包**:这里的响应是按契约逐条手写的。
|
||||
*
|
||||
* 要证明的四件事:
|
||||
* 1. 请求真的发到**服务端给的那条路径**(`/api/v1/media/<name>`;本端一个字都不拼);
|
||||
* 2. **必须带 `Authorization: Bearer`**(U19 是 `owner|admin` 端点:语音是私密内容);
|
||||
* 3. `200` 回的是**字节本身**(不是 JSON 信封),且一个字不改;
|
||||
* 4. `404 media_not_found` / `401` / 非信封错误体 / 0 字节都折成可分支的失败,
|
||||
* **0 字节不算成功**(那会让下一次播放「命中缓存」一份什么都没有的文件)。
|
||||
*/
|
||||
class MediaRepositoryTest {
|
||||
|
||||
private val backend = FakeMediaBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MediaRepository(
|
||||
ApiClientFactory.create(
|
||||
baseUrl = backend.baseUrl,
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
),
|
||||
)
|
||||
|
||||
/** 一段假的音频字节:这里只关心「字节原样过」,不关心它是不是真能解码。 */
|
||||
private val bytes = ByteArray(64) { it.toByte() }
|
||||
|
||||
@Test
|
||||
fun `a copy is fetched from the path the server gave, with a bearer token`() = runBlocking {
|
||||
backend.reply(200, bytes, "audio/ogg")
|
||||
|
||||
val fetched = repository().download(backend.mediaUrl(backend.name)).getOrThrow()
|
||||
|
||||
assertArrayEquals(bytes, fetched)
|
||||
val request = backend.lastRequest()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals(
|
||||
"请求路径必须**逐字**是服务端给的 voice.url(客户端绝不自己拼)",
|
||||
"/api/v1/media/${backend.name}",
|
||||
request.path,
|
||||
)
|
||||
assertEquals("语音是私密内容:U19 要求 owner|admin,必须带 Bearer", "Bearer $TOKEN", request.authorization)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing copy is reported as not found, not as an empty audio`() = runBlocking {
|
||||
backend.reply(
|
||||
404,
|
||||
"""{"error":{"code":"media_not_found","message":"media not found"}}""".toByteArray(),
|
||||
"application/json",
|
||||
)
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
// 非法名字 / 穿越 / 不存在服务端统一同码(不给探测面)⇒ 用户看到的就是本端那一句
|
||||
// 「服务端上已经没有这条语音了」(**不是**把 media not found 原样抖给用户)。
|
||||
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(error!!))
|
||||
assertNull(repository().download(backend.mediaUrl(backend.name)).getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non envelope error body is not parsed as an envelope`() = runBlocking {
|
||||
// 416(Range 不可满足)由 Go 标准库直写 text/plain + Content-Range,**不套** JSON 信封
|
||||
// (契约 §0.3 / §4.5)⇒ 本端不许拿 error.code 去解析它,也不能因此崩。
|
||||
backend.reply(416, "invalid range".toByteArray(), "text/plain")
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
val mapped = (error as CyreneException).error
|
||||
assertTrue(mapped is CyreneError.Server)
|
||||
assertEquals(
|
||||
"非信封响应不许编出一个 code,也不许炸",
|
||||
"",
|
||||
(mapped as CyreneError.Server).code,
|
||||
)
|
||||
assertEquals(416, mapped.httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unauthorized response is mapped as unauthorized`() = runBlocking {
|
||||
backend.reply(
|
||||
401,
|
||||
"""{"error":{"code":"unauthorized","message":"no token"}}""".toByteArray(),
|
||||
"application/json",
|
||||
)
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue((error as CyreneException).error is CyreneError.Unauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty two hundred is a failure, never a cached empty file`() = runBlocking {
|
||||
backend.reply(200, ByteArray(0), "audio/ogg")
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertEquals(
|
||||
"0 字节必须判失败(否则下一次播放会「命中」一份什么都没有的文件)",
|
||||
CyreneError.Local(CyreneError.LocalKind.Parse),
|
||||
(error as CyreneException).error,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that is not there fails and is classified as a local failure`() = runBlocking {
|
||||
// 对端不在听(换服务器 / adb reverse 掉了)。
|
||||
val dead = MediaRepository(
|
||||
ApiClientFactory.create(
|
||||
baseUrl = "http://127.0.0.1:1/",
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
),
|
||||
)
|
||||
|
||||
val error = dead.download("http://127.0.0.1:1/api/v1/media/${backend.name}").exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
// 只要求「是本地那一档」:具体是哪一种由系统决定,不逐条钉死。
|
||||
assertTrue((error as CyreneException).error is CyreneError.Local)
|
||||
}
|
||||
|
||||
private class FakeMediaBackend {
|
||||
|
||||
val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private val requests = CopyOnWriteArrayList<Request>()
|
||||
private var status = 200
|
||||
private var body = ByteArray(0)
|
||||
private var contentType = "audio/ogg"
|
||||
|
||||
private val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/") { exchange -> handle(exchange) }
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
fun mediaUrl(name: String): String = "${baseUrl}api/v1/media/$name"
|
||||
|
||||
fun reply(status: Int, body: ByteArray, contentType: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
this.contentType = contentType
|
||||
}
|
||||
|
||||
fun lastRequest(): Request = requests.lastOrNull() ?: error("服务端一个请求都没收到")
|
||||
|
||||
fun close() = server.stop(0)
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requests += Request(
|
||||
method = exchange.requestMethod,
|
||||
path = exchange.requestURI.path,
|
||||
authorization = exchange.requestHeaders.getFirst("Authorization"),
|
||||
)
|
||||
exchange.responseHeaders.add("Content-Type", contentType)
|
||||
// 空体用 -1(不是 0):0 会让标准库改用 chunked,那样「0 字节」这条用例测的就不是
|
||||
// 我们想测的那件事了(见 ImageCacheTest 的同名手法)。
|
||||
if (body.isEmpty()) {
|
||||
exchange.sendResponseHeaders(status, -1)
|
||||
} else {
|
||||
exchange.sendResponseHeaders(status, body.size.toLong())
|
||||
}
|
||||
exchange.responseBody.use { it.write(body) }
|
||||
}
|
||||
|
||||
data class Request(val method: String, val path: String, val authorization: String?)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "fake-token-42"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -86,6 +87,34 @@ class MessageMergeTest {
|
||||
assertEquals(listOf("m3"), idsOf(incoming))
|
||||
}
|
||||
|
||||
@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 胜出」改成「保留旧的」,语音气泡就永远不会出现。
|
||||
val withoutVoice = msg("m1", 100, content = "明天几点出发")
|
||||
val withVoice = withoutVoice.copy(
|
||||
voice = VoiceDto(
|
||||
source = "asr",
|
||||
status = "transcribed",
|
||||
name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
|
||||
mime = "audio/ogg",
|
||||
size = 12_345,
|
||||
durationMs = 3_200,
|
||||
url = "/api/v1/media/a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
|
||||
),
|
||||
)
|
||||
|
||||
val merged = mergeMessages(listOf(withoutVoice), listOf(withVoice))
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals("m1", merged.single().id)
|
||||
assertEquals("asr", merged.single().voice?.source)
|
||||
assertEquals(3_200, merged.single().voice?.durationMs)
|
||||
}
|
||||
|
||||
private fun msgIn(sessionId: String, id: String) = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* 「播放前取字节」这一层([VoiceStore]):地址怎么拼、缓存怎么命中、落哪儿、失败留不留残骸。
|
||||
*
|
||||
* 用真临时目录 + 一个假的下载器(`suspend (String) -> Result<ByteArray>`)跑完,不需要网络也不需要
|
||||
* Android([VoiceStore] 这一层的行为全是文件系统与地址拼接)。**真的走 HTTP** 那一半在
|
||||
* [MediaRepositoryTest](JDK 自带服务端 + 真 OkHttp/Retrofit)。
|
||||
*
|
||||
* 要钉住的六件事:
|
||||
* 1. **请求地址 = 当前服务器地址 + 服务端给的相对路径**(`voice.url` 逐字保留,本端不拼路径);
|
||||
* 2. **重复播放一个请求都不发**(内容寻址 ⇒ 命中永不失效);
|
||||
* 3. **落盘位置是 `<cacheDir>/media/`**,不在 `documents/`(那是 FileProvider 唯一暴露出去的根,
|
||||
* 本片不扩大暴露面);
|
||||
* 4. 名字不可信 / 地址拼不出来 ⇒ **如实失败且不发请求**(绝不退回「自己拼一条路径」);
|
||||
* 5. 下载失败**不留半截文件**(否则下一次会命中一份被截断的音频);
|
||||
* 6. 0 字节的残留当**未命中**(重新取),缓存目录写不进去则如实报缓存不可用。
|
||||
*/
|
||||
class VoiceStoreTest {
|
||||
|
||||
private lateinit var cacheRoot: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheRoot = Files.createTempDirectory("cyrene-voice").toFile()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cacheRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private val ogg = ByteArray(64) { it.toByte() }
|
||||
|
||||
private fun audio(name: String = this.name, url: String? = null) =
|
||||
VoiceAudio(
|
||||
name = name,
|
||||
mime = "audio/ogg",
|
||||
size = ogg.size.toLong(),
|
||||
url = url ?: "/api/v1/media/$name",
|
||||
durationMs = 3_200,
|
||||
)
|
||||
|
||||
private fun store(
|
||||
baseUrl: String = BASE_URL,
|
||||
download: suspend (String) -> Result<ByteArray>,
|
||||
) = VoiceStore(
|
||||
download = download,
|
||||
cacheDir = cacheRoot,
|
||||
baseUrlProvider = { baseUrl },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the request goes to the current server plus the path the server gave`() = runBlocking {
|
||||
val requested = mutableListOf<String>()
|
||||
val store = store(download = { url -> requested += url; Result.success(ogg) })
|
||||
|
||||
val file = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals(listOf("$BASE_URL/api/v1/media/$name"), requested)
|
||||
// 落盘位置:`<cacheDir>/media/<存储名>`(不是 documents/ —— 那是 FileProvider 那一层)。
|
||||
assertEquals(File(VoiceStore.directory(cacheRoot), name), file)
|
||||
assertEquals(ogg.size.toLong(), file.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing the same copy twice asks the server only once`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val first = store.load(audio()).getOrThrow()
|
||||
val second = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals("名字由内容决定 ⇒ 命中一个请求都不发", 1, downloads)
|
||||
assertEquals(first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
|
||||
val leftover = File(VoiceStore.directory(cacheRoot), name)
|
||||
leftover.parentFile!!.mkdirs()
|
||||
leftover.writeBytes(ByteArray(0))
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val file = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals(1, downloads)
|
||||
assertEquals(ogg.size.toLong(), file.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable server address fails without any request`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(baseUrl = "", download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val result = store.load(audio())
|
||||
|
||||
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
|
||||
assertEquals("拼不出可信地址就如实失败,绝不自己拼一条", 0, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a relative path that is not a media path never becomes a request`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val result = store.load(audio(url = "/api/v1/documents/$name"))
|
||||
|
||||
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
|
||||
assertEquals(0, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an untrustworthy name is never turned into a cache path`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
// 地址那一半照旧可信(服务端给的路径正常),不可信的是**存储名**自己:拼缓存路径的那一步
|
||||
// 必须挡住它(否则能爬出 `cacheDir/media/`)。
|
||||
val result = store.load(audio(name = "../../escape.ogg", url = "/api/v1/media/escape.ogg"))
|
||||
|
||||
assertEquals(
|
||||
CyreneError.Local(CyreneError.LocalKind.Parse),
|
||||
(result.exceptionOrNull() as CyreneException).error,
|
||||
)
|
||||
assertEquals(0, downloads)
|
||||
assertFalse("名字不可信时连缓存目录都不该被碰", VoiceStore.directory(cacheRoot).exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed download leaves no half file for the next play to believe`() = runBlocking {
|
||||
var attempted = 0
|
||||
val failing = store(download = {
|
||||
attempted++
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
|
||||
})
|
||||
|
||||
val first = failing.load(audio())
|
||||
|
||||
assertTrue(first.isFailure)
|
||||
val directory = VoiceStore.directory(cacheRoot)
|
||||
assertTrue(
|
||||
"失败不许留下任何文件(`.part` 临时文件也要清干净)",
|
||||
!directory.exists() || directory.listFiles()!!.isEmpty(),
|
||||
)
|
||||
|
||||
// 再点一次:真的重发(不是「命中一份空文件」)。
|
||||
val retry = store(download = { attempted++; Result.success(ogg) }).load(audio()).getOrThrow()
|
||||
assertEquals(2, attempted)
|
||||
assertEquals(ogg.size.toLong(), retry.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache directory that cannot be written is reported as such`() = runBlocking {
|
||||
// 拿一个**普通文件**当缓存目录:建目录必然失败。
|
||||
val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
|
||||
val store = VoiceStore(
|
||||
download = { Result.success(ogg) },
|
||||
cacheDir = blocked,
|
||||
baseUrlProvider = { BASE_URL },
|
||||
)
|
||||
|
||||
val result = store.load(audio())
|
||||
|
||||
assertTrue(result.exceptionOrNull() is DocumentCacheUnavailableException)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "http://192.168.1.9:8080"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceTooLargeException
|
||||
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
|
||||
import top.yeij.cyrene.ui.chat.isContentlessUserMessage
|
||||
|
||||
/**
|
||||
* 语音消息的**呈现判据**(纯函数,接收侧)。
|
||||
*
|
||||
* 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,五条口径逐条钉在这里:
|
||||
* 1. **🎤 判据 = `voice` 键存在**(`isVoiceMessage`)——**绝不**从正文里认媒体类型
|
||||
* (正文里可能出现用户自己打出的 `[语音]` 字样);
|
||||
* 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全);
|
||||
* 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充;
|
||||
* 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示;
|
||||
* 5. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**,
|
||||
* 绝不退回「自己拼一条 `/api/v1/media/<name>`」。
|
||||
*/
|
||||
class VoiceMessageTest {
|
||||
|
||||
private fun message(
|
||||
content: String = "",
|
||||
role: String = "user",
|
||||
voice: VoiceDto? = null,
|
||||
) = HistoryMessageDto(
|
||||
id = "msg-1",
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = content,
|
||||
voice = voice,
|
||||
createdAt = 1_791_088_571_785L,
|
||||
)
|
||||
|
||||
private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private fun voice(
|
||||
source: String = VoiceSource.ASR,
|
||||
status: String = VoiceStatus.TRANSCRIBED,
|
||||
name: String? = copyName,
|
||||
mime: String? = "audio/ogg",
|
||||
size: Long? = 12_345,
|
||||
durationMs: Int? = 3_200,
|
||||
url: String? = "/api/v1/media/$copyName",
|
||||
) = VoiceDto(
|
||||
source = source,
|
||||
status = status,
|
||||
name = name,
|
||||
mime = mime,
|
||||
size = size,
|
||||
durationMs = durationMs,
|
||||
url = url,
|
||||
)
|
||||
|
||||
// —— 1. 判据 = 键存在 ——
|
||||
|
||||
@Test
|
||||
fun `the voice key is the only criterion`() {
|
||||
assertTrue(isVoiceMessage(message(voice = voice())))
|
||||
assertFalse(isVoiceMessage(message(content = "在的")))
|
||||
// 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发,
|
||||
// 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。
|
||||
assertFalse(isVoiceMessage(message(content = "[语音]")))
|
||||
assertFalse(isVoiceMessage(message(content = "语音")))
|
||||
assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice())))
|
||||
}
|
||||
|
||||
// —— 2. 四者同生共死 ⇒ 能不能播 ——
|
||||
|
||||
@Test
|
||||
fun `an audio copy is playable only when all four keys are there`() {
|
||||
val audio = voiceAudio(voice())!!
|
||||
assertEquals(copyName, audio.name)
|
||||
assertEquals("audio/ogg", audio.mime)
|
||||
assertEquals(12_345L, audio.size)
|
||||
assertEquals("/api/v1/media/$copyName", audio.url)
|
||||
assertEquals(3_200, audio.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing key on any of the four makes the copy unplayable`() {
|
||||
// 四者同生共死:缺任何一个 ⇒ 一个播放控件都不画(不能给一个点下去必然失败的按钮)。
|
||||
assertNull(voiceAudio(voice(name = null)))
|
||||
assertNull(voiceAudio(voice(mime = null)))
|
||||
assertNull(voiceAudio(voice(size = null)))
|
||||
assertNull(voiceAudio(voice(url = null)))
|
||||
// 空白与 null 同等对待(空白串拼出来是一条没有名字的地址)。
|
||||
assertNull(voiceAudio(voice(name = " ")))
|
||||
assertNull(voiceAudio(voice(mime = "")))
|
||||
assertNull(voiceAudio(voice(url = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent voice key has no copy and a zero byte copy is still a copy`() {
|
||||
assertNull(voiceAudio(null))
|
||||
// size 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西。
|
||||
assertEquals(0L, voiceAudio(voice(size = 0))!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice without a copy keeps its duration`() {
|
||||
// 微信那条路:`source = platform`、四个副本键全缺 ⇒ 不可播,但时长照留(渠道给了)。
|
||||
val platform = voice(
|
||||
source = VoiceSource.PLATFORM,
|
||||
name = null,
|
||||
mime = null,
|
||||
size = null,
|
||||
url = null,
|
||||
durationMs = 5_000,
|
||||
)
|
||||
assertNull(voiceAudio(platform))
|
||||
assertEquals("0:05", voiceDurationLabel(platform.durationMs))
|
||||
}
|
||||
|
||||
// —— 3. 时长 ——
|
||||
|
||||
@Test
|
||||
fun `a missing duration shows nothing at all`() {
|
||||
// 「缺席就不显示时长」:不拿 size / 码率反算,也不显示 `0:00` 冒充。
|
||||
assertNull(voiceDurationLabel(null))
|
||||
assertNull(voiceDurationLabel(0))
|
||||
assertNull(voiceDurationLabel(-1))
|
||||
// 不足一秒时**向下取整会发生什么**:100ms 会变成 `0:00` —— 那正是「冒充」,
|
||||
// 所以这一条也当缺席(契约里不存在 0 秒的语音)。
|
||||
assertNull(voiceDurationLabel(100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration is formatted as minutes and seconds`() {
|
||||
assertEquals("0:01", voiceDurationLabel(1_000))
|
||||
assertEquals("0:03", voiceDurationLabel(3_200))
|
||||
assertEquals("0:59", voiceDurationLabel(59_999))
|
||||
assertEquals("1:00", voiceDurationLabel(60_000))
|
||||
assertEquals("1:05", voiceDurationLabel(65_000))
|
||||
assertEquals("10:00", voiceDurationLabel(600_000))
|
||||
// 不折算成小时制:语音消息不会长到那个量级,多一种表达就多一种自己发明的说法。
|
||||
assertEquals("124:03", voiceDurationLabel(7_443_000))
|
||||
}
|
||||
|
||||
// —— 4. 三态 status 在界面上多说的那一句 ——
|
||||
|
||||
@Test
|
||||
fun `only an empty transcription with blank content gets the extra line`() {
|
||||
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, ""))
|
||||
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, " "))
|
||||
// transcribed:正文就是转写文本,多一句都是噪声。
|
||||
assertNull(voiceNote(VoiceStatus.TRANSCRIBED, "明天几点出发"))
|
||||
// failed:正文**就是**服务端那行诚实提示,本端一个字都不改、也不添油加醋。
|
||||
assertNull(voiceNote(VoiceStatus.FAILED, "这段语音没能转成文字(未配置语音识别)"))
|
||||
// 认不出的 status:不猜(与 failed 同形:只显示正文)。
|
||||
assertNull(voiceNote("", ""))
|
||||
assertNull(voiceNote("future-status", ""))
|
||||
// empty 而正文非空(契约说不会出现):不补一句自相矛盾的话。
|
||||
assertNull(voiceNote(VoiceStatus.EMPTY, "其实有字"))
|
||||
}
|
||||
|
||||
// —— 5. 相对路径接到当前服务器地址 ——
|
||||
|
||||
@Test
|
||||
fun `a server relative path is rebased onto the current server`() {
|
||||
assertEquals(
|
||||
"http://192.168.1.9:8080/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "http://192.168.1.9:8080"),
|
||||
)
|
||||
// 尾斜杠、带路径的 baseUrl、大小写 scheme:都按「scheme://authority」处理(与 ImageHostRebase 同)。
|
||||
assertEquals(
|
||||
"http://host:8080/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "http://host:8080/"),
|
||||
)
|
||||
assertEquals(
|
||||
"HTTPS://host/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "HTTPS://host/cyrene"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable base url yields nothing instead of a guessed path`() {
|
||||
// 拼不出可信地址就回 null(调用方据此如实报「暂时播不了」),**绝不**退回自己拼一条路径。
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", ""))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "192.168.1.9:8080"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "http://"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "ftp://host"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a path that is not exactly one media name is refused`() {
|
||||
// 只看服务端给的那条路径:不是「恰好一段 /api/v1/media/<name>」就不接(看不懂的不改、不发)。
|
||||
assertNull(voiceMediaUrl("/api/v1/files/$copyName", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/.", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/..", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a/../b.ogg", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/%2e%2e%2fsecret", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a.ogg?token=x", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a.ogg#f", "http://host"))
|
||||
assertNull(voiceMediaUrl("http://evil/api/v1/media/a.ogg", "http://host"))
|
||||
}
|
||||
|
||||
// —— 失败文案 ——
|
||||
|
||||
@Test
|
||||
fun `a failure always says something, and says the server's own words when it has them`() {
|
||||
// 服务端有话说(404 media_not_found 的两种情形同码)⇒ 只说本端能确定的那半句。
|
||||
val notFound = CyreneException(
|
||||
CyreneError.Server(code = "media_not_found", message = "media not found", httpStatus = 404),
|
||||
)
|
||||
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(notFound))
|
||||
// 其它服务端错 / 本地网络错:原样带上服务端那句(或本端的分类说明),**绝不静默**。
|
||||
assertEquals(
|
||||
"语音加载失败,服务器开小差了",
|
||||
voicePlaybackFailureMessage(
|
||||
CyreneException(CyreneError.Server(code = "x", message = "服务器开小差了", httpStatus = 500)),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"语音加载失败,网络不可用,请检查连接",
|
||||
voicePlaybackFailureMessage(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
|
||||
),
|
||||
)
|
||||
assertEquals("当前服务器地址不可用,暂时播不了这条语音", voicePlaybackFailureMessage(VoiceUrlUnavailableException()))
|
||||
assertEquals("这条语音太大,本机放不了", voicePlaybackFailureMessage(VoiceTooLargeException()))
|
||||
assertEquals(
|
||||
"本地缓存不可用,暂时播不了这条语音",
|
||||
voicePlaybackFailureMessage(DocumentCacheUnavailableException()),
|
||||
)
|
||||
assertEquals("语音加载失败,请稍后重试", voicePlaybackFailureMessage(IllegalStateException("boom")))
|
||||
}
|
||||
|
||||
// —— 与「这条消息什么都没有」那条既有判据的交界 ——
|
||||
|
||||
@Test
|
||||
fun `a voice message is never reported as contentless`() {
|
||||
// 语音的正文可能为空(status = empty,或服务端那行提示没到),但那条消息**不是什么都没有**
|
||||
// ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。
|
||||
val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null))
|
||||
assertFalse(isContentlessUserMessage(empty))
|
||||
// 对照:同样空的正文,没有 voice 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。
|
||||
assertTrue(isContentlessUserMessage(message(content = "")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 语音播放通道的状态机(纯函数)。
|
||||
*
|
||||
* 它要钉住的是**跨气泡**的那几条事实——少了任何一条,真机上就会出现「两条一起响」、
|
||||
* 「界面显示 A 在响而喇叭里是 B」或者「A 的失败提示挂在 B 上」:
|
||||
*
|
||||
* 1. 再点一下同一目标 = 停(**加载中也算**:用户不该被慢下载困住);
|
||||
* 2. 点另一条 = 从它重新开始(旧的那条由调用方停下并释放,纯函数只说状态变成什么);
|
||||
* 3. **迟到的回调不许改状态**:`prepared` / `failed` / `finished` 三个转换都只认「目标就是当前
|
||||
* 这一条」,而且 `prepared` 只从 `Loading` 出发(正在响的那一条不会被一个迟到的失败说成失败);
|
||||
* 4. `Failed` 不粘:再点一次就是重试(回 `Loading`);
|
||||
* 5. `silenced`(停下 / 换会话 / 组件销毁)**收所有态**,包括 `Failed` 那句提示。
|
||||
*/
|
||||
class VoicePlaybackTest {
|
||||
|
||||
private val a = "msg-a"
|
||||
private val b = "msg-b"
|
||||
|
||||
// —— 1 / 2. 按下与换目标 ——
|
||||
|
||||
@Test
|
||||
fun `pressing from idle starts loading that message`() {
|
||||
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Idle.pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing the message that is playing stops it`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing the message that is loading cancels it`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing another message switches to it`() {
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Playing(a).pressed(b))
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(a).pressed(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing a failed message retries it`() {
|
||||
val failed = VoicePlaybackState.Failed(a, "网络不可用")
|
||||
|
||||
assertFalse("失败不算「占着这条」,否则再点一下只会被当成「停」", failed.isActiveFor(a))
|
||||
assertEquals(VoicePlaybackState.Loading(a), failed.pressed(a))
|
||||
}
|
||||
|
||||
// —— 3. 迟到的回调 ——
|
||||
|
||||
@Test
|
||||
fun `prepared only moves the message that was loading`() {
|
||||
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Loading(a).prepared(a))
|
||||
// 用户已经点了别的 / 点了停:迟到的「准备好」不许把界面拽回去。
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).prepared(a))
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.prepared(a))
|
||||
// 正在响的那一条收到 prepared 也原样(它已经在响了)。
|
||||
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Playing(a).prepared(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed only moves the message that was loading`() {
|
||||
assertEquals(VoicePlaybackState.Failed(a, "坏了"), VoicePlaybackState.Loading(a).failed(a, "坏了"))
|
||||
// 正在响的那一条不该被一个迟到的失败回调说成失败(解码错误的真实顺序是「先停再报」)。
|
||||
assertEquals(
|
||||
VoicePlaybackState.Playing(a),
|
||||
VoicePlaybackState.Playing(a).failed(a, "坏了"),
|
||||
)
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).failed(a, "坏了"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finished only clears the message that was playing`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).finished(a))
|
||||
assertEquals(VoicePlaybackState.Playing(b), VoicePlaybackState.Playing(b).finished(a))
|
||||
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Loading(a).finished(a))
|
||||
}
|
||||
|
||||
// —— 4 / 5. 停下来收所有态 ——
|
||||
|
||||
@Test
|
||||
fun `silencing collects every state including a failure note`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Failed(a, "坏了").silenced)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silenced channel is active for nobody`() {
|
||||
assertFalse(VoicePlaybackState.Idle.silenced.isActiveFor(a))
|
||||
assertTrue(VoicePlaybackState.Loading(a).isActiveFor(a))
|
||||
assertTrue(VoicePlaybackState.Playing(a).isActiveFor(a))
|
||||
}
|
||||
|
||||
// —— 视图查询(气泡据此决定画什么)——
|
||||
|
||||
@Test
|
||||
fun `only playing counts as playing`() {
|
||||
assertTrue(VoicePlaybackState.Playing(a).isPlayingFor(a))
|
||||
assertFalse("加载中不算在响", VoicePlaybackState.Loading(a).isPlayingFor(a))
|
||||
assertFalse(VoicePlaybackState.Playing(b).isPlayingFor(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading and playing are two different questions`() {
|
||||
// 界面那一句「加载中…」只能在**真的在取字节**时出现(`isActiveFor` 会把「正在响」也算进来,
|
||||
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」)。
|
||||
assertTrue(VoicePlaybackState.Loading(a).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Playing(a).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Loading(b).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Idle.isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Failed(a, "坏了").isLoadingFor(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure note is only reported for its own message`() {
|
||||
val failed = VoicePlaybackState.Failed(a, "这条语音太大,本机放不了")
|
||||
|
||||
assertEquals("这条语音太大,本机放不了", failed.failureOf(a))
|
||||
assertNull(failed.failureOf(b))
|
||||
assertNull(VoicePlaybackState.Idle.failureOf(a))
|
||||
assertNull(VoicePlaybackState.Playing(a).failureOf(a))
|
||||
}
|
||||
|
||||
// —— 一条完整的用户路径 ——
|
||||
|
||||
@Test
|
||||
fun `a full press load failure retry stop path`() {
|
||||
var state: VoicePlaybackState = VoicePlaybackState.Idle
|
||||
|
||||
state = state.pressed(a)
|
||||
assertEquals(VoicePlaybackState.Loading(a), state)
|
||||
state = state.failed(a, "语音加载失败,请稍后重试")
|
||||
assertEquals("失败提示挂在这一条上", "语音加载失败,请稍后重试", state.failureOf(a))
|
||||
state = state.pressed(a)
|
||||
assertEquals("重试清掉上一句", null, state.failureOf(a))
|
||||
state = state.prepared(a)
|
||||
assertEquals(VoicePlaybackState.Playing(a), state)
|
||||
state = state.finished(a)
|
||||
assertEquals("放完回初始外观,不留痕迹", VoicePlaybackState.Idle, state)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
|
||||
/**
|
||||
* 「这条消息什么都没传过来」的判据(纯函数,`user` 消息的空气泡修复)。
|
||||
@@ -15,8 +16,9 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
*
|
||||
* 这个测试要钉住两件事:
|
||||
* 1. 真的什么都没有时成立(空 / 纯空白);
|
||||
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链,
|
||||
* 以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上「内容没传过来」的帽子。
|
||||
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链、
|
||||
* 有 `voice` 键(语音形态),以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上
|
||||
* 「内容没传过来」的帽子。
|
||||
*/
|
||||
class ContentlessMessageTest {
|
||||
|
||||
@@ -26,6 +28,7 @@ class ContentlessMessageTest {
|
||||
image: String? = null,
|
||||
documents: List<DocumentRefDto> = emptyList(),
|
||||
thinking: String? = null,
|
||||
voice: VoiceDto? = null,
|
||||
) = HistoryMessageDto(
|
||||
id = "m1",
|
||||
sessionId = "web",
|
||||
@@ -34,6 +37,7 @@ class ContentlessMessageTest {
|
||||
image = image,
|
||||
documents = documents,
|
||||
thinking = thinking,
|
||||
voice = voice,
|
||||
createdAt = 1_791_088_571_785L,
|
||||
)
|
||||
|
||||
@@ -65,6 +69,17 @@ class ContentlessMessageTest {
|
||||
assertTrue(isContentlessUserMessage(message(image = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice message is never contentless`() {
|
||||
// 语音的转写文本可能为空(`status = empty`,或微信那条根本没有副本),但那条消息
|
||||
// **不是什么都没有**:它有语音形态。判据只看 `voice` 键在不在(契约 §4.4)。
|
||||
assertFalse(
|
||||
isContentlessUserMessage(
|
||||
message(content = "", voice = VoiceDto(source = "platform", status = "empty")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 不成立:凡有一点点内容就一个字都不说 ——
|
||||
|
||||
@Test
|
||||
|
||||
@@ -306,6 +306,57 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
|
||||
## 8. 端点速查
|
||||
|
||||
### 8.1 语音夹具(接收侧:`voice` 键 + `GET /api/v1/media/{name}`)
|
||||
|
||||
**它解决什么问题**:生产库里**一条带 `voice` 的真消息都没有**(拉 200 条 0 命中),而语音的 `voice`
|
||||
键**只在四个 REST 读取面**填充(契约 v0.79 §4.4 覆盖率)—— ⇒ 想验「语音气泡 + 播放」,必须由服务端
|
||||
造一条**带 `voice` 键的历史消息**。
|
||||
|
||||
`POST /__control/messages` 多两个参数(其余不变):
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|---|---|---|
|
||||
| `voice` | `transcribed` / `empty` / `failed` / `nocopy` / `none`(默认) | 见下表 |
|
||||
| `duration` | 毫秒(默认 `2000`);`0` | `0` ⇒ **不打 `durationMs` 键**(验「缺席就不显示时长」那一档) |
|
||||
|
||||
| `voice=` | `source` / `status` | 音频副本 | 设备上该看到什么 |
|
||||
|---|---|---|---|
|
||||
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文 = 你给的 `text` |
|
||||
| `empty` | `asr` / `empty` | 有 | 播放键照在;正文为空 ⇒ 多一句「没有识别出文字」 |
|
||||
| `failed` | `asr` / `failed` | 有 | 播放键照在;正文由你用 `text=` 给(假后端**不**替你编服务端那句话) |
|
||||
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信 `weixin-ilink` 那条路) |
|
||||
|
||||
服务端那一条:`GET /api/v1/media/{name}` 只认夹具名(`fakevoice0000000000000000000000.wav`),
|
||||
回的是一段**真的 2 秒 440 Hz WAV**(启动时用 `wave` 现生成、16-bit PCM 单声道 8 kHz,不落盘),
|
||||
其余名字一律 `404 media_not_found`。
|
||||
|
||||
```bash
|
||||
B=http://127.0.0.1:18080
|
||||
|
||||
# 1) 灌一条有副本的语音(正文是转写文本)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&text=%E6%98%8E%E5%A4%A9%E5%87%A0%E7%82%B9%E5%87%BA%E5%8F%91"
|
||||
|
||||
# 2) 确认库里那条真的带 voice 键(形状 = 契约 §4.4)
|
||||
curl -s "$B/api/v1/sessions/web/messages?limit=1"
|
||||
|
||||
# 3) 副本字节本身(浏览器/播放器都能验:2 秒 440 Hz)
|
||||
curl -s -o voice.wav "$B/api/v1/media/fakevoice0000000000000000000000.wav"
|
||||
|
||||
# 4) 另外三档(各灌一条,屏幕上应看得出四档的差别)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=empty"
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=failed&text=%E8%BF%99%E6%AE%B5%E8%AF%AD%E9%9F%B3%E6%B2%A1%E8%83%BD%E8%BD%AC%E6%88%90%E6%96%87%E5%AD%97"
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=nocopy"
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&duration=0" # 不显示时长
|
||||
```
|
||||
|
||||
**在 App 上验**(App 已按第 2 节指向本工具):灌完之后**重进聊天页**(或用 `POST /__control/ws-close`
|
||||
踢一次重连触发补拉),那几条语音就会以语音气泡出现。
|
||||
|
||||
> ⚠️ 本工具的 `history` 握手回放**会把 `voice` 一起回放**(它直接回内存里那份消息对象),
|
||||
> 而**真后端不会**(合同 §4.4:`history` 回放不带 `voice`)。所以在这里「重进页面就看到语音气泡」
|
||||
> 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段,
|
||||
> 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。
|
||||
|
||||
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
|
||||
|
||||
| 端点 | 备注 |
|
||||
@@ -322,13 +373,14 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
| `GET /api/v1/personas` | 用户侧角色目录(不含 admin 专属 `thinking`) |
|
||||
| `PATCH` / `DELETE /api/v1/messages/{id}` | 编辑 / 删除单条,并广播 `message_edited` / `message_deleted` |
|
||||
| `POST /api/v1/chat` | **额外**实现的 REST 写路径(长文 / 图片 / 附件走这条),回 202 + `ChatAckDto` 形状 |
|
||||
| `GET /api/v1/media/{name}` | 语音副本播放端点(契约 §1.2 **U19**):只认夹具名,回 2 秒 WAV;其余 `404 media_not_found`。⚠️ 真后端要 Bearer `owner\|admin`,本工具**不校验鉴权** ⇒ 这里验不到鉴权那一半(见 8.1) |
|
||||
| `GET /ws/chat?token=…` | 手写 WebSocket;握手后立刻发 `history`(回放条数可配) |
|
||||
|
||||
`/__control/**`(同样包信封,但载荷是本工具自己的状态,不是产品契约):
|
||||
|
||||
| 端点 | 作用 |
|
||||
|---|---|
|
||||
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=` | 免费灌 N 条(`text` 支持 `{n}` 占位) |
|
||||
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=` | 免费灌 N 条(`text` 支持 `{n}` 占位);`voice=` 灌一条**语音消息**(见 8.1) |
|
||||
| `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
|
||||
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
|
||||
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
|
||||
@@ -361,6 +413,7 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
| `SessionSummaryDto`(`title` / `lastMessage` / `personaId` 可整键缺席) | `app/.../data/api/dto/SessionDto.kt:16-50` |
|
||||
| 会话排序 `pinned DESC, main DESC, created_at ASC` | `SessionDto.kt:8-9` + 线上夹具 `SessionDtoRealPayloadTest.kt:36-38` |
|
||||
| `HistoryMessageDto` | `app/.../data/api/dto/MessageDto.kt:13-46` |
|
||||
| `voice` 键(7 个键名 / 三态 / 四者同生共死)与 `GET /api/v1/media/{name}` | **契约**:`Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」+ §1.2 **U19**(v0.79 已实现)。本工具按它造夹具,**不代表**真后端的逐个字节 |
|
||||
| `after` 升序 / `before` 倒序 / `q` 与 `after` 互斥 | `app/.../data/api/SessionsApi.kt:71-101` + `MessageRepository.kt:79-101` |
|
||||
| limit 默认 50 / 上限 200 | `app/.../data/repository/MessageRepository.kt:149-150` |
|
||||
| `EnumTableDto` 的 6/5/9 张表与 `unknown:"display-raw"` | 线上夹具 `MetaDtoRealPayloadTest.kt:24`(逐字) |
|
||||
@@ -382,9 +435,11 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
- **`platform` 过滤**是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传 `null`
|
||||
(`MessageRepository.kt:42,99`),所以这条路径没被真正走过。
|
||||
- **`q` 搜索**是 ASCII 大小写不敏感的子串匹配;`%` / `_` / `\` 不做通配(我根本不解析通配符)。
|
||||
- **不实现**:admin 面全部端点、`documents` / `upload`、`voice_input` 的落库、`mode_switch` 的语义、
|
||||
`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧**只记录、不处置**。因此 App 里
|
||||
管理员页面、附件上传这类路径在这个假后端上跑不通。
|
||||
- **不实现**:admin 面全部端点、`documents` / `upload` 的**摄取链路**、`voice_input` 的落库、
|
||||
`mode_switch` 的语义、`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧
|
||||
**只记录、不处置**。因此 App 里管理员页面、附件上传这类路径在这个假后端上跑不通。
|
||||
⚠️ `GET /api/v1/media/{name}` **实现了**(语音播放那一半,见 8.1),但**不校验 Bearer**
|
||||
—— 真后端那条是 `owner|admin` 端点(契约 U19)。
|
||||
- **主动消息只能靠 `proactive_message` 帧造**:本工具不模拟 `backthink`(真后端也没有 HTTP 入口),
|
||||
所以「她自己决定什么时候开口」这件**服务端**行为在这里验不到 —— 能验的是「帧到了客户端之后」那一段
|
||||
(见第 7 节场景五)。同理,本工具**不会**自动补发 `message_persisted`:真后端走 IM 桥的主动消息会额外
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
@@ -25,6 +27,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import wave
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
@@ -114,6 +117,76 @@ def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
# —— 语音夹具(接收侧那条路:`voice` DTO 键 + `GET /api/v1/media/{name}`)———————
|
||||
#
|
||||
# 为什么要造假音频:**本端没有别的造法**。生产库里一条带 `voice` 的真消息都没有(拉 200 条 0 命中),
|
||||
# 而语音的 `voice` 键**只在四个 REST 读取面**填充、实时帧与握手回放都没有(契约 v0.79 §4.4 覆盖率)
|
||||
# ⇒ 想验「语音气泡 + 播放」,必须有一条**带 voice 键的历史消息**,而它只能由服务端造。
|
||||
#
|
||||
# 这份夹具是**按客户端解出来的形状**写的(`VoiceDto.kt` / `HistoryMessageDto.voice`),
|
||||
# 不是真后端的权威形状;它只负责让那条路在设备上跑起来。
|
||||
VOICE_FIXTURE_NAME = "fakevoice0000000000000000000000.wav"
|
||||
VOICE_FIXTURE_MIME = "audio/wav"
|
||||
VOICE_FIXTURE_DURATION_MS = 2000
|
||||
|
||||
|
||||
def make_voice_wav(duration_ms: int = VOICE_FIXTURE_DURATION_MS, freq: float = 440.0, rate: int = 8000) -> bytes:
|
||||
"""生成一段**真的能播**的 WAV(16-bit PCM 单声道,首尾各 20ms 淡入淡出)。
|
||||
|
||||
为什么是真音频而不是几个字节:这条路验的就是「MediaPlayer 放得出来」。给一串随便的字节,
|
||||
客户端只会走到「这段语音本机播不了」那一档——那验的是失败分支,不是主路径。
|
||||
纯标准库(`wave` + `math`),零依赖;每次启动现生成,不落盘(本工具不留持久数据)。
|
||||
"""
|
||||
frames = max(1, int(rate * duration_ms / 1000))
|
||||
fade = max(1, int(rate * 0.02))
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as handle:
|
||||
handle.setnchannels(1)
|
||||
handle.setsampwidth(2)
|
||||
handle.setframerate(rate)
|
||||
samples = bytearray()
|
||||
for index in range(frames):
|
||||
gain = min(1.0, index / fade, (frames - index) / fade)
|
||||
value = int(12000 * gain * math.sin(2 * math.pi * freq * index / rate))
|
||||
samples += struct.pack("<h", value)
|
||||
handle.writeframes(bytes(samples))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS):
|
||||
"""按控制端点给的种类造一个 `voice` 键的值;`none` ⇒ 不打这个键(返回 None)。
|
||||
|
||||
`kind` 的取值(都对着契约 §4.4 那几档,供设备上一档一档看):
|
||||
|
||||
| kind | `source` / `status` | 音频副本 | 设备上该看到什么 |
|
||||
|---|---|---|---|
|
||||
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文即转写文本 |
|
||||
| `empty` | `asr` / `empty` | 有 | 同上,但正文为空 ⇒ 多一句「没有识别出文字」 |
|
||||
| `failed` | `asr` / `failed` | 有 | 播放键照在,正文是服务端那行提示(假后端不会替你编,用 `text=` 给) |
|
||||
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信那条路) |
|
||||
"""
|
||||
if kind in (None, "", "none"):
|
||||
return None
|
||||
name = VOICE_FIXTURE_NAME if kind != "nocopy" else None
|
||||
duration = duration_ms if duration_ms > 0 else None
|
||||
copy_keys = (
|
||||
{"name": name, "mime": VOICE_FIXTURE_MIME, "size": len(VOICE_WAV), "url": "/api/v1/media/{}".format(name)}
|
||||
if name
|
||||
else {}
|
||||
)
|
||||
source = "platform" if kind == "nocopy" else "asr"
|
||||
status = {"transcribed": "transcribed", "empty": "empty", "failed": "failed", "nocopy": "transcribed"}[kind]
|
||||
payload = {"source": source, "status": status}
|
||||
payload.update(copy_keys)
|
||||
if duration is not None:
|
||||
payload["durationMs"] = duration
|
||||
return payload
|
||||
|
||||
|
||||
# 夹具音频只生成一次(启动时),`size` 与真正发出去的字节因此是同一份。
|
||||
VOICE_WAV = make_voice_wav()
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
@@ -276,10 +349,13 @@ class Store:
|
||||
documents=None,
|
||||
created_at=None,
|
||||
message_id=None,
|
||||
voice=None,
|
||||
) -> dict:
|
||||
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
|
||||
|
||||
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
|
||||
[voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)——
|
||||
只有控制端点能造出来,真后端的入站语音副本链本工具不模拟。
|
||||
"""
|
||||
with self.lock:
|
||||
session = self.sessions.get(session_id)
|
||||
@@ -307,6 +383,8 @@ class Store:
|
||||
message["mood"] = mood
|
||||
if documents:
|
||||
message["documents"] = documents
|
||||
if voice:
|
||||
message["voice"] = voice
|
||||
session.messages.append(message)
|
||||
return message
|
||||
|
||||
@@ -861,6 +939,7 @@ MESSAGES_RE = re.compile(r"^/api/v1/sessions/([^/]+)/messages$")
|
||||
SESSION_RE = re.compile(r"^/api/v1/sessions/([^/]+)$")
|
||||
SESSION_READ_RE = re.compile(r"^/api/v1/sessions/([^/]+)/read$")
|
||||
MESSAGE_RE = re.compile(r"^/api/v1/messages/([^/]+)$")
|
||||
MEDIA_RE = re.compile(r"^/api/v1/media/([^/]+)$")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -1096,8 +1175,40 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._delete_message(message_id)
|
||||
return
|
||||
|
||||
match = MEDIA_RE.match(path)
|
||||
if match and method == "GET":
|
||||
self._serve_media(unquote(match.group(1)))
|
||||
return
|
||||
|
||||
self._fail(404, "not_found", "no such endpoint: {} {}".format(method, path))
|
||||
|
||||
def _serve_media(self, name: str) -> None:
|
||||
"""`GET /api/v1/media/{name}`(契约 v0.79 §1.2 **U19**,语音副本播放端点)。
|
||||
|
||||
⚠️ **真后端要 Bearer `owner|admin`**(语音是私密内容);本工具**不校验任何鉴权**(README 首条),
|
||||
所以这条端点在设备上验的是「客户端有没有把请求发对地方、字节能不能播」,**不**能验鉴权。
|
||||
|
||||
只认夹具那一个名字:其余(含穿越形态)一律 `404 media_not_found`——与真后端
|
||||
「名字非法 / 穿越 / 不存在统一同码」的取向一致,也方便顺手验一次失败分支。
|
||||
"""
|
||||
if name != VOICE_FIXTURE_NAME:
|
||||
self._fail(404, "media_not_found", "media not found")
|
||||
return
|
||||
data = VOICE_WAV
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", VOICE_FIXTURE_MIME)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Content-Disposition", 'inline; filename="{}"'.format(name))
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Cache-Control", "private, max-age=86400, immutable")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
log("http", "media {} bytes (voice fixture)".format(len(data)))
|
||||
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
||||
self.close_connection = True
|
||||
|
||||
def _create_session(self) -> None:
|
||||
body, raw = self._parse_json_body()
|
||||
if raw is not None and body is None:
|
||||
@@ -1399,13 +1510,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
prefix = self._param(query, "prefix", "seed")
|
||||
template = self._param(query, "text", None)
|
||||
platform = self._param(query, "platform", None)
|
||||
voice_kind = self._param(query, "voice", None)
|
||||
try:
|
||||
voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS))
|
||||
except KeyError:
|
||||
self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none")
|
||||
return
|
||||
if session_id not in self.store.sessions:
|
||||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||||
return
|
||||
first_id = last_id = None
|
||||
for index in range(1, count + 1):
|
||||
content = template.format(n=index) if template else "{} #{}".format(prefix, index)
|
||||
message = self.store.add_message(session_id, role, content, platform=platform)
|
||||
message = self.store.add_message(
|
||||
session_id,
|
||||
role,
|
||||
content,
|
||||
platform=platform,
|
||||
voice=voice,
|
||||
)
|
||||
first_id = first_id or message["id"]
|
||||
last_id = message["id"]
|
||||
with self.store.lock:
|
||||
|
||||
Reference in new issue
Block a user