merge: W62 —— 语音消息接收侧(voice DTO + 语音气泡 + 播放;无副本不画播放键、时长缺席不估算、实时帧不等 voice)

This commit is contained in:
AskaEth committed 2026-10-07 11:14:54 +08:00
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
+59 -4
View File
@@ -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 桥的主动消息会额外
+124 -1
View File
@@ -17,7 +17,9 @@
import argparse
import base64
import hashlib
import io
import json
import math
import os
import re
import struct
@@ -25,6 +27,7 @@ import sys
import threading
import time
import uuid
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, unquote, urlsplit
@@ -114,6 +117,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: