Compare commits
62
Commits
6de2f7656a
...
android
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ab1ff004 | ||
|
|
576dd58a6b | ||
|
|
a9c940d39f | ||
|
|
f6eb93027d | ||
|
|
e94bdf6583 | ||
|
|
6aa434dad5 | ||
|
|
222f691b78 | ||
|
|
436c0f06f6 | ||
|
|
4a9e22ea77 | ||
|
|
6d158ca9bd | ||
|
|
b35b1b53ab | ||
|
|
504c500e20 | ||
|
|
db53de1a7e | ||
|
|
d716d26864 | ||
|
|
8babd1d2bb | ||
|
|
53d7a9fb69 | ||
|
|
5989e0d4eb | ||
|
|
4d25d9ec69 | ||
|
|
4e822df5ed | ||
|
|
e0e657d5ee | ||
|
|
c1ebd208b1 | ||
|
|
a94e310cd7 | ||
|
|
f1e894e71f | ||
|
|
1055f03b46 | ||
|
|
9da25b0895 | ||
|
|
e962489520 | ||
|
|
cf00af7b42 | ||
|
|
7b39994715 | ||
|
|
11602c0138 | ||
|
|
591f298dc0 | ||
|
|
9dee236778 | ||
|
|
4b19278733 | ||
|
|
3fe55e8d0c | ||
|
|
3d4a9f288a | ||
|
|
d7d93b4cb0 | ||
|
|
6d4a37e484 | ||
|
|
3b9936e212 | ||
|
|
dc5972213e | ||
|
|
c07a713e86 | ||
|
|
bfa79946d4 | ||
|
|
0e2df1193d | ||
|
|
0e834fc854 | ||
|
|
4fe5509c24 | ||
|
|
cdd58fa9c4 | ||
|
|
541a70f126 | ||
|
|
3d8bc61912 | ||
|
|
9ced7444bd | ||
|
|
2fbf24a675 | ||
|
|
22d655b73b | ||
|
|
55612a3922 | ||
|
|
83a2a88b3c | ||
|
|
53c3c5c8ee | ||
|
|
e0bdaa3537 | ||
|
|
316cd27049 | ||
|
|
c40a752bcf | ||
|
|
df7b504fa2 | ||
|
|
6d4f2cdbae | ||
|
|
af9c63d267 | ||
|
|
c8f19f9c84 | ||
|
|
f9555fba85 | ||
|
|
900d191b8d | ||
|
|
0e03eba6a4 |
No files matched your search
@@ -12,3 +12,7 @@ captures/
|
||||
.cxx/
|
||||
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
|
||||
/IMG_*.png
|
||||
# 开发文档 —— **主人 2026-10-07 明确指令:整个文件夹禁止进入远程仓库**。
|
||||
# 它只在本机磁盘上(前后端协同留言板、进度交接、设计拆解都在里面),靠磁盘本身交接。
|
||||
# 凭据同样绝不写进去(那类东西在 dev-secrets.local.md)。
|
||||
devdocs/
|
||||
@@ -15,6 +15,16 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!--
|
||||
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
|
||||
|
||||
它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
|
||||
被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
|
||||
改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
|
||||
没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!--
|
||||
联网权限**显式**声明,不靠任何传递依赖。
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.DocumentsApi
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
@@ -23,6 +24,7 @@ import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.DocumentRepository
|
||||
import top.yeij.cyrene.data.repository.MediaRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.MetaRepository
|
||||
import top.yeij.cyrene.data.repository.PersonaRepository
|
||||
@@ -65,12 +67,22 @@ class AppContainer(context: Context) {
|
||||
/**
|
||||
* 「她主动说的话」的通知通路(W53N):判据、去重与落地都在里面,Android 那一步从这里注入。
|
||||
*
|
||||
* 它**不订阅任何东西**(帧由 `SessionsViewModel` 那条既有订阅喂进来),因此与连接的所有权
|
||||
* 完全无关——本端那条共享连接的生死仍然只在登录态那一处表达。
|
||||
* 它**不订阅任何东西**(帧由 [proactiveFrameIntake] 喂进来),因此与连接的所有权完全无关
|
||||
* ——本端那条共享连接的生死仍然只在登录态那一处表达。
|
||||
*/
|
||||
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
|
||||
presence = appPresence,
|
||||
post = { text, notificationId -> postProactiveNotification(appContext, text, notificationId) },
|
||||
post = { text, notificationId ->
|
||||
// 「免打扰」**每次现读**(`AppSettings.current` 是那份热副本):开关一改,下一条通知就
|
||||
// 换一条渠道,不必重启 App。为什么不能在这里挂起读一次、以及那个毫秒级窗口是怎么回事,
|
||||
// 都写在 `AppSettings.current` 的 KDoc 里。
|
||||
postProactiveNotification(
|
||||
context = appContext,
|
||||
text = text,
|
||||
notificationId = notificationId,
|
||||
quiet = appSettings.current.value.proactiveQuiet,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -186,6 +198,34 @@ class AppContainer(context: Context) {
|
||||
|
||||
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
|
||||
|
||||
private var cachedMediaApi: MediaApi? = null
|
||||
private var cachedMediaBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 语音副本端点(`GET /api/v1/media/{name}`,契约 §1.2 **U19**);与 [documentsApi] **同一套门**:
|
||||
* `owner|admin` **必须 Bearer**(与图片读侧「名字即凭据」刻意相反 —— 语音是私密内容)。
|
||||
*
|
||||
* 同样与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理):`voice.url` 是**服务端相对路径**,
|
||||
* 只有落在**同一台后端**上才取得到那份副本。
|
||||
*/
|
||||
val mediaApi: MediaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedMediaApi
|
||||
if (cached != null && cachedMediaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedMediaBaseUrl = baseUrl
|
||||
cachedMediaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val mediaRepository: MediaRepository get() = MediaRepository(mediaApi)
|
||||
|
||||
private var cachedAdminApi: AdminApi? = null
|
||||
private var cachedAdminBaseUrl: String? = null
|
||||
|
||||
@@ -287,4 +327,22 @@ class AppContainer(context: Context) {
|
||||
onAuthFailed = authManager::onSessionExpired,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「她主动说的话 ⇒ 弹通知」那条通路在**帧这一层的消费点**(W57,**进程级**):把 [chatSocket]
|
||||
* 上那两种帧交给 [proactiveNotifier]。构造即订阅,此后与进程同寿(没有 start / stop 的理由
|
||||
* 见 [ProactiveFrameIntake] 的类注释)。
|
||||
*
|
||||
* 改前这份订阅挂在 `SessionsViewModel` 的 `viewModelScope` 上(它随 Activity 一起结束),于是
|
||||
* 「从最近任务划掉 App、进程还被 `KeepAliveService` 撑着」那一档:帧到了 [chatSocket]、
|
||||
* 一个订阅者都没有,而 `events` 是 `replay = 0` 的 `SharedFlow` ⇒ 当场丢掉,通知永远不弹。
|
||||
*
|
||||
* 声明在这里(类的末尾)有一个顺序上的理由:它要同时用到 [proactiveNotifier] 与 [chatSocket],
|
||||
* 而 [chatSocket] 是本类最后声明的那个(Kotlin 按声明顺序初始化)。读 [ChatSocket.events] 会把
|
||||
* 那条连接对象提前建出来——本容器与它同时诞生、同时活着,这一步没有任何代价。
|
||||
*/
|
||||
val proactiveFrameIntake: ProactiveFrameIntake = ProactiveFrameIntake(
|
||||
events = chatSocket.events,
|
||||
notifier = proactiveNotifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.core.notify.ProactiveNotifier
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
private const val LOG_TAG = "ProactiveFrameIntake"
|
||||
|
||||
/** 一帧「她主动说的话」剥出来的东西:[ProactiveNotifier.onProactiveFrame] 的四个入参 + 会话归属。 */
|
||||
internal data class ProactiveFrame(
|
||||
val sessionId: String?,
|
||||
val id: String?,
|
||||
val messageId: String?,
|
||||
val timestamp: String?,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这一帧算不算「她主动说的话」,以及那四个入参各取帧上的哪个键——**纯函数**(离线可穷举)。
|
||||
*
|
||||
* 只有两种帧是她主动说的话,它们对应服务端两条路(见 [ProactiveNotifier]):
|
||||
* 1. [WsEvent.ProactiveMessage](实时帧 `proactive_message`)——`id` 取落库行键 `data.id`,`messageId`
|
||||
* 取 IM 侧消息号,两者**不是同一个 id**(见那个事件的 KDoc);
|
||||
* 2. [WsEvent.MessagePersisted] **且** `proactive == true`(IM 桥那条路补发的落库帧)——这一档只有
|
||||
* 落库行的键(`message.id`),另外两个键帧上就没有,所以恒给 `null`,一个字都不许编。
|
||||
*
|
||||
* 其余帧一律 `null`:普通落库广播、流式增量、别的事件都不是「她主动找我说话」。判据错在这一层的
|
||||
* 两个方向都很难在真机上发现——要么她开口了却什么都不弹(要等下一次主动消息才看得见),要么普通
|
||||
* 消息也弹(每一次她正常回话都响)。
|
||||
*/
|
||||
internal fun proactiveFrameOf(event: WsEvent): ProactiveFrame? = when (event) {
|
||||
is WsEvent.ProactiveMessage -> ProactiveFrame(
|
||||
sessionId = event.sessionId,
|
||||
id = event.id,
|
||||
messageId = event.messageId,
|
||||
timestamp = event.timestamp,
|
||||
content = event.content,
|
||||
)
|
||||
|
||||
is WsEvent.MessagePersisted -> {
|
||||
if (!event.proactive) {
|
||||
// 普通落库广播(她回话、别的端发的消息都走这一帧):主人只批了 proactive 这一档。
|
||||
null
|
||||
} else {
|
||||
ProactiveFrame(
|
||||
sessionId = event.sessionId,
|
||||
id = event.message?.id,
|
||||
messageId = null,
|
||||
timestamp = null,
|
||||
content = event.message?.content.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「她主动说的话 ⇒ 弹系统通知」那条通路的**帧消费点**(W57,**进程级**)。
|
||||
*
|
||||
* ## 为什么必须提到这一层(改前那一档是坏的)
|
||||
* 这条通路的判据与去重一直是对的,错的是**订阅挂在哪**。改前它是 `SessionsViewModel` 里那句
|
||||
* `viewModelScope.launch { socket.events.collect(::onEvent) }`。连接倒是进程级的——划掉 Activity
|
||||
* 之后它照样连着、照样在收帧——但 `viewModelScope` 随 Activity 一起结束,而 `ChatSocket.events`
|
||||
* 是 `replay = 0` 的 `SharedFlow`:**没有订阅者的 `tryEmit` 就是当场丢掉**(它不替将来的订阅者
|
||||
* 留任何东西)。于是「从最近任务把 App 划掉、进程还被前台服务撑着」那一档,她的话进了连接、
|
||||
* 然后没了。
|
||||
*
|
||||
* 本类因此只做一件事:**在进程级订那份帧流**,把 [proactiveFrameOf] 认出的帧交给 [ProactiveNotifier]。
|
||||
* 它不建连接、不关连接、不认识登录态——连接的所有权一个字节都没动,仍然只在登录态那一处表达
|
||||
* (`SessionsViewModel` 的 `LoggedIn → connect()` / `LoggedOut → close()`)。
|
||||
*
|
||||
* ## 为什么没有 start() / stop()
|
||||
* 它的生命周期**就是进程**:`AppContainer` 在 `Application.onCreate` 里构造它,此后一直活着。
|
||||
* 刻意不提供 `stop()` 正是这次要修的那个 bug——**唯一会想到去调 stop 的调用方(某个 ViewModel)
|
||||
* 会随 Activity 一起死**,那等于把这条通路又绑回 Activity 上;留一个可以被忘掉调用的 `start()`
|
||||
* 也只是多一种坏法(忘调 = 通知整个哑掉,且没有任何报错)。
|
||||
*
|
||||
* 它不需要停:帧只有连接活着才可能到,而连接在登出(含令牌失效那条经 `onAuthFailed` 的路径)时
|
||||
* 由登录态关掉。「登出期间收不到帧」这件事由**连接**保证,不靠这里取消订阅。去重账本也**刻意不**
|
||||
* 在这里清:它是 `ProactiveNotifier` 里的进程级账本,而换了账号之后同一条消息再推一次,仍是
|
||||
* 同一个人的同一句话,不该弹第二遍。
|
||||
*
|
||||
* ## 它现在是这条通路上唯一的帧消费点
|
||||
* 改前 `SessionsViewModel` 里那两处转交已经删掉(留两处就等于靠去重去替一次重复消费擦屁股,
|
||||
* 而且「谁在喂通知」会有两个答案)。所以这条订阅一旦死掉,通知就**整个**哑掉——兜底见 [deliver]。
|
||||
*
|
||||
* @param events 进程内唯一那条连接的帧流(`ChatSocket.events`)。
|
||||
* @param notifier 判据 + 去重 + 落地那一处。本类只喂帧,不替它判断任何事(那两个入参是它自己的
|
||||
* 事实与动作,见它的 KDoc)。
|
||||
* @param dispatcher 收集用的调度器;单测传 `Dispatchers.Unconfined`,让帧在同一次 `emit` 里走完。
|
||||
*/
|
||||
class ProactiveFrameIntake(
|
||||
events: SharedFlow<WsEvent>,
|
||||
private val notifier: ProactiveNotifier,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 订阅本身那个 scope:私有,没有第二个人拿得到它,也就没有人能取消这条订阅——本类的全部意义
|
||||
* 就在这里。`SupervisorJob` 只为 [deliver] 那条兜底留余地。
|
||||
*/
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
/** 构造即订阅(理由见类注释:启动时刻只有一个,留个能忘调的 `start()` 只会多一种坏法)。 */
|
||||
init {
|
||||
scope.launch { events.collect { event -> proactiveFrameOf(event)?.let(::deliver) } }
|
||||
}
|
||||
|
||||
private fun deliver(frame: ProactiveFrame) {
|
||||
try {
|
||||
notifier.onProactiveFrame(
|
||||
sessionId = frame.sessionId,
|
||||
id = frame.id,
|
||||
messageId = frame.messageId,
|
||||
timestamp = frame.timestamp,
|
||||
content = frame.content,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// 落地那一步(建渠道 / notify)万一抛,**这条订阅必须活下来**:它是进程级那唯一一处、
|
||||
// 不会再被重建,而不接住的话异常会打到线程的未捕获处理器(Android 上就是进程当场挂)。
|
||||
// 代价封顶在「这一条通知没弹」——去重账本已经把它记下了,不会为了补它再弹一次。
|
||||
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "这条主动消息的通知没能落地", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,17 @@ import java.util.UUID
|
||||
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
|
||||
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
|
||||
*
|
||||
* ## 四个 extra 为什么都读「原始值」
|
||||
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` / `EXTRA_STREAM` 一律用 [Intent.rawExtra] 取原始值,
|
||||
* 再由本文件按**形态**分派([shareStreamExtrasOf] / [shareTextExtrasOf])。两个理由:逐类型的
|
||||
* `getXxxExtra` 遇到别的形态只会往 logcat 打一句 `ClassCastException` 再把值扔掉(那正是「分享点下去
|
||||
* 什么都没发生」的来处),而本端还要知道「这个 extra 里**到底有没有躺着值**」——那件事纯函数看不到,
|
||||
* 只能由这里如实报进去(两个 `present` 参数)。
|
||||
*
|
||||
* ⚠️ **正文的回落链起点也在这里**:`EXTRA_TEXT` 读不出东西时,`EXTRA_SUBJECT` → `EXTRA_TITLE` 依次
|
||||
* 顶上([ShareRules.extract]),来源记进 `SharedContent.textSource`。三个都空 / 都读不出来时,这一次
|
||||
* 分享照样要往下走(走「读不进来」那条如实告知),**绝不静默**。
|
||||
*
|
||||
* ## 清理
|
||||
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
|
||||
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
|
||||
@@ -90,14 +101,24 @@ object ShareIntake {
|
||||
*/
|
||||
fun accept(context: Context, intent: Intent) {
|
||||
val appContext = context.applicationContext
|
||||
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
|
||||
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
|
||||
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
|
||||
val rawStream = intent.streamExtraValue()
|
||||
// 四个 extra 一律读**原始值**、按**形态**分派(见 [shareStreamExtrasOf] / [shareTextExtrasOf]):
|
||||
// 挨个 getXxxExtra 去问的话,遇到别的形态只会拿到一行 `ClassCastException` 加一个 `null`
|
||||
// ——那正是「分享进来什么都没发生」的来处。读不出形态时本端既不猜、也不闭嘴(见下面两个
|
||||
// `present`):那照样是一次「带着内容迹象却什么都没读进来」。
|
||||
val rawStream = intent.rawExtra(Intent.EXTRA_STREAM)
|
||||
val streamExtras = shareStreamExtrasOf(rawStream)
|
||||
val textExtras = shareTextExtrasOf(
|
||||
text = intent.rawExtra(Intent.EXTRA_TEXT),
|
||||
subject = intent.rawExtra(Intent.EXTRA_SUBJECT),
|
||||
title = intent.rawExtra(Intent.EXTRA_TITLE),
|
||||
)
|
||||
val extracted = ShareRules.extract(
|
||||
action = intent.action,
|
||||
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
|
||||
// 正文走回落链:EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(有些发送方不填 EXTRA_TEXT,
|
||||
// 内容只在后两个里;只读第一个会让那次分享整条静默)。来源记在 `textSource` 里。
|
||||
text = textExtras.text,
|
||||
subject = textExtras.subject,
|
||||
title = textExtras.title,
|
||||
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
|
||||
stream = streamExtras.uriForms.firstOrNull(),
|
||||
streams = streamExtras.uriForms,
|
||||
@@ -105,6 +126,7 @@ object ShareIntake {
|
||||
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
|
||||
// 否则「分享点下去什么都没发生」会从那条缝里回来。
|
||||
streamPresent = rawStream != null,
|
||||
textPresent = textExtras.present,
|
||||
clipUris = intent.clipUris(),
|
||||
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
|
||||
mimeOf = { uri ->
|
||||
@@ -280,14 +302,27 @@ object ShareIntake {
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
|
||||
* 一个 extra 的**原始值**(四个 extra 共用),取不到回 `null`。
|
||||
*
|
||||
* ## 为什么读原始值而不是 `getXxxExtra`
|
||||
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
|
||||
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
|
||||
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
|
||||
* `ClassCastException` 再把值扔掉(`String[]` 形态的 `EXTRA_STREAM` 就是这么静默丢掉的),而那
|
||||
* 正是「分享点下去什么都没发生」的来处。形态分派见 [shareStreamExtrasOf] / [shareTextExtrasOf]。
|
||||
*
|
||||
* ## 为什么要 `runCatching`
|
||||
* `Bundle.get` 第一件事是 `unparcel()`,它会解析别的进程给的字节:里面有个解析不出来的
|
||||
* `Parcelable` 时它抛 `BadParcelableException`(核对过 AOSP 的 `android/os/BaseBundle.java`:
|
||||
* defuse 关着时**直接抛出**,开着时也还要看 `Flags.deprecateBundleDefuse()` 那个 flag)。那是
|
||||
* `RuntimeException`,从 `accept` 冒出去就是 `onCreate` 里的一次崩溃。
|
||||
*
|
||||
* ⚠️ 与它相对的是「类型不符」那种:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`
|
||||
* 并回 `null`(同一个 AOSP 文件),所以那一档**不会**抛异常——但也**读不到值**,于是它只能靠
|
||||
* 本函数给出的原始值被记成「里面躺着值」(见 [ShareTextExtras.present]),不会被静默当成
|
||||
* 「什么都没分享」。两件事一起挡住:畸形 bundle 不当崩,读不出来的值不当没发生。
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
|
||||
private fun Intent.rawExtra(key: String): Any? = runCatching { extras?.get(key) }.getOrNull()
|
||||
|
||||
/**
|
||||
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
|
||||
@@ -348,3 +383,54 @@ internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
|
||||
|
||||
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` 三个**原始值** ⇒ 能当正文用的那几段。
|
||||
*
|
||||
* ## 为什么也读原始值(而不是 `getCharSequenceExtra` 挨个问)
|
||||
* 两个理由,第一个是「读得回来」,第二个是「读不回来也要知道有东西」:
|
||||
*
|
||||
* 1. `getCharSequenceExtra` 只回「读到 / 没读到」,而本端要按**形态**分派:值可能是 `String`、
|
||||
* `Spanned`(富文本分享的常态)、`SpannedString`……它们都是 `CharSequence`,但按 `String` 读就
|
||||
* 直接是 `null`(那正是「分享进来什么都没发生」的来处之一)。这里用 `as?` 一次判完:是
|
||||
* `CharSequence` 就收下,不是就当没有——**不猜、也不抛**。
|
||||
* 2. 「三个里**有没有躺着值**」这件事纯函数看不到(它只收到几个 `CharSequence?`),而
|
||||
* 「有值、只是本端一段都读不出来」与「用户什么都没分享」是**两件事**:前者必须能说出口,
|
||||
* 否则那三种 extra 一起落空就是一次静默。所以 [present] 由这里如实报进去
|
||||
* (见 [ShareRules.extract] 的 `textPresent`)。
|
||||
*
|
||||
* ## 它抛不抛异常(核对过 AOSP 源码,`sources/android-36.1/android/os/BaseBundle.java`)
|
||||
* - **类型不符不抛**:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`(`unparcel()` 之后
|
||||
* 直接 `(CharSequence) o`,失败就 `typeWarning` 记一句 log 再回 `null`)⇒ 所以
|
||||
* `intent.getCharSequenceExtra(...)` 不会因为「值不是 CharSequence」把 App 打崩,它只是**读不到**。
|
||||
* 那一档在这里落成 [present] 为真、[text] 为 `null`:既不崩、也不再静默。
|
||||
* - **畸形 bundle 会抛**:它内部的 `unparcel()` 在别的进程给的 bundle 解析不出来时抛
|
||||
* `BadParcelableException`(`RuntimeException`)。那一路由 [Intent.rawExtra] 的 `runCatching` 挡住
|
||||
* ——读不到就当没有,**绝不让异常从 `accept` 冒出去**(那会是 `onCreate` 里的一次崩溃)。
|
||||
*/
|
||||
internal data class ShareTextExtras(
|
||||
/** `EXTRA_TEXT` 读出来的那一段(读不回来是 `null`;空白**原样留着**,由纯函数判它算不算正文)。 */
|
||||
val text: String?,
|
||||
/** `EXTRA_SUBJECT` 读出来的那一段。 */
|
||||
val subject: String?,
|
||||
/** `EXTRA_TITLE` 读出来的那一段。 */
|
||||
val title: String?,
|
||||
/**
|
||||
* 三个 extra 里**至少一个躺着原始值**——含**读不出类型**的那一种,也含**只是一段空白**的那一种。
|
||||
*
|
||||
* 空白也算,是刻意的:它说的是「对方**给了**这个字段」,与「这个字段里有没有可用的正文」是两件
|
||||
* 事。而 `EXTRA_STREAM` 那边早就是这个口径(原始值只要非 null 就算 `streamPresent`),两个 extra
|
||||
* 用同一条判据,才不会出现「流的空值会说话、文本的空值却闭嘴」这种要靠运气才发现的差别。
|
||||
*/
|
||||
val present: Boolean,
|
||||
)
|
||||
|
||||
/** 见 [ShareTextExtras]:把三个文本 extra 的原始值读成能当正文用的那几段(纯逻辑,可单测)。 */
|
||||
internal fun shareTextExtrasOf(text: Any?, subject: Any?, title: Any?): ShareTextExtras = ShareTextExtras(
|
||||
text = (text as? CharSequence)?.toString(),
|
||||
subject = (subject as? CharSequence)?.toString(),
|
||||
title = (title as? CharSequence)?.toString(),
|
||||
// 读不出类型(`42`、别种 `Parcelable`……)也算「里面躺着值」:本端不猜它是什么意思,但不该因此
|
||||
// 把整次分享当成「什么都没发生」。`null`(连值都没有)才是真的没有。
|
||||
present = text != null || subject != null || title != null,
|
||||
)
|
||||
@@ -21,7 +21,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
* 一个布尔量在那里必然被旧实例的 `onStop` 记成「后台」,于是用户正看着聊天页,她一句话却弹出
|
||||
* 通知来。按实例登记就没有这个方向性问题——新实例先加进来、旧实例后移出去,集合始终非空。
|
||||
* 集合按 `ConcurrentHashMap` 语义(`Activity` 不重写 `equals` ⇒ 就是**同一实例**),
|
||||
* 且读写可能不在同一线程上(帧在主线程的收集里读),所以用并发容器而不是普通 `MutableSet`。
|
||||
* 且读写可能不在同一线程上(她的话那条通路是在**进程级订阅自己的调度器**上读它的,见
|
||||
* `ProactiveFrameIntake`),所以用并发容器而不是普通 `MutableSet`。
|
||||
*
|
||||
* ## 它**不是**「App 有没有活着」
|
||||
* 后台保活(前台服务)让进程活着、连接继续收帧,而本类在这里恒报「不在前台」——那正是通知要
|
||||
|
||||
@@ -11,25 +11,70 @@ import top.yeij.cyrene.app.MainActivity
|
||||
import top.yeij.cyrene.core.keepalive.notificationsAllowed
|
||||
|
||||
/**
|
||||
* 「她主动说的话」那条通知的渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做过的静音
|
||||
* 设置会失效(与 `keep_alive` 同一条纪律)。
|
||||
* 「她主动说的话」那条通知的渠道 id —— **弹横幅**的那一条([PROACTIVE_IMPORTANCE])。
|
||||
*
|
||||
* ⚠️ **这里是 `proactive_v2`,不是最初的 `proactive`**:渠道的**重要级一旦创建就改不了**
|
||||
* (Android 的设计 —— 只有用户能改,App 改不动)。2026-10-07 主人明确要求「要横幅」,而把重要级
|
||||
* 从 DEFAULT 提到 HIGH **对已经建过旧渠道的设备不生效** ⇒ 只能换一个新 id,让系统按新重要级
|
||||
* 重新建一条。旧渠道由 [ensureProactiveChannel] 顺手删掉(它的重要级改不动,留着只会让系统设置里
|
||||
* 多一条永远不再出现的条目)。
|
||||
*
|
||||
* **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是
|
||||
* [PROACTIVE_IMPORTANCE](**要提醒人**)。两者混在一条渠道里,就只能在「保活开始响铃」与
|
||||
* 「她的话不响铃」之间二选一——那正是「一个渠道只能是其中一件事」的由来。
|
||||
* [PROACTIVE_IMPORTANCE]。两者混在一条渠道里,就只能在「保活开始响铃」与「她的话不响铃」之间
|
||||
* 二选一——那正是「一个渠道只能是其中一件事」的由来。
|
||||
*
|
||||
* 与 [QUIET_PROACTIVE_CHANNEL_ID] **也是两条**,理由与上面那条一模一样:横幅由渠道重要级决定,
|
||||
* 而重要级建完就改不了 ⇒ 设置页那个「免打扰」开关**不可能**靠运行时改设置实现,只能按开关选一条
|
||||
* 渠道发(见 [proactiveChannelId])。
|
||||
*/
|
||||
internal const val PROACTIVE_CHANNEL_ID = "proactive"
|
||||
internal const val PROACTIVE_CHANNEL_ID = "proactive_v2"
|
||||
|
||||
/**
|
||||
* 渠道重要级:`DEFAULT`(有提示音、不弹全屏横幅)。
|
||||
* 「她主动说的话」在**免打扰**下走的那条渠道 id:重要级是 [QUIET_PROACTIVE_IMPORTANCE]
|
||||
* (有提示音、通知栏有条目、**不弹横幅**)。
|
||||
*
|
||||
* 这一条**是**要来提醒人的(她主动找我说话),所以不用 LOW;也不升到 HIGH——HIGH 会在屏幕顶部
|
||||
* 弹横幅盖住用户正在做的事,而这句话并没有急到那个程度。
|
||||
* 为「免打扰」单独建**第二条**渠道,是因为**重要级建完就改不了**(理由见 [PROACTIVE_CHANNEL_ID])
|
||||
* ——想「只关横幅」就只有换渠道这一条路。
|
||||
*
|
||||
* 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改),所以这里必须是
|
||||
* 最终值。`ProactiveNotificationTest` 把这两个值钉住。
|
||||
* ⚠️ 刻意**不用** `setSilent(true)`:那是**完全静音**(连提示音都没),而用户要的语义是
|
||||
* 「只在通知栏提示,别在屏幕顶部弹横幅」——两者之间差着一个提示音,不是同一件事。
|
||||
*/
|
||||
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
|
||||
internal const val QUIET_PROACTIVE_CHANNEL_ID = "proactive_quiet"
|
||||
|
||||
/**
|
||||
* 上一个渠道 id([PROACTIVE_CHANNEL_ID] 的前身)。**只在建渠道时用于删除**:它的重要级是
|
||||
* DEFAULT 且改不动,留着毫无用处。
|
||||
*/
|
||||
internal const val LEGACY_PROACTIVE_CHANNEL_ID = "proactive"
|
||||
|
||||
/**
|
||||
* 弹横幅那条渠道的重要级:**`HIGH`**(屏幕顶部弹横幅 + 提示音)。
|
||||
*
|
||||
* 这一条**是**来提醒人的 —— 她主动找我说话的那一刻,我显然不在跟她聊天(App 不在前台),
|
||||
* 正是最需要在屏幕顶部弹一下的场景。DEFAULT 只会响一声、安静躺在通知栏里,很容易错过
|
||||
* (主人 2026-10-07 的原话:「我在其他 APP 时没看到横幅」)。
|
||||
*
|
||||
* 想安静的用户可以在系统设置里单独把这条渠道调低、或关掉它的横幅 —— 那是用户的权利,
|
||||
* 不是我们对他的默认;应用内的那条捷径是设置页的「免打扰」,它把通知改发到
|
||||
* [QUIET_PROACTIVE_CHANNEL_ID]。
|
||||
*
|
||||
* ⚠️ 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改)⇒ **改这个常量
|
||||
* 对已建过渠道的设备不生效**,必须同时换 [PROACTIVE_CHANNEL_ID]。`ProactiveNotificationShapeTest`
|
||||
* 把这两个值钉住。
|
||||
*/
|
||||
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_HIGH
|
||||
|
||||
/**
|
||||
* 免打扰那条渠道的重要级:**`DEFAULT`**(提示音 + 通知栏条目,**没有横幅**)。
|
||||
*
|
||||
* `HIGH`(4) 才是屏幕顶部弹横幅的门槛,所以 DEFAULT 正是「她的话照旧说、只是不盖住我正在做的事」
|
||||
* 那一档。
|
||||
*
|
||||
* ⚠️ 与上面那条同理:**改这个常量必须同时换 [QUIET_PROACTIVE_CHANNEL_ID]**,否则对已经建过它的
|
||||
* 设备一个字节都不生效。这个方向(往安静改)出错时用户的感受只是「开关按了没反应」——不会有任何
|
||||
* 报错,所以这条纪律只能靠常量与测试钉住。
|
||||
*/
|
||||
internal const val QUIET_PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
|
||||
|
||||
/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */
|
||||
internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
|
||||
@@ -40,6 +85,67 @@ internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
|
||||
*/
|
||||
internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话"
|
||||
|
||||
/**
|
||||
* 免打扰那条渠道的渠道名。**必须一眼看出与 [PROACTIVE_CHANNEL_NAME] 的区别**:系统设置的渠道列表
|
||||
* 里这两行会并排出现,用户就是靠这一行判断「哪条会弹横幅」的——同名的话两条渠道就白分了
|
||||
* (他只能点进去、一条条试着看)。
|
||||
*/
|
||||
internal const val QUIET_PROACTIVE_CHANNEL_NAME = "她主动说的话(免打扰:只在通知栏)"
|
||||
|
||||
/** 弹横幅那条渠道的描述(点进渠道详情才看得到)。 */
|
||||
internal const val PROACTIVE_CHANNEL_DESCRIPTION = "她主动找你说话时的通知"
|
||||
|
||||
/**
|
||||
* 免打扰那条渠道的描述。把「不弹横幅」与「还有提示音」都说清:用户点进来想知道的是**它到底静到
|
||||
* 什么程度**,而「免打扰」四个字很容易被读成「完全没有声音」——那是另一件事(也正是用户会去系统
|
||||
* 设置里翻、翻完还是不确定的那一件事)。
|
||||
*/
|
||||
internal const val QUIET_PROACTIVE_CHANNEL_DESCRIPTION =
|
||||
"她主动找你说话时的通知(只在通知栏提示、不弹横幅;仍有提示音)"
|
||||
|
||||
/**
|
||||
* 一条 proactive 渠道的完整定义:**id / 渠道名 / 重要级 / 描述一次给全**。
|
||||
*
|
||||
* 为什么要打包成一个值:这四样必须**成对地**跟着「免打扰」这个布尔走(换了 id 却没换重要级 =
|
||||
* 开关没生效;换了重要级却没换 id = 对老设备没生效)。散成四个各自 `if (quiet)` 就是四份口径,
|
||||
* 而它们分叉时的症状全都是「用户按了开关,什么都没变」——最难被发现的那种坏法。
|
||||
*/
|
||||
internal data class ProactiveChannelSpec(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val importance: Int,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 「免打扰」→ 这一次该用哪条渠道 —— **纯函数**(不含任何 Android 调用,两档可穷举)。
|
||||
*
|
||||
* @param quiet 设置页那个「免打扰」开关**当下的值**(`false` = 都弹,也是默认)。
|
||||
*/
|
||||
internal fun proactiveChannelSpec(quiet: Boolean): ProactiveChannelSpec = if (quiet) {
|
||||
ProactiveChannelSpec(
|
||||
id = QUIET_PROACTIVE_CHANNEL_ID,
|
||||
name = QUIET_PROACTIVE_CHANNEL_NAME,
|
||||
importance = QUIET_PROACTIVE_IMPORTANCE,
|
||||
description = QUIET_PROACTIVE_CHANNEL_DESCRIPTION,
|
||||
)
|
||||
} else {
|
||||
ProactiveChannelSpec(
|
||||
id = PROACTIVE_CHANNEL_ID,
|
||||
name = PROACTIVE_CHANNEL_NAME,
|
||||
importance = PROACTIVE_IMPORTANCE,
|
||||
description = PROACTIVE_CHANNEL_DESCRIPTION,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发通知那一刻用哪条渠道 id —— **纯函数**,就是「同一个开关、两条渠道」这件事的全部落地形状。
|
||||
*
|
||||
* 单独再露一层是因为调用方要的正好只是这个 id(`NotificationCompat.Builder(context, ???)`),
|
||||
* 而测试要问的也只是这一个问题:「开关两档各发去哪条」。
|
||||
*/
|
||||
internal fun proactiveChannelId(quiet: Boolean): String = proactiveChannelSpec(quiet).id
|
||||
|
||||
/**
|
||||
* 点通知进来时那个「切到主会话」的 Intent extra。
|
||||
*
|
||||
@@ -52,15 +158,20 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR
|
||||
/**
|
||||
* 弹一条「她主动说的话」的通知。
|
||||
*
|
||||
* 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、去重与 id 的派生
|
||||
* 都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)——本函数被调用就
|
||||
* 意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
|
||||
* 只做四件事:按 [quiet] 选渠道、建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、
|
||||
* 去重与 id 的派生都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)
|
||||
* ——本函数被调用就意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
|
||||
*
|
||||
* ## 为什么 id 是入参而不是这里恒定一个
|
||||
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
|
||||
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
|
||||
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
|
||||
*
|
||||
* ## 免打扰只换渠道,别的一个字节都不动
|
||||
* [quiet] 为真时整条通知除了「发去哪条渠道 / 7.x 的优先级」以外**逐字节不变**:同一个标题、同一
|
||||
* 段正文、同一个通知号、同一次点击行为。用户要的是「别在我正做事的时候盖住屏幕」,不是「别让我
|
||||
* 知道」——所以什么都不该少,只是她的话安静地躺在通知栏里。
|
||||
*
|
||||
* ## 没授通知权限时什么都不做
|
||||
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
|
||||
* 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有
|
||||
@@ -74,11 +185,24 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR
|
||||
*
|
||||
* @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动:
|
||||
* 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。
|
||||
* @param quiet 「免打扰」开关**当下的值**:`true` ⇒ 发到 [QUIET_PROACTIVE_CHANNEL_ID](有提示音、
|
||||
* 不弹横幅);`false` ⇒ 发到 [PROACTIVE_CHANNEL_ID](弹横幅)。**刻意不给默认值**:调用方忘写
|
||||
* 这一位时症状是「开关按了没反应」,那种坏法没有任何报错——让编译器拦住它。
|
||||
* 值由调用方每次**现读**(见 `AppContainer` 里 `post` 那个 lambda),所以开关一改不必重启 App,
|
||||
* 下一条通知就走新渠道。
|
||||
*/
|
||||
internal fun postProactiveNotification(context: Context, text: String, notificationId: Int) {
|
||||
internal fun postProactiveNotification(
|
||||
context: Context,
|
||||
text: String,
|
||||
notificationId: Int,
|
||||
quiet: Boolean,
|
||||
) {
|
||||
if (!context.notificationsAllowed()) return
|
||||
|
||||
ensureProactiveChannel(context)
|
||||
// 选渠道与建渠道用的是**同一个** spec:两处各自 `if (quiet)` 就会有一条渠道永远建不出来,
|
||||
// 而它的症状是「开了免打扰之后她再也不通知我了」(Android 8+ 对不存在的渠道是静默丢弃)。
|
||||
val channel = proactiveChannelSpec(quiet)
|
||||
ensureProactiveChannel(context, channel)
|
||||
|
||||
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
|
||||
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
|
||||
@@ -91,7 +215,7 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, PROACTIVE_CHANNEL_ID)
|
||||
val notification = NotificationCompat.Builder(context, channel.id)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle(PROACTIVE_NOTIFICATION_TITLE)
|
||||
.setContentText(text)
|
||||
@@ -102,7 +226,10 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
|
||||
// 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
// 横幅:Android 8+ 由**渠道重要级**决定(上面那条 spec 选出来的那条渠道),这一句管的是
|
||||
// 7.x 及更早(那边还没有渠道,优先级就是全部)。两处必须同向——免打扰那一档在这里也得
|
||||
// 降回 DEFAULT,否则老设备上「开着免打扰却照样弹横幅」。
|
||||
.setPriority(if (quiet) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
|
||||
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
|
||||
@@ -111,20 +238,40 @@ internal fun postProactiveNotification(context: Context, text: String, notificat
|
||||
}
|
||||
|
||||
/**
|
||||
* 建(或更新)渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的渠道列表里不会平白多出
|
||||
* 一条「她的话」。
|
||||
* 建(或更新)**这一次要用的那一条**渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的
|
||||
* 渠道列表里不会平白多出一条「她的话」。
|
||||
*
|
||||
* ## 为什么只建「这一次要用的那一条」,而不是两条都建
|
||||
* 「免打扰」决定的是**下一条**通知发去哪条渠道,而渠道只需要在 `notify` 之前存在。两条都建的话,
|
||||
* 一个从没开过免打扰、将来也不打算开的用户,系统设置里会平白多出一条他从没见过的「她主动说的话
|
||||
* (免打扰:只在通知栏)」——那是我们替他做的选择,也算脏数据。所以这里只 ensure 被选中的那一条。
|
||||
*
|
||||
* ⚠️ 但**必须在 `notify` 之前** ensure:Android 8+ 上目标渠道不存在时,那条通知是被系统**静默
|
||||
* 丢弃**的(不报错、不打日志、只是什么都没有)。这正是「只建一条」这个省法的代价 —— 它靠
|
||||
* [postProactiveNotification] 里「选渠道」与「建渠道」用的是**同一个** [ProactiveChannelSpec]
|
||||
* 兜住(两处各自 `if (quiet)` 就会有一条渠道永远建不出来,而症状是「开了免打扰之后她再也不
|
||||
* 通知我了」——用户只会以为是她不说话了)。
|
||||
*
|
||||
* ## 另一条渠道**不删**
|
||||
* 它是「这一次没选」,不是「以后不再用」:用户来回切开关时,他在系统设置里对另一条调过的横幅 /
|
||||
* 静音设置得留着(删掉 = 把他的选择一起删掉,还得重新建一条空白的)。唯一会删的是
|
||||
* [LEGACY_PROACTIVE_CHANNEL_ID] —— 那条 id 本端**再也不会发**,而它的重要级又改不动,留着只会
|
||||
* 在系统设置里多一条永远不再出现的条目(用户可能在那儿调半天、却发现怎么调都没变化)。
|
||||
*
|
||||
* 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效**
|
||||
* ——它已经在 [PROACTIVE_IMPORTANCE] 上定死。
|
||||
* ——它已经在 [PROACTIVE_IMPORTANCE] / [QUIET_PROACTIVE_IMPORTANCE] 上定死(渠道名与描述此后仍
|
||||
* 可以改,重要级改不了)。
|
||||
*
|
||||
* @param spec 这一次要用的那条渠道(由 [proactiveChannelSpec] 按「免打扰」开关选出来)。
|
||||
*/
|
||||
private fun ensureProactiveChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
PROACTIVE_CHANNEL_ID,
|
||||
PROACTIVE_CHANNEL_NAME,
|
||||
PROACTIVE_IMPORTANCE,
|
||||
).apply {
|
||||
description = "她主动找你说话时的通知"
|
||||
private fun ensureProactiveChannel(context: Context, spec: ProactiveChannelSpec) {
|
||||
val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply {
|
||||
description = spec.description
|
||||
setShowBadge(true)
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
// 旧的 DEFAULT 渠道删掉:重要级改不动,留着只会在系统设置里多一条永远不再出现的条目
|
||||
// (用户可能在那儿调半天、却发现怎么调都没变化)。删一条不存在的渠道是幂等的。
|
||||
manager.deleteNotificationChannel(LEGACY_PROACTIVE_CHANNEL_ID)
|
||||
}
|
||||
@@ -32,10 +32,12 @@ enum class ProactiveNotifyOutcome {
|
||||
/**
|
||||
* 「她主动说的话」的通路(W53N):**判据 + 去重 + 落地**收在一处,Android 那一步由 [post] 注入。
|
||||
*
|
||||
* ## 它凭什么能看见两条路
|
||||
* ## 谁把帧喂进来(W57 起)
|
||||
* 后端两条路(实时帧 `proactive_message`、落库帧 `message_persisted{proactive:true}`)在客户端
|
||||
* 汇于一处:`SessionsViewModel` 的帧订阅(它持有整个会话列表,两条帧它本来就都在消费)。本类
|
||||
* 因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android。
|
||||
* 汇于一处:**进程级**的帧消费点 `app.ProactiveFrameIntake` 订着那条共享连接,把这两种帧折成下面
|
||||
* [onProactiveFrame] 的四个入参喂过来(同一条消息无论走哪条路、到几次,都只经过这一处)。
|
||||
* 本类因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android——测试里传个
|
||||
* 假的 [post] 就能把它整条驱动起来。
|
||||
*
|
||||
* ## 它绝不碰连接
|
||||
* 连接的所有权在登录态(`SessionsViewModel` 那处 `LoggedIn → connect()`)。本类只在帧**已经到达
|
||||
|
||||
@@ -8,8 +8,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
|
||||
|
||||
@@ -72,6 +78,23 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
else -> InterruptMode.AppendQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* 「免打扰」([AppPrefs.proactiveQuiet])没有落盘值时的取值 —— **`false` = 都弹**(横幅 + 提示音
|
||||
* + 通知栏条目)。老安装、或从没碰过这个开关的用户走的都是它。
|
||||
*
|
||||
* 两条路必须落在**同一个**值上:[AppPrefs] 那个数据类的默认参数(进程刚起来、还没读到盘时用),
|
||||
* 与 [toProactiveQuiet](盘上没有这个键时用)。分叉的症状是「开关显示关着、行为却是安静的」
|
||||
* ——一个默认就安静的 App 会让人以为通知坏了。
|
||||
*/
|
||||
internal const val PROACTIVE_QUIET_DEFAULT = false
|
||||
|
||||
/**
|
||||
* 落盘布尔 → 免打扰开关(`AppSettings` 的 `proactive_quiet` 键读取口;抽成顶层函数是为了能单测
|
||||
* ——`AppSettings` 本体要 Android + DataStore,单测里起不来)。缺省 / 盘上没有这个键
|
||||
* ⇒ [PROACTIVE_QUIET_DEFAULT]。
|
||||
*/
|
||||
internal fun Boolean?.toProactiveQuiet(): Boolean = this ?: PROACTIVE_QUIET_DEFAULT
|
||||
|
||||
/**
|
||||
* App 级偏好(非机密)。
|
||||
*
|
||||
@@ -81,6 +104,10 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
|
||||
* @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
|
||||
* `core.keepalive.shouldKeepAliveRun`)
|
||||
* @param proactiveQuiet 免打扰:她主动找你说话**只在通知栏提示,不在屏幕顶部弹横幅**(**默认
|
||||
* `false` = 都弹**)。⚠️ 它的语义**不是静音**:提示音与通知栏条目照旧,只是不盖住用户正在做的
|
||||
* 事。落地形状是**换一条渠道**(见 `core.notify.proactiveChannelId`)——横幅由渠道重要级决定,
|
||||
* 而重要级建完就改不了,所以只能两条渠道切着用
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
@@ -88,6 +115,7 @@ data class AppPrefs(
|
||||
val thinkingExpanded: Boolean = true,
|
||||
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
|
||||
val keepAliveEnabled: Boolean = false,
|
||||
val proactiveQuiet: Boolean = false,
|
||||
)
|
||||
|
||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
||||
@@ -97,6 +125,13 @@ class AppSettings(context: Context) {
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
|
||||
|
||||
/**
|
||||
* 与 `AppSettings` 同寿的收集 scope —— 也就是**与进程同寿**(`AppContainer` 在
|
||||
* `Application.onCreate` 里建它)。刻意**不取消**:[current] 的下一个读者可以是任何时刻的
|
||||
* 任何一次通知落地,没有比进程结束更晚的时刻需要它。
|
||||
*/
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val data: Flow<AppPrefs> = store.data.map { prefs ->
|
||||
AppPrefs(
|
||||
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
|
||||
@@ -104,9 +139,31 @@ class AppSettings(context: Context) {
|
||||
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
|
||||
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
|
||||
keepAliveEnabled = prefs[KEY_KEEP_ALIVE] ?: false,
|
||||
proactiveQuiet = prefs[KEY_PROACTIVE_QUIET].toProactiveQuiet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [data] 的**热副本**:进程里任何地方都读得到「当下是什么偏好」。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
* 发通知那一刻**没有挂起上下文**:帧的落地是一次同步回调(`ProactiveNotifier.post` 不是
|
||||
* `suspend`,也不该是——它是连接那条已建立的流水线上的最后一跳),而 DataStore 的读取是挂起
|
||||
* 的。所以只能由这里常驻订阅、把最近一次读数摆在一个同步读得到的地方(`StateFlow.value`)。
|
||||
*
|
||||
* ## 为什么是 `Eagerly`
|
||||
* 它的读者(发通知那条路)随时可能来,而「等有人订阅了再开始读」意味着第一次落地时读到的是
|
||||
* 初始值。`Eagerly` 让这一次读盘在设置对象构造时就发生 —— 与 UI 那边(`CyreneRoot` /
|
||||
* `SettingsScreen` 各自 `collectAsState`)读的是同一份上游数据,没有多出一次磁盘 IO 的量级。
|
||||
*
|
||||
* ⚠️ 它有一个**毫秒级**的不准确窗口:进程刚起来、第一次读盘还没回来时,[StateFlow.value] 是
|
||||
* [AppPrefs] 的默认值(免打扰 = false ⇒ 弹横幅)。要撞上它,得在「本对象构造完 → DataStore
|
||||
* 第一次读完」之间就有一条她的主动消息走完建连、认证、收帧(`ProactiveFrameIntake`)——
|
||||
* 实际上到不了那么快。**代价封顶是「开了免打扰的用户在冷启动瞬间多看到一次横幅」**,
|
||||
* 而不是丢通知。
|
||||
*/
|
||||
val current: StateFlow<AppPrefs> = data.stateIn(scope, SharingStarted.Eagerly, AppPrefs())
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
store.edit { it[KEY_THEME_MODE] = mode.name }
|
||||
}
|
||||
@@ -127,6 +184,15 @@ class AppSettings(context: Context) {
|
||||
store.edit { it[KEY_KEEP_ALIVE] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* 写「免打扰」。**写这一处就够了**:发通知那条路每次现读 [current](见它的 KDoc),所以这里
|
||||
* 一落盘、DataStore 一发出新值,**下一条**通知就走新渠道——不必重启 App,也不必在这里再去
|
||||
* 通知谁。
|
||||
*/
|
||||
suspend fun setProactiveQuiet(enabled: Boolean) {
|
||||
store.edit { it[KEY_PROACTIVE_QUIET] = enabled }
|
||||
}
|
||||
|
||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
||||
ThemeMode.Light.name -> ThemeMode.Light
|
||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
||||
@@ -139,5 +205,6 @@ class AppSettings(context: Context) {
|
||||
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
||||
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
|
||||
val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled")
|
||||
val KEY_PROACTIVE_QUIET = booleanPreferencesKey("proactive_quiet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Streaming
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* 语音副本的字节读取(契约 **v0.79 已实现**,api-client.md §1.2 **U19**
|
||||
* `GET /api/v1/media/{name}`)。
|
||||
*
|
||||
* ## 鉴权:**必须** Bearer `owner|admin`
|
||||
* 与 `GET /api/v1/documents/{name}` 同一套门,与图片读侧「128-bit 名即凭据」**刻意相反**
|
||||
* ——语音是私密内容,那个 URL 不能直接贴进 `<audio src>`。Bearer 不在本接口上做任何事:
|
||||
* [ApiClientFactory.create] 按 **OkHttpClient** 挂 `AuthInterceptor`,只要本接口的实例是从
|
||||
* 那里建的(`AppContainer.mediaApi` 的写法),请求就自带令牌、并享受 401 单飞续期。
|
||||
*
|
||||
* ## 为什么参数是一整条 URL 而不是 `@Path name`
|
||||
* 契约把相对路径 `/api/v1/media/<name>` 定成**服务端唯一的派生字段**(`voice.url`),并明写
|
||||
* 客户端**绝不自己拼路径**。所以调用方把**服务端给的那条路径接到当前服务器地址上**
|
||||
* (纯函数 `domain.chat.voiceMediaUrl`)再传进来——本接口一个字都不拼,只发出去。
|
||||
*
|
||||
* @see MediaRepository
|
||||
*/
|
||||
interface MediaApi {
|
||||
|
||||
/**
|
||||
* 取一条语音副本的字节。
|
||||
*
|
||||
* 响应**不是信封**(就是音频本身),所以返回 [ResponseBody] 由仓库层读字节并负责关闭。
|
||||
* [Streaming] 让 Retrofit 不把响应体一次性读进内存。
|
||||
*
|
||||
* ⚠️ 服务端的 `416`(`Range` 不可满足)是**标准库直写**的 `text/plain` + `Content-Range`,
|
||||
* **不套** `{"error":{…}}`(契约 §0.3 / §4.5)——本端不发 `Range`、也不拿 `error.code`
|
||||
* 去解析它,`ApiErrorMapper` 对这种非信封响应的处理是「说不出就不编」。
|
||||
*/
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun download(@Url url: String): Response<ResponseBody>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
/**
|
||||
* 一段待朗读的文本(`POST /api/v1/audio/speech` 的请求体,api-client.md §1.2 U6 / 行 126)。
|
||||
*
|
||||
* ## 只有两个字段,是**刻意的**
|
||||
* 契约给了五个键 `{text, voice?, format?, instruction?, sessionId?}`,但其中三个在本端**不生效**:
|
||||
* - `voice`:接受但不生效 —— 发音人**不由它决定**;
|
||||
* - `format`:不生效 —— 产物恒为 WAV(响应也明说「别照抄 `Content-Type`」,见 [SpeechApi]);
|
||||
* - `instruction`:只影响表达方式,本端当前的产品口径里没有「怎么说」这一档输入。
|
||||
*
|
||||
* 发一个服务端会忽略的键,只会让排查时多一条**看起来有含义、实际没有**的字段。所以这里只发
|
||||
* 真正起作用的那两个 —— 尤其是 [sessionId]:发音人由它**那条会话的生效人格**解析
|
||||
* (会话绑定人格 → 全局默认人格 → 都没有就取服务端配置的 default),**省略它不等于没有角色**。
|
||||
*
|
||||
* [sessionId] 为 `null` 时字段**整个缺席**(`ApiClientFactory.json` 的 `explicitNulls = false`),
|
||||
* 而不是发一个 `"sessionId": null`。
|
||||
*/
|
||||
@Serializable
|
||||
data class SpeechRequestDto(
|
||||
@SerialName("text") val text: String,
|
||||
@SerialName("sessionId") val sessionId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 语音合成(契约 **v0.96 增量 / v0.97 补写**,api-client.md §1.2 **U6** `POST /api/v1/audio/speech`)。
|
||||
*
|
||||
* ## 鉴权:owner-only,双凭据门
|
||||
* `Authorization: Bearer <owner JWT>` **或** `X-API-Key`,两个分支在服务端落到同一个
|
||||
* `authorize(RoleOwner)`。本端只走前者(主人 2026-10-26 拍板:不设 API Key 输入框),令牌由
|
||||
* [ApiClientFactory.create] 挂在 OkHttpClient 上的 `AuthInterceptor` 自动注入,本接口不碰它。
|
||||
* 无 / 无效凭据 → `401`,错误体是 §4.1 那个 `{error:{code,message}}` 信封。
|
||||
*
|
||||
* ## 响应**不是信封**
|
||||
* 成功是 `200` + **原始音频字节**(与 [MediaApi] 同一个形状),所以返回 [ResponseBody] 由上层
|
||||
* 读字节并负责关闭,[Streaming] 让它不一次性读进内存。
|
||||
*
|
||||
* ⚠️ **产物恒为 WAV,按字节嗅探判定,不要照抄 `Content-Type`** —— 那条头的实际值不在契约里
|
||||
* 承诺(§4.1 行 561 的同句旁注就是这个意思)。嗅探在 [top.yeij.cyrene.data.voice.SpeechClient]。
|
||||
*
|
||||
* 失败时的错误码(全部是 `{error:{code}}` 信封):
|
||||
* `400`(空 text)、`500 voice_unavailable`(未接语音)、`500 tts_failed`、
|
||||
* `500 tts_persona_unmapped`(人格没有发音人映射)、`500 tts_no_character`(连生效人格都没有)、
|
||||
* `500 tts_empty`(拿到的是静默空音频,同时响应头带 `X-Voice-Provider: silent`)。
|
||||
*
|
||||
* @see top.yeij.cyrene.data.voice.SpeechClient
|
||||
*/
|
||||
interface SpeechApi {
|
||||
|
||||
/**
|
||||
* 把 [body] 合成为一段 WAV 字节。
|
||||
*
|
||||
* 路径**不带前导 `/`**(与 [ChatApi] 一致):Retrofit 的 `baseUrl` 已含 `/` 结尾,
|
||||
* 带前导 `/` 会把它当成根路径、把部署在子路径下的服务端地址整段丢掉。
|
||||
*/
|
||||
@Streaming
|
||||
@POST("api/v1/audio/speech")
|
||||
suspend fun speech(@Body body: SpeechRequestDto): Response<ResponseBody>
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
|
||||
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
|
||||
*
|
||||
* [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
|
||||
* `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
|
||||
* (前提与理由写在 [voice] 的 KDoc 里)。
|
||||
*/
|
||||
@Serializable
|
||||
data class HistoryMessageDto(
|
||||
@@ -59,6 +63,38 @@ data class HistoryMessageDto(
|
||||
* 回放 / `channel_persisted`(本端不消费最后一条)。
|
||||
*/
|
||||
val turnId: String? = null,
|
||||
/**
|
||||
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]);
|
||||
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事。
|
||||
*
|
||||
* ## 判据是**两个**问题,别再合成一个
|
||||
* - 「这条**来自**一段声音吗」= 这个键在不在(`domain.chat.hasVoice`);
|
||||
* - 「要不要画语音形态」= 键在 **且** `status != "failed"`(`domain.chat.isVoiceMessage`)。
|
||||
* 后端 M57-M59 的出站合成降级行(`{source:"tts", status:"failed", fallbackReason}`)与入站的
|
||||
* 转写失败行都是**键在、但没有可播的语音**:正文照常按文字渲染,另加一行灰色小标
|
||||
* (`domain.chat.voiceFallbackNote`),**不画**语音形态。按旧的单条判据(键在即语音)它们会
|
||||
* 被画成一个点下去必然播不了的气泡。
|
||||
*
|
||||
* ## 覆盖率(契约 v0.97):**全部读面都带**,第一条帧就带
|
||||
* `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
|
||||
* `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与
|
||||
* `message_edited` 广播帧,**外加** WS 握手 `history` 回放与 `message_persisted` /
|
||||
* `channel_persisted` 落库帧——后三处由后端 M44(`fb55769`)修好:落库顺序改为
|
||||
* **INSERT → 写 `voice` 列 → notify**,于是语音消息的**第一条**落库帧就带 `voice`
|
||||
* (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。
|
||||
*
|
||||
* 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底:
|
||||
* `voice` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。
|
||||
*
|
||||
* ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify;
|
||||
* ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
|
||||
* 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。
|
||||
*
|
||||
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
|
||||
* 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一
|
||||
* 诚实的表达(与 [documents] 回一条 `unreadable` 不同)。
|
||||
*/
|
||||
val voice: VoiceDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**,
|
||||
* `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。
|
||||
*
|
||||
* ## 🎤 判据 = 本键存在 **且** `status != "failed"`
|
||||
* `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。
|
||||
* 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]`
|
||||
* 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。
|
||||
*
|
||||
* 键在只说明「这条来自一段声音」;要不要画语音形态还看 `status`(见下)。
|
||||
* 判据落在一处:`domain.chat.isVoiceMessage`(`domain.chat.hasVoice` 答的是前半个问题)。
|
||||
*
|
||||
* ## 三态 `status`(闭集)
|
||||
* `transcribed`(正文即转写文本;出站方向 = 合成成功)/ `empty`(转写成功但没识别出文字)/
|
||||
* `failed`(**没有可播的语音**——入站没拿到转写文本,出站是合成降级回文字)。客户端**按 `status`
|
||||
* 分支,绝不按正文文案分支**;设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 /
|
||||
* 限流 / 超时 / 格式不支持 / 无字节一律只进服务端日志),所以本端**不猜**细分。
|
||||
*
|
||||
* ## `fallbackReason` 只在 `failed` 时有
|
||||
* 出站方向(`source = "tts"`)合成降级时,服务端在这个键里给出降级原因;本端**原样**小字展示
|
||||
* (`domain.chat.voiceFallbackNote`),**不猜闭集**——取值集合后端尚未定稿,见那个函数的 KDoc。
|
||||
*
|
||||
* ## `name` / `mime` / `size` / `url` 四者同生共死
|
||||
* 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。
|
||||
* 反过来说:**四个键没凑齐就不要画播放控件**——不能给一个点下去必然失败的按钮。
|
||||
* 判据落在一处:`domain.chat.voiceAudio`。一个真实的无副本例子是微信(`weixin-ilink`):
|
||||
* 平台自带转写就**不白下载那份音频**,于是 `source` 是 `platform` 而四个键全缺席
|
||||
* (**只有 🎤 标记**,不可回放)。
|
||||
*
|
||||
* ## `durationMs` 只有渠道如实给出时才有
|
||||
* 服务端「读不出来就省略键,**绝不估算**」。本端同理:**缺席就不显示时长**
|
||||
* (不拿 size / 码率反算,也不显示 `0:00` 冒充)。所以它是可空的,且 `<= 0` 一并当缺席
|
||||
* (见 `domain.chat.voiceDurationLabel`)。
|
||||
*
|
||||
* ## `url` 是**服务端相对路径**,客户端绝不自己拼
|
||||
* 取值形如 `/api/v1/media/<name>`,由服务端一处拼出(**唯一派生字段**)。
|
||||
* 客户端要取字节时把它接到**当前服务器地址**后面(`domain.chat.voiceMediaUrl`)——
|
||||
* 与图片那条「保留 path、替换 host」同一取向,但这里服务端给的就是相对路径,不需要换 host。
|
||||
*/
|
||||
@Serializable
|
||||
data class VoiceDto(
|
||||
/**
|
||||
* `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的
|
||||
* `voice_item.text`)/ `"tts"`(我们自己合成的出站语音,后端 M57-M59)。
|
||||
* 见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
|
||||
*
|
||||
* 接收侧**不按 `source` 分支**:合成成功的那条与入站语音同形(`status = transcribed` +
|
||||
* 四键副本),走同一条渲染路径。
|
||||
*/
|
||||
val source: String = "",
|
||||
/**
|
||||
* `"transcribed"` / `"empty"` / `"failed"`(闭集三态,见 `domain.chat.VoiceStatus`)。
|
||||
*
|
||||
* 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值
|
||||
* (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。
|
||||
*
|
||||
* ⚠️ 只有 `"failed"` 是**没有可播的语音**的那一态(`domain.chat.isPlayableVoice`);认不出的
|
||||
* 取值一律当可播(fail-open,理由写在那个函数的 KDoc 里)。
|
||||
*/
|
||||
val status: String = "",
|
||||
/** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */
|
||||
val name: String? = null,
|
||||
/** 副本的 MIME(按存储名扩展名映射)。与 [name] / [size] / [url] 同生共死。 */
|
||||
val mime: String? = null,
|
||||
/** 副本的字节数。与 [name] / [mime] / [url] 同生共死;**不用它反算时长**。 */
|
||||
val size: Long? = null,
|
||||
/** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */
|
||||
val durationMs: Int? = null,
|
||||
/**
|
||||
* 合成降级的原因(后端 M57-M59 预告,`devdocs/02` 已登记;契约 **v0.98 的正式回执尚未到**)。
|
||||
*
|
||||
* 只在 `status == "failed"` 时有意义,其余状态下服务端不会下发(`omitempty`);入站转写失败
|
||||
* 也不带它——入站的失败原因由正文那行诚实提示承载。
|
||||
*
|
||||
* 本端**原样**小字展示(`domain.chat.voiceFallbackNote`),**不猜闭集、不做映射**:取值集合
|
||||
* 后端还没定(`tts_timeout` 之类是我们看到的预告示例,不是契约)。缺席 / 空白时那一行小标
|
||||
* 退化成固定的一句(`VOICE_FALLBACK_NOTE`),不白屏。
|
||||
*/
|
||||
val fallbackReason: String? = null,
|
||||
/** 副本的服务端相对路径(`/api/v1/media/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
|
||||
val url: String? = null,
|
||||
)
|
||||
@@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext
|
||||
* 三条读取出口共用同一个端点,只是给的查询参数不同:
|
||||
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
|
||||
* - [search]:`q` 会话内搜索(也是倒序);
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
|
||||
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
|
||||
*
|
||||
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
|
||||
*/
|
||||
@@ -87,7 +88,9 @@ class MessageRepository(
|
||||
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
|
||||
*
|
||||
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
|
||||
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
|
||||
* 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
|
||||
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
|
||||
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
|
||||
*/
|
||||
suspend fun loadAfter(
|
||||
sessionId: String,
|
||||
@@ -100,6 +103,87 @@ class MessageRepository(
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/**
|
||||
* **增量追尾**(W77):从 [after] 起把某会话的新消息一直翻到尾巴,最多 [maxPages] 页。
|
||||
*
|
||||
* 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。
|
||||
*
|
||||
* ## 什么时候只能说 `complete = false`
|
||||
* 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于:
|
||||
* `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被
|
||||
* 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次也问不到
|
||||
* (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。
|
||||
*
|
||||
* 要紧的是**切点**:只有当服务端的 `LIMIT` 在某一毫秒中间切过一刀,才会留下追不回来的尾巴。
|
||||
* 客户端看不见那一刀落在哪儿,只知道自己**拿过满页**就等于「发生过一次切点」。所以这里只有一条
|
||||
* 不变式,下面几档都是它的推论:
|
||||
*
|
||||
* > `complete = true` **当且仅当整个追尾从未拿过满页**。
|
||||
*
|
||||
* - **第一页就是空页 ⇒ `true`**:一条新消息都没有,没有可少给的;
|
||||
* - **第一页就不满 ⇒ `true`**:`LIMIT` 没切到任何东西,`> cursor` 的行全在这一页里;
|
||||
* - **拿过整页之后,下一页是空页 ⇒ `false`**:那次空页分不出「干净的尾巴」与「空页底下还压着
|
||||
* 同一毫秒的几条」;
|
||||
* - **拿过整页之后,下一页不满 ⇒ 也是 `false`**:不满只说明「`> cursor` 的行就这么多了」,对
|
||||
* 切点那一毫秒还有没有行**一无所知**。例如积压 `m1@500 m2@500 m3@500 m4@600`、页宽 2:
|
||||
* 第一页给 `m1 m2`(切点正好劈开 500 那一毫秒),第二页 `after=500` 只给 `m4@600`、不满页,
|
||||
* 而 `m3@500` 既不 `> 500`、也就永远拿不到;
|
||||
* - **翻满 [maxPages] ⇒ `false`**。
|
||||
*
|
||||
* 代价:积压一到页宽(默认 50 条)就回落整表。这与「翻满 [maxPages] ⇒ 回落」同类——那时增量
|
||||
* 已经取回 ≥50 行,多问一次整表是边际成本,也正合 [MAX_INCREMENT_PAGES] 那句「积压多到增量不再
|
||||
* 便宜」。主路径(几条新消息、第一页就不满)不受影响。
|
||||
*
|
||||
* 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。
|
||||
*
|
||||
* 不传 `platform`:未读面上的口径是服务端 `count(created_at > read_at)`(`unreadCountQuery`,
|
||||
* 不带平台过滤),按平台过滤会让本地数出来的条数**与服务端的 `unreadCount` 不同源**。
|
||||
*
|
||||
* @param after 本端**已见**的最大 `createdAt`(毫秒);服务端返回严格新于它的消息,升序。
|
||||
* @param maxPages 最多翻几页(默认 [MAX_INCREMENT_PAGES])。用尽即 `complete = false`:
|
||||
* 一次静默刷新不该为了补一个角标翻十几页。
|
||||
*/
|
||||
suspend fun loadIncrement(
|
||||
sessionId: String,
|
||||
after: Long,
|
||||
pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||
maxPages: Int = MAX_INCREMENT_PAGES,
|
||||
platform: String? = null,
|
||||
): Result<MessageIncrement> = withContext(dispatcher) {
|
||||
val size = pageSize.coerceIn(1, MAX_PAGE_SIZE)
|
||||
val byId = LinkedHashMap<String, HistoryMessageDto>()
|
||||
var cursor = after
|
||||
// 只要翻到过一整页,就说明服务端的 LIMIT 切过一刀,那一刀可能劈开了一毫秒(见 KDoc)。
|
||||
// 此后无论下一页是空页还是不满页,都说不出「追到了尾巴」。
|
||||
var chasedFullPage = false
|
||||
|
||||
fun done(at: Long, complete: Boolean) = Result.success(
|
||||
MessageIncrement(messages = byId.values.sortedWith(MessageOrder), cursor = at, complete = complete),
|
||||
)
|
||||
|
||||
var page = 0
|
||||
while (page < maxPages.coerceAtLeast(1)) {
|
||||
page++
|
||||
val raw = apiCall { api.listMessages(sessionId, size, before = null, platform = platform, after = cursor) }
|
||||
.getOrElse { return@withContext Result.failure(it) }
|
||||
val paged = toPagedMessages(raw, size)
|
||||
// 空页有两种含义,客户端分不出来:第一页就空 = 真的没有新的;拿过一整页之后空 = 可能还有
|
||||
// 同一毫秒的几条卡在游标上(服务端只给严格大于)。后者只能说「没追完」。
|
||||
if (paged.messages.isEmpty()) {
|
||||
return@withContext done(cursor, complete = !chasedFullPage)
|
||||
}
|
||||
// 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。
|
||||
paged.messages.forEach { byId[it.id] = it }
|
||||
val newest = paged.newestCreatedAt ?: return@withContext done(cursor, complete = false)
|
||||
// 不满页只说明「> cursor 的行就这么多了」,对切点那一毫秒一无所知 —— 与空页档同一个洞,
|
||||
// 同样是拿过整页之后就不作数。
|
||||
if (!paged.hasMore) return@withContext done(newest, complete = !chasedFullPage)
|
||||
cursor = newest
|
||||
chasedFullPage = true
|
||||
}
|
||||
done(cursor, complete = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
|
||||
* 完整消息 DTO**。
|
||||
@@ -152,6 +236,15 @@ class MessageRepository(
|
||||
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
|
||||
const val DEFAULT_SEARCH_PAGE_SIZE = 30
|
||||
|
||||
/**
|
||||
* [loadIncrement] 一次最多翻几页(500 条)。
|
||||
*
|
||||
* 这个上限只为一件事存在:一次**后台**的顺手对齐不该翻十几页。越过它就说明积压已经多到
|
||||
* 「增量」不再便宜,那时整表(`GET /sessions` 一次拿回权威的 `unreadCount` 与预览)
|
||||
* 反而更划算——调用方据此回落,见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*/
|
||||
const val MAX_INCREMENT_PAGES = 10
|
||||
|
||||
private val EMPTY_PAGE = PagedMessages(
|
||||
messages = emptyList(),
|
||||
hasMore = false,
|
||||
@@ -190,3 +283,18 @@ private fun toPagedMessages(
|
||||
newestCreatedAt = ascending.lastOrNull()?.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [MessageRepository.loadIncrement] 的结论:升序增量消息 + 推进后的游标 + 有没有真的追到尾巴。
|
||||
*
|
||||
* [complete] 为 `false` 时 [messages] 仍是**已拿到的全部**(不丢),但调用方不能把它当成
|
||||
* 「这之后就没有了」——未读面上那种情况要回落到整表,见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*
|
||||
* 放在这里而不是 `PagedMessages.kt`:`PagedMessages` 是纯分页形状(一页的元数据),
|
||||
* 这个类型是 [MessageRepository] 翻页循环的产物,跟着它的生产者走。
|
||||
*/
|
||||
data class MessageIncrement(
|
||||
val messages: List<HistoryMessageDto>,
|
||||
val cursor: Long,
|
||||
val complete: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import top.yeij.cyrene.domain.chat.voiceMediaUrl
|
||||
import java.io.File
|
||||
|
||||
/** 本端一次读得下、也放得动的语音副本上限,超过就如实说「放不了」(见 [VoiceTooLargeException])。 */
|
||||
class VoiceTooLargeException : Exception("voice copy too large")
|
||||
|
||||
/** 服务端只给了相对路径,而本端目前拿不到一个能接上去的服务器地址(未登录 / 地址为空)。 */
|
||||
class VoiceUrlUnavailableException : Exception("voice media url unavailable")
|
||||
|
||||
/**
|
||||
* 语音副本的取字节层(`GET /api/v1/media/{name}` 的第一个调用方)。
|
||||
*
|
||||
* 与 [DocumentRepository] 同一个形状:响应不是信封(就是音频本身),所以不用 `apiCall`——
|
||||
* 成功即读字节,失败才走 [ApiErrorMapper](`404 media_not_found` / `401`)。
|
||||
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
|
||||
*
|
||||
* [url] 由调用方拼好(服务端给的**相对路径** + 当前服务器地址,纯函数
|
||||
* [voiceMediaUrl]):本层**不自己拼路径**——契约把那条路径定成服务端唯一的派生字段。
|
||||
* 因此这里也不做形态校验:拼不出可信地址的调用方根本不会走到这里([VoiceStore] 先判)。
|
||||
*/
|
||||
class MediaRepository(
|
||||
private val api: MediaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取回一条语音副本的字节。
|
||||
*
|
||||
* 读到超过 [MAX_VOICE_BYTES] 就判 [VoiceTooLargeException]:语音副本本就不该大,
|
||||
* 而「一次读进内存」这条路上必须有一把尺子(与文档 / 图片同一个做法)。
|
||||
* **0 字节不算成功**——那会让下一次播放「读缓存命中」一份什么都没有的文件。
|
||||
*/
|
||||
suspend fun download(url: String): Result<ByteArray> = withContext(dispatcher) {
|
||||
try {
|
||||
val response = api.download(url)
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> body.use { stream ->
|
||||
val bytes = readBounded(stream.byteStream(), MAX_VOICE_BYTES)
|
||||
when {
|
||||
bytes == null -> Result.failure(VoiceTooLargeException())
|
||||
bytes.isEmpty() -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.success(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 2xx 却没有响应体:与 apiCall 同一口径,判解析失败而不是「拿到了空文件」。
|
||||
response.isSuccessful -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.failure(
|
||||
CyreneException(ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* 上限取附件那一把尺子(20 MB):语音副本远小于它,而这里要防的只是「一次读进内存」
|
||||
* 这条路上没有上界。**不新造一个数字**——多一个自定义上限就多一处会漂移的口径。
|
||||
*/
|
||||
val MAX_VOICE_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音副本的「取字节 + 落盘缓存」层(播放前那一步的**唯一**取数入口)。
|
||||
*
|
||||
* ## 缓存:`<cacheDir>/media/`,复用 [DocumentCache] 的三条规则
|
||||
* - **名字规则 / 命中判定 / 原子落盘**全部复用 [DocumentCache](同一条 `isSafeName`、
|
||||
* `isHit`(非空才算命中)、`write`(先写 `.part` 再改名))——只在缓存根下换一个目录名。
|
||||
* - **特意不落进 `documents/`**:那个目录是 `FileProvider`(`res/xml/file_paths.xml`)**唯一**
|
||||
* 暴露出去的根,「用其它应用打开 / 保存到下载目录」两条出口都从它取文件。语音本片只做
|
||||
* 收 / 放,不提供「交给别的 App」这条出口,所以**不把暴露面扩大一个字节**。
|
||||
* - 目录名是 `media`,与 `voice.url` 里的 `/api/v1/media/` 同源,便于排查时对得上号。
|
||||
*
|
||||
* ## 为什么缓存(而不是每次现下)
|
||||
* 服务端副本是**内容寻址**的(名字由内容决定)+ `Cache-Control: private, max-age=86400,
|
||||
* immutable` ⇒ 同一个名字的字节永不改变,「文件在且非空」就是永远有效的命中。重复播放因此
|
||||
* **一个请求都不发**,离线也能重听刚听过的那条。代价是缓存目录里多占几十 KB~几百 KB 一份,
|
||||
* 而它在 `cacheDir` 下:系统需要空间时可以回收,本端被回收之后下次播放重新下载(**不是**错误)。
|
||||
*
|
||||
* ## 它不做什么
|
||||
* 不拼路径([voiceMediaUrl] 在调用前判,拿不到可信地址就直接失败)、不做形态校验、不猜 MIME、
|
||||
* **不拿 `size` 反算时长**(时长只认服务端给的 `durationMs`)。
|
||||
*/
|
||||
class VoiceStore(
|
||||
/**
|
||||
* 取一条副本的字节(未命中缓存时调一次)。
|
||||
*
|
||||
* 收成一个 lambda 而不是直接收 [MediaRepository]:本类因此可以在单测里用临时目录 + 假下载器
|
||||
* 跑完,不必造一个 HTTP 替身(与 [ImageCacheStore] 的 `download` 参数同一手法)。
|
||||
* 生产路径上它就是 `container.mediaRepository::download`。
|
||||
*/
|
||||
private val download: suspend (String) -> Result<ByteArray>,
|
||||
private val cacheDir: File,
|
||||
private val baseUrlProvider: () -> String,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取这条语音的字节并确保它已落进缓存;返回**可直接交给 `MediaPlayer` 的那份文件**。
|
||||
*
|
||||
* 顺序是刻意的:**先拼地址、再查缓存**。地址拼不出来(未登录 / 地址为空)就如实失败,
|
||||
* 绝不因为「本地碰巧缓存过」而放行——那会让一条本端根本没有服务器归属的语音照旧能放,
|
||||
* 而调用方拿不到任何异常信号。
|
||||
*/
|
||||
suspend fun load(audio: VoiceAudio): Result<File> = withContext(dispatcher) {
|
||||
val url = voiceMediaUrl(audio.url, baseUrlProvider())
|
||||
?: return@withContext Result.failure(VoiceUrlUnavailableException())
|
||||
// ⚠️ 刻意**不用** `DocumentCache.fileFor`:那个函数的缓存根写死成 `documents/`
|
||||
// (FileProvider 那一层),传别的目录进去会拼成 `<cacheDir>/media/documents/<name>`。
|
||||
// 这里只用它的**名字判据**,路径自己拼。
|
||||
if (!DocumentCache.isSafeName(audio.name)) {
|
||||
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回别的名字。
|
||||
return@withContext Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
}
|
||||
val file = File(directory(cacheDir), audio.name)
|
||||
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
|
||||
|
||||
val bytes = download(url).getOrElse { return@withContext Result.failure(it) }
|
||||
try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(file)
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——**绝不**退回「把字节直接喂给
|
||||
// 播放器」那条路:那需要另一条落盘位置,而缓存目录写不进去时多半写得进的地方也没有。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 缓存根下的目录名(与 `voice.url` 里的 `/api/v1/media/` 同源,便于对号)。 */
|
||||
const val DIRECTORY_NAME: String = "media"
|
||||
|
||||
/** 语音副本的缓存目录:`<cacheDir>/media`。 */
|
||||
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SpeechApi
|
||||
import top.yeij.cyrene.data.api.SpeechRequestDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCache
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** 服务端回的字节**不是**一段 WAV(200 却回了 JSON 信封 / HTML 错误页 / 别的东西)。 */
|
||||
class SpeechNotAudioException : Exception("speech response is not wav")
|
||||
|
||||
/**
|
||||
* 一次 TTS 请求的**字节嗅探**:真的是 RIFF/WAVE 吗?
|
||||
*
|
||||
* 契约(api-client.md §4.1 行 561 的旁注、§1.2 U6)只说「产物恒为 WAV」,**没有**承诺
|
||||
* `Content-Type` 是什么,并明写「不要照抄 `Content-Type`」。所以判据只能落在字节上:
|
||||
* 头 4 字节 `RIFF` + 偏移 8 起的 4 字节 `WAVE`(RIFF 容器里夹一段 `fmt ` 那种标准布局)。
|
||||
*
|
||||
* 为什么要判:`MediaPlayer` 拿到非音频字节时抛的是它自己那套 `setDataSource` 异常,
|
||||
* 与本端「取字节失败」的语义完全不同;而 200 回 JSON 信封恰恰是**最可能**的一种
|
||||
* 「网关/反代把错误包了一层」的形态 —— 提前拦在这里,失败就被如实归到「解析失败」那一档,
|
||||
* 而不是在播放器里炸成一个没人认识的东西。
|
||||
*/
|
||||
fun isWavBytes(bytes: ByteArray): Boolean =
|
||||
bytes.size >= WAV_HEADER_BYTES &&
|
||||
bytes[0] == 'R'.code.toByte() &&
|
||||
bytes[1] == 'I'.code.toByte() &&
|
||||
bytes[2] == 'F'.code.toByte() &&
|
||||
bytes[3] == 'F'.code.toByte() &&
|
||||
bytes[8] == 'W'.code.toByte() &&
|
||||
bytes[9] == 'A'.code.toByte() &&
|
||||
bytes[10] == 'V'.code.toByte() &&
|
||||
bytes[11] == 'E'.code.toByte()
|
||||
|
||||
/** RIFF 头里本端要看的那几个字节都在前 12 个之内。 */
|
||||
private const val WAV_HEADER_BYTES = 12
|
||||
|
||||
/** 本端一次读得下、也放得动的合成音频上限(与语音副本同一把尺子:附件上限 20 MB)。 */
|
||||
private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
|
||||
|
||||
/**
|
||||
* 「把一段文字念出来」的取字节层(`POST /api/v1/audio/speech` 的唯一调用方,契约 api-client.md
|
||||
* §1.2 **U6**,v0.96 增量 + v0.97 补写)。
|
||||
*
|
||||
* ## 凭据:只走 Bearer,不设 API Key
|
||||
* 服务端是 owner-only 的**双凭据门**(Bearer owner JWT **或** `X-API-Key`,两个分支落到同一个
|
||||
* `authorize(RoleOwner)`)。本端只走前者:令牌由 [ApiClientFactory.create] 挂在 OkHttpClient 上的
|
||||
* `AuthInterceptor` 注入,并顺带享受「带令牌仍 401 ⇒ 单飞续期并重试一次」。
|
||||
* 主人 2026-10-26 拍板**不加 API Key 输入框** —— 那会多一份要保管、要轮换、要泄漏的凭据,
|
||||
* 而本端本来就已经有 owner 会话。
|
||||
*
|
||||
* ## 发音人从哪来:`sessionId`,不是 `voice`
|
||||
* 契约里 `voice` **接受但不生效**、`format` **不生效**。真正决定发音人的是 `sessionId` 那条会话的
|
||||
* **生效人格**(会话绑定 → 全局默认 → 服务端配置的 default)。所以 [speak] 一定要拿到当前会话 id
|
||||
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。
|
||||
*
|
||||
* ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次
|
||||
* 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
|
||||
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
|
||||
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
|
||||
* `documents/` 根下,不扩大暴露面一个字节)。
|
||||
*
|
||||
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
|
||||
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
|
||||
*
|
||||
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
|
||||
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
|
||||
* 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit]
|
||||
* (非空才算)——它是 `cacheDir` 下的东西,系统随时可回收,被回收之后下次朗读重新请求,
|
||||
* **不是**错误。
|
||||
*
|
||||
* ## 错误怎么分类
|
||||
* 非 2xx 一律走 [ApiErrorMapper](`401` ⇒ [CyreneError.Unauthorized],其余 ⇒
|
||||
* [CyreneError.Server] 且**保留服务端那句 `code`**——`tts_persona_unmapped` /
|
||||
* `tts_no_character` 这两档的文案完全不同,折成一句话就说不清了)。
|
||||
* 分类成「柔和提示」还是「明确降级」是纯函数的事,在
|
||||
* [top.yeij.cyrene.domain.chat.ttsFailureOf] 里,本类不做产品判断。
|
||||
*
|
||||
* @param baseUrlProvider 每次现读服务器地址(换服务器 ⇒ 客户端重建,与 `AppContainer` 同一纪律)。
|
||||
* @param apiFactory 按 baseUrl 造 [SpeechApi];收成 lambda 是为了单测能塞一个直连本地 HTTP 替身的
|
||||
* 实例,而不必造一整套依赖注入。
|
||||
*/
|
||||
class SpeechClient(
|
||||
private val baseUrlProvider: () -> String,
|
||||
private val tokenProvider: () -> String? = { null },
|
||||
private val unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
private val cacheDir: File,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val apiFactory: (String) -> SpeechApi = { baseUrl ->
|
||||
ApiClientFactory.create(
|
||||
baseUrl,
|
||||
SpeechApi::class.java,
|
||||
tokenProvider,
|
||||
unauthorizedPolicy,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
/** 按 baseUrl 缓存 Retrofit 实例(换地址才重建),与 `AppContainer.mediaApi` 同一手法。 */
|
||||
private var cachedApi: SpeechApi? = null
|
||||
private var cachedBaseUrl: String? = null
|
||||
|
||||
private fun api(): SpeechApi {
|
||||
val baseUrl = baseUrlProvider()
|
||||
val cached = cachedApi
|
||||
if (cached != null && cachedBaseUrl == baseUrl) return cached
|
||||
return apiFactory(baseUrl).also {
|
||||
cachedApi = it
|
||||
cachedBaseUrl = baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 [text] 合成成一段 WAV 并落盘,返回**可直接交给 `MediaPlayer`** 的那份文件。
|
||||
*
|
||||
* [sessionId] 是当前会话 id:它决定发音人(见类 KDoc)。传 `null` 表示「本端不知道是哪个会话」,
|
||||
* 服务端会退到全局默认人格 —— 调用方**不要**为了让字段好看而编一个 id 出来。
|
||||
*/
|
||||
suspend fun speak(text: String, sessionId: String?): Result<File> = withContext(dispatcher) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
// 服务端对空 text 回 `400`;本端连请求都不发(省一次往返,也省一次无意义的失败)。
|
||||
return@withContext Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
}
|
||||
val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId, baseUrlProvider()))
|
||||
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
|
||||
|
||||
try {
|
||||
val response = api().speech(SpeechRequestDto(text = trimmed, sessionId = sessionId))
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> body.use { stream ->
|
||||
val bytes = readBounded(stream.byteStream(), MAX_SPEECH_BYTES)
|
||||
when {
|
||||
// 超过上限:本端一次读不下(合成音频远小于它,走到这里说明对面不对劲)。
|
||||
bytes == null -> Result.failure(SpeechNotAudioException())
|
||||
// 0 字节:与 MediaRepository 同一口径,判解析失败而不是「拿到一段静音」。
|
||||
bytes.isEmpty() -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
// 200 却不是 WAV:**绝不**把 JSON / HTML 喂给 MediaPlayer(见 isWavBytes)。
|
||||
!isWavBytes(bytes) -> Result.failure(SpeechNotAudioException())
|
||||
else -> cache(file, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 2xx 却没有响应体:与 apiCall 同一口径。
|
||||
response.isSuccessful -> Result.failure(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
|
||||
)
|
||||
|
||||
else -> Result.failure(
|
||||
CyreneException(
|
||||
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun cache(file: File, bytes: ByteArray): Result<File> = try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(file)
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——绝不退回「把字节留在内存里等播放器
|
||||
// 读」那条路:MediaPlayer 只吃路径,而缓存目录写不进去时多半写得进的地方也没有。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 合成缓存的名字前缀:与语音副本(服务端内容寻址名)在同一目录里区分开。 */
|
||||
const val NAME_PREFIX = "tts_"
|
||||
|
||||
/** sha256 前 32 位十六进制,与服务端存储名 / [DocumentCache.localName] 同一长度。 */
|
||||
const val NAME_HEX_LENGTH = 32
|
||||
|
||||
/**
|
||||
* 缓存名 = `tts_<sha256(baseUrl \n sessionId \n text)[:32]>.wav`。
|
||||
*
|
||||
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
|
||||
* 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
|
||||
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
|
||||
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
|
||||
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
|
||||
*
|
||||
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
|
||||
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
|
||||
*/
|
||||
fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(
|
||||
"${baseUrl.trimEnd('/')}\n${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH)
|
||||
return NAME_PREFIX + hex + ".wav"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.chat.VoiceClip
|
||||
|
||||
/** 录音机的两个可见态(界面只认这两个)。 */
|
||||
sealed interface VoiceRecorderState {
|
||||
|
||||
/** 没在录。 */
|
||||
data object Idle : VoiceRecorderState
|
||||
|
||||
/**
|
||||
* 正在录。
|
||||
*
|
||||
* [elapsedMs] 由**已收到的字节数**反算([VoiceClip.durationMillisOf]),不是另起一个计时器——
|
||||
* 两个时钟一定会漂,而屏幕上那个数字必须与最终发出去的音频长度一致。
|
||||
*
|
||||
* [reachedLimit] = 已经录满 [VoiceClip.MAX_RECORDING_MILLIS],此后的字节被**丢弃**(不是
|
||||
* 悄悄截断已经收到的部分)。UI 看到它应当立刻收尾发送,见本类注释。
|
||||
*/
|
||||
data class Recording(val elapsedMs: Int, val reachedLimit: Boolean) : VoiceRecorderState
|
||||
}
|
||||
|
||||
/**
|
||||
* 麦克风录音机:`AudioRecord` 收 PCM → 交给 [VoiceClip] 封 WAV(本类不碰封装,见那里的 KDoc)。
|
||||
*
|
||||
* ## 为什么自己持有一个作用域,而不复用调用方的 `rememberCoroutineScope`
|
||||
* 读循环必须活到 [stop] 真的收尾:调用方的组合一旦被丢弃(转屏 / 退到后台那一刻重建),
|
||||
* 挂在它上面的协程会被取消,而 USB/内置麦克风已经交给系统的那一块缓冲就再也取不回来——
|
||||
* 用户按下停止却拿到一个缺尾的片段。所以读循环跑在这个类自己的 `SupervisorJob + IO` 上,
|
||||
* 由 [release] 明确收掉。
|
||||
*
|
||||
* ## 满了不自动发送,只置位
|
||||
* 到 [VoiceClip.MAX_RECORDING_MILLIS] 时把 [VoiceRecorderState.Recording.reachedLimit] 置真、
|
||||
* 继续把多余的字节丢掉。**发送时机由 UI 决定**(它要先把「正在录」那一行换回输入栏,再交给
|
||||
* VM):本类不知道 VM 也不发帧,这条边界让它在单测里不必造一个 VM。
|
||||
*
|
||||
* ## 权限
|
||||
* 调用方必须先拿到 `RECORD_AUDIO`(运行时权限)。本类**不申请**也不检查:`AudioRecord`
|
||||
* 在没权限时会抛 `SecurityException`,[start] 把它连同「设备不支持 / 初始化失败」一起收敛成
|
||||
* `false`,由 UI 按「这台设备现在开不了麦克风」说一句,**绝不静默**。
|
||||
*/
|
||||
class VoiceRecorder(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
||||
|
||||
/** 当前录音状态(界面据此显示「录音中 0:07」)。 */
|
||||
val state: StateFlow<VoiceRecorderState> = _state.asStateFlow()
|
||||
|
||||
private var record: AudioRecord? = null
|
||||
|
||||
/** 收集到的 PCM;只有 [start] 建、[stop]/[cancel] 取走或丢掉。 */
|
||||
private var collected: ByteArrayOutputStream? = null
|
||||
|
||||
private var reader: Job? = null
|
||||
|
||||
/**
|
||||
* 录音代次:每次 [start] 自增。
|
||||
*
|
||||
* 只服务一件事:读循环的 `finally` 里**只在还属于这一代**时才把状态收回 [VoiceRecorderState.Idle]。
|
||||
* 少了它,「取消 → 立刻重录」的窄窗口里,上一轮的收尾会把新那一轮的「录音中」抹成「没在录」,
|
||||
* 而麦克风其实还在收字节——界面上什么都不显示,用户以为自己没在录。
|
||||
*/
|
||||
private var generation = 0
|
||||
|
||||
@Volatile
|
||||
private var collecting = false
|
||||
|
||||
/**
|
||||
* 开始录音。
|
||||
*
|
||||
* @return `false` = 这台设备现在起不了麦克风(没有权限 / 设备被占用 / 不支持的采样参数)。
|
||||
* 调用方必须**如实告知**(不得假装在录)。
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(): Boolean {
|
||||
if (collecting) return false
|
||||
val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_MASK, ENCODING)
|
||||
if (minBuffer <= 0) return false
|
||||
val recorder = runCatching {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(ENCODING)
|
||||
.setSampleRate(SAMPLE_RATE_HZ)
|
||||
.setChannelMask(CHANNEL_MASK)
|
||||
.build(),
|
||||
)
|
||||
// 缓冲给到最小值的两倍:读循环偶尔晚一拍时不至于立刻丢帧。
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
}.getOrNull() ?: return false
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
runCatching { recorder.release() }
|
||||
return false
|
||||
}
|
||||
val started = runCatching { recorder.startRecording() }.isSuccess
|
||||
if (!started) {
|
||||
runCatching { recorder.release() }
|
||||
return false
|
||||
}
|
||||
record = recorder
|
||||
val sink = ByteArrayOutputStream(INITIAL_PCM_BYTES)
|
||||
collected = sink
|
||||
collecting = true
|
||||
val current = ++generation
|
||||
_state.value = VoiceRecorderState.Recording(elapsedMs = 0, reachedLimit = false)
|
||||
reader = scope.launch { readLoop(recorder, sink, current) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并交出这一段 PCM;`null` = 没有可发的字节(没在录 / 读失败 / 全被丢弃)。
|
||||
*
|
||||
* `suspend` 是刻意的:必须等读循环真的收尾([reader] 的 `join`)才去读那份缓冲,
|
||||
* 否则最后一块还没写进去就被取走——用户会听到句尾少一截。
|
||||
*/
|
||||
suspend fun stop(): ByteArray? {
|
||||
if (!collecting) return null
|
||||
collecting = false
|
||||
reader?.join()
|
||||
reader = null
|
||||
releaseRecorder()
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
val sink = collected ?: return null
|
||||
collected = null
|
||||
return sink.toByteArray().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** 取消:丢掉已经收到的字节,一个都不发(用户点了「取消」)。 */
|
||||
fun cancel() {
|
||||
collecting = false
|
||||
reader?.cancel()
|
||||
reader = null
|
||||
releaseRecorder()
|
||||
collected = null
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
}
|
||||
|
||||
/** 组合被丢弃时调(等价于 [cancel]:一段没发出去的录音不该在后台继续占着麦克风)。 */
|
||||
fun release() = cancel()
|
||||
|
||||
/**
|
||||
* 读循环:一块一块收进 [sink],顺手更新已录时长;满了之后**丢字节但不打断录音**
|
||||
* (见类注释「满了不自动发送」)。
|
||||
*
|
||||
* 读出错(`read < 0`)就结束这一次录音:硬件被抢走、设备被拔出都会走到这里,此时
|
||||
* 已经收到的部分仍然有效([stop] 照常交出它们)。
|
||||
*
|
||||
* @param generation 启动时那一代的编号:收尾只在自己还是「当前这一代」时才改状态。
|
||||
*/
|
||||
private suspend fun readLoop(recorder: AudioRecord, sink: ByteArrayOutputStream, generation: Int) {
|
||||
val buffer = ByteArray(READ_BYTES)
|
||||
try {
|
||||
while (collecting) {
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
if (read == 0) continue
|
||||
val used = sink.size()
|
||||
if (used < VoiceClip.MAX_PCM_BYTES) {
|
||||
sink.write(buffer, 0, minOf(read, VoiceClip.MAX_PCM_BYTES - used))
|
||||
}
|
||||
val elapsed = VoiceClip.durationMillisOf(minOf(sink.size(), VoiceClip.MAX_PCM_BYTES))
|
||||
_state.value = VoiceRecorderState.Recording(
|
||||
elapsedMs = elapsed,
|
||||
reachedLimit = elapsed >= VoiceClip.MAX_RECORDING_MILLIS,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// 读循环自己结束(出错 / 被取消)时也要把状态收回 Idle,否则界面会永远停在「录音中」。
|
||||
// 只在自己还是当前这一代时才收:否则「取消 → 立刻重录」的窄窗口里,上一轮的收尾
|
||||
// 会把新一轮的「录音中」擦掉。
|
||||
if (generation == this.generation && _state.value is VoiceRecorderState.Recording) {
|
||||
_state.value = VoiceRecorderState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 `AudioRecord` 拆干净(`stop` 在没起录时抛 `IllegalStateException`,所以都包在 `runCatching` 里)。 */
|
||||
private fun releaseRecorder() {
|
||||
val current = record ?: return
|
||||
record = null
|
||||
runCatching { current.stop() }
|
||||
runCatching { current.release() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SAMPLE_RATE_HZ: Int = VoiceClip.SAMPLE_RATE_HZ
|
||||
const val ENCODING: Int = AudioFormat.ENCODING_PCM_16BIT
|
||||
const val CHANNEL_MASK: Int = AudioFormat.CHANNEL_IN_MONO
|
||||
|
||||
/**
|
||||
* 一次读多少字节:`2048` = 16 kHz 下单声道 16-bit 的 64 ms(1024 帧)。
|
||||
* 比这更小则更新频率过高(每秒几十次状态写),更大则「停止」那一刻的延迟可感。
|
||||
*/
|
||||
const val READ_BYTES: Int = 2048
|
||||
|
||||
/** 初始缓冲按 10 秒预分配(够大多数语音,长的那条会自己扩容)。 */
|
||||
val INITIAL_PCM_BYTES: Int = VoiceClip.pcmBytesFor(10_000)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,37 @@ class ChatSocket(
|
||||
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
|
||||
|
||||
/**
|
||||
* 发送 `voice_input`(契约 v0.96 §3.15):一段 base64 音频 + 格式,走与 `chat` 同一条出站通道。
|
||||
*
|
||||
* ## 为什么没有「离线队列」
|
||||
* 文本那条有补发队列(内容只是一小段字符串,留着不贵);语音的 audio 是**几 MB 的 base64**
|
||||
* 内联在帧里,队列化它等于把这段内存钉在待发状态里,而重连之后服务端到底要不要它、要哪一段
|
||||
* 又都是用户该重新决定的事。所以本层与 [sendChat] 一样:**发不出去就回 `false`**,由
|
||||
* `ChatViewModel` 把这段留在内存里、让用户按「重发」(复用同一个 `clientMsgId`)。
|
||||
*
|
||||
* @param audio base64(单行,`Base64.NO_WRAP`)。
|
||||
* @param format 契约白名单 `wav|mp3|ogg` 之一;本端恒 `"wav"`。
|
||||
* @param deviceId 与 `chat` 帧同一份来源标识。
|
||||
* @param clientMsgId 幂等键:同一段语音重发**必须复用**(否则服务端会把同一句话说两遍)。
|
||||
* @return 未连接 / 写失败返回 `false`。
|
||||
*/
|
||||
fun sendVoiceInput(
|
||||
sessionId: String?,
|
||||
audio: String,
|
||||
format: String,
|
||||
deviceId: String?,
|
||||
clientMsgId: String?,
|
||||
): Boolean = send(
|
||||
WsOutbound.VoiceInput(
|
||||
audio = audio,
|
||||
format = format,
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
clientMsgId = clientMsgId,
|
||||
),
|
||||
)
|
||||
|
||||
private fun send(outbound: WsOutbound): Boolean {
|
||||
if (_state.value != WsState.Connected) return false
|
||||
val socket = webSocket ?: return false
|
||||
|
||||
@@ -190,10 +190,19 @@ sealed class WsEvent {
|
||||
override val type: String get() = "tool_execution.update"
|
||||
}
|
||||
|
||||
/** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */
|
||||
/**
|
||||
* 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。
|
||||
*
|
||||
* [resultSummary] 是**执行结果摘要**(新契约切片,与回看面 `toolCalls[].resultSummary` 同一份预算:
|
||||
* 已经脱敏、已经截断 ≤500 字符的成品,本端只展示)。它的语义是**缺席即「不知道」**:
|
||||
* 只有这一轮的执行结果**真的拿到了**才在场,失败 / 取消 / 超时 / 未落图时后端**整个键省略**
|
||||
* (且承诺不用空串顶位)。所以 `null` 与 `""` 在本端都读作**不知道**——绝不解成「没有输出」,
|
||||
* 因为那是替服务端下一个它没下的结论。当前服务端**尚未部署**该键 ⇒ 真机上它恒缺席。
|
||||
*/
|
||||
data class ToolExecutionEnd(
|
||||
val tool: String,
|
||||
val phase: String,
|
||||
val resultSummary: String? = null,
|
||||
val id: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
@@ -591,12 +600,30 @@ sealed class WsEvent {
|
||||
override val type: String get() = "observability_log"
|
||||
}
|
||||
|
||||
/** 语音合成回复(定向回请求连接)。 */
|
||||
/**
|
||||
* 语音合成回复(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
|
||||
*
|
||||
* ## 会话归属**只在 `data` 里**
|
||||
* 契约明写这帧的帧壳**没有** `sessionId`(与其它结构化流帧不同),会话 id 在载荷上。
|
||||
* 所以解析层用既有口径 [`resolveSessionId`] 把它提到 [sessionId] 上:帧壳有就用帧壳,
|
||||
* 否则用 `data` 里那一份。少了这一步,`isForSession` 会把帧**放行给每一个会话的 VM**
|
||||
* (空值 = 无法判定),于是切到别的会话时也会响一段本属于别的会话的语音。
|
||||
*
|
||||
* ## 只定向回发起 `voice_input` 的那条连接、断线不补投
|
||||
* 这是契约的明确边界,不是缺口:本端**不**为它做任何「重连后拉取」的补救(历史靠 REST,
|
||||
* 音频只在这一次连接里存在)。所以这一帧只有「当场播放 / 当场显示」这一种用途。
|
||||
*
|
||||
* [text] 是**转写**(那段音频被识别成什么),[reply] 是**她说的话**——两者的取舍见
|
||||
* `domain.chat.voiceReplyText`。[provider] 是降级信号(`silent` = 这一轮没有真音频),
|
||||
* 判据见 `domain.chat.voiceOutputHasAudio`。
|
||||
*/
|
||||
data class VoiceOutput(
|
||||
val audio: String?,
|
||||
val format: String?,
|
||||
val text: String?,
|
||||
val reply: String?,
|
||||
val deviceId: String? = null,
|
||||
val provider: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
@@ -760,7 +787,7 @@ sealed class WsEvent {
|
||||
"tool_execution.update" -> decode<ToolExecutionData>(data)
|
||||
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
|
||||
"tool_execution.end" -> decode<ToolExecutionData>(data)
|
||||
.let { ToolExecutionEnd(it.tool, it.phase, it.id, turnId, seq, sessionId) }
|
||||
.let { ToolExecutionEnd(it.tool, it.phase, it.resultSummary, it.id, turnId, seq, sessionId) }
|
||||
"stream_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
|
||||
"stream_end" -> decode<StreamEndData>(data)
|
||||
.let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) }
|
||||
@@ -881,8 +908,20 @@ sealed class WsEvent {
|
||||
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
|
||||
"message_deleted" -> decode<DeletedData>(data)
|
||||
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
|
||||
"voice_output" -> decode<VoiceOutputData>(data)
|
||||
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
|
||||
"voice_output" -> decode<VoiceOutputData>(data).let {
|
||||
VoiceOutput(
|
||||
audio = it.audio,
|
||||
format = it.format,
|
||||
text = it.text,
|
||||
reply = it.reply,
|
||||
deviceId = it.deviceId,
|
||||
provider = it.provider,
|
||||
turnId = turnId,
|
||||
seq = seq,
|
||||
// 帧壳**没有** sessionId(契约 v0.96),归属在 data 里 ⇒ 走既有口径提到帧上。
|
||||
sessionId = resolveSessionId(sessionId, it.sessionId),
|
||||
)
|
||||
}
|
||||
// 她主动说的话(v0.72)。帧壳**没有** `sessionId`(服务端不设),与会话归属有关的一切
|
||||
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
|
||||
"proactive_message" -> decode<ProactiveData>(data).let {
|
||||
@@ -983,6 +1022,10 @@ private data class ToolCallData(val id: String = "", val index: Int = 0, val del
|
||||
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
|
||||
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
|
||||
* 也没有 `streamId`(它们不属于某一次模型流)。
|
||||
*
|
||||
* [resultSummary] 只由 `.end` 携带(后端在新契约切片里只补了 `.end`)。它放在这份共用载荷上
|
||||
* 是因为三帧形状同源、共用一个 DTO;`.start` / `.update` 即便被塞了这个键也**不读**
|
||||
* (它们的领域事件上根本没有这个字段),所以契约没变宽——变宽的只是「多一个键不再算坏帧」。
|
||||
*/
|
||||
@Serializable
|
||||
private data class ToolExecutionData(
|
||||
@@ -990,6 +1033,7 @@ private data class ToolExecutionData(
|
||||
val phase: String = "",
|
||||
val id: String? = null,
|
||||
val delta: String = "",
|
||||
val resultSummary: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -1115,12 +1159,22 @@ private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList(
|
||||
@Serializable
|
||||
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
|
||||
|
||||
/**
|
||||
* `voice_output` 载荷(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
|
||||
*
|
||||
* `sessionId` 在这里**不是可选的**(契约「帧壳没有 sessionId(在 data 里)」),但解成缺省空串
|
||||
* 并交给 [WsEvent.resolveSessionId]:坏帧(真没给)时宁可退化成「无法判定」(放行、由消费方
|
||||
* 兜底),也不把整帧按坏帧丢掉——丢了就是「她回了语音但屏幕上什么都没有」。
|
||||
*/
|
||||
@Serializable
|
||||
private data class VoiceOutputData(
|
||||
val audio: String? = null,
|
||||
val format: String? = null,
|
||||
val text: String? = null,
|
||||
val reply: String? = null,
|
||||
val sessionId: String = "",
|
||||
val deviceId: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,12 +105,26 @@ sealed interface WsOutbound {
|
||||
}
|
||||
}
|
||||
|
||||
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
|
||||
/**
|
||||
* `voice_input`:`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`(契约 v0.96 §3.15)。
|
||||
*
|
||||
* 五个键里 [audio] / [format] 是我们**总要给**的(一段 WAV 的 base64 + `"wav"`,见
|
||||
* `domain.chat.VoiceClip`);[text] 只有本端**自己做过转写**时才给(给了服务端就跳过 STT),
|
||||
* 本端不做本地转写 ⇒ 恒省略。
|
||||
*
|
||||
* [clientMsgId] 与 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条,重复到达回
|
||||
* `chat_duplicate`、不再落库不再起回合。所以「这一段没发出去、用户点重发」必须**复用**
|
||||
* 同一个键(见 `domain.chat.voiceClientMsgIdFor`),换新键就是让服务端把同一句话再答一遍。
|
||||
*
|
||||
* [deviceId] 的取值与 `chat` 帧同一份(`ChatViewModel.DEVICE_ID`):服务端据此标记来源。
|
||||
*/
|
||||
data class VoiceInput(
|
||||
val audio: String? = null,
|
||||
val format: String? = null,
|
||||
val text: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val clientMsgId: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "voice_input"
|
||||
@@ -120,6 +134,8 @@ sealed interface WsOutbound {
|
||||
format?.let { put("format", it) }
|
||||
text?.let { put("text", it) }
|
||||
sessionId?.let { put("sessionId", it) }
|
||||
deviceId?.let { put("deviceId", it) }
|
||||
clientMsgId?.let { put("clientMsgId", it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,12 +40,21 @@ data class EditableMessage(
|
||||
)
|
||||
|
||||
/**
|
||||
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签):
|
||||
* `<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
|
||||
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器会**改掉**的那几个标签——行内标签不在其中,
|
||||
* 见下):`<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
|
||||
*
|
||||
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
|
||||
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
|
||||
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
|
||||
*
|
||||
* **行内标签(`<b>` / `<code>` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
|
||||
* 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 <b>18:35</b>` 在
|
||||
* 编辑框里连 `<b>` 一起显示、也一起保存,这里一个字符都不动它。
|
||||
*
|
||||
* **`<br>` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `<br>` 的消息在这份
|
||||
* 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`<br>` 原样显示、原样保存);只有原文**另有**已知标记时
|
||||
* 才走剥标记那条路,那时 `<br>` 与 `<p>` / `</p>` 一样被换成它本来就在表示的**空行分隔**
|
||||
* (保存后渲染结果不变,且弹层已经明说标记会被替换)。
|
||||
*/
|
||||
private val KNOWN_TAG_RE = Regex(
|
||||
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 预览内容**下方**那一行灰色小字的组装(纯函数,逐档穷举单测在 `PreviewMetadataTest`)。
|
||||
*
|
||||
* ## 这一行是什么,不是什么
|
||||
* 它只放**关于这份文件本身**的格式事实:有多少行、按什么口径解码、多大、多少像素、什么时候拍的。
|
||||
* 它**不是**状态栏、也不是错误提示——「读不到 / 打开失败 / 保存失败」各有自己的去处(芯片行、
|
||||
* 正文区那句人话、弹层下方那行告知),混进来会让这一行读起来像出了什么事。
|
||||
*
|
||||
* ## 纪律:拿不到的项就不出现
|
||||
* 每一项都在**有证据时才产出**,没有证据就整项缺席——**不留占位符、不写「未知」、不写 `--`**。
|
||||
* 一屏小字里堆三个「未知」换不来任何信息,却会让人以为本端查过了。三档证据强度各自成条:
|
||||
* - **字节**(本端真的持有那一份):行数、像素、EXIF 拍摄日期、文件体积;
|
||||
* - **服务端结论**(DTO 里的字段):注入字符数、注入是否被截断、嗅探所得的 mime;
|
||||
* - **本端口径**(本端自己做了什么):`UTF-8` 只是**本端解码口径**,不是文件的真实编码(见下)。
|
||||
*
|
||||
* ## 「编码」为什么敢写 UTF-8(且只敢这么写)
|
||||
* 客户端**拿不到文件的真实编码**:磁盘上那份字节是按什么字符集写的,没有任何字段说过,本端也没有
|
||||
* 可靠的方式判定(猜 GBK / Shift-JIS 就是在编)。能确定的只有一件事:正文区那段文字是
|
||||
* [DocumentPreview.slice] 用 `String(bytes, Charsets.UTF_8)` 解出来的——所以这一项写的是
|
||||
* **「本端解码口径」**,即「你正在读的这段文字是怎么来的」。**表述里必须带这个限定语**:
|
||||
* 裸写一个「UTF-8」会被读成「这份文件是 UTF-8 编码的」,那是本端不知道的事。
|
||||
*
|
||||
* ## 与芯片行重合的两处:一处保留、一处省略
|
||||
* 预览弹层顶部那枚芯片已经写着「类型 · 体积 · 字数 · 状态 · 注入已截断」([DocumentChipModel.detail]),
|
||||
* 因此这一行刻意**不重复**:
|
||||
* - **省略**「体积」与「服务端状态」:[DocumentChips.sizeLabel] / [DocumentChips.statusLabel] 那两个
|
||||
* 数字/词与芯片行**逐字相同**、且没有口径可补,再写一遍只是噪音(图片那一路没有芯片行,所以
|
||||
* 体积在 [imageItems] 里是要显示的);
|
||||
* - **保留**「字符数」并补上口径:芯片只写「8,000 字」,读者无法知道那是**服务端注入上下文**的
|
||||
* 字数还是这份文件的字数——这正是一句会把人带偏的话,补上「(服务端注入口径)」才算说清。
|
||||
*
|
||||
* ## 本端的截断为什么不在这里再说一遍
|
||||
* [PreviewSlice.truncated](本端只预览了前 256 KB / 5,000 行)已经让 [PreviewSlice.notice] 在**正文
|
||||
* 上方**明说了,本行不再重复同一件事;而**服务端**的「注入被预算截断」是另一件事([DocumentRefDto.truncated],
|
||||
* 正文照样进了上下文),它没有别的去处,所以在 [textItems] 里单列一条。
|
||||
*/
|
||||
object PreviewMetadata {
|
||||
|
||||
/** 这一行各项之间的分隔符。 */
|
||||
const val ITEM_SEPARATOR: String = " · "
|
||||
|
||||
/** 把各项拼成能直接交给 `Text` 的那一行;空列表拼成空串(调用方据此不画这一行)。 */
|
||||
fun join(items: List<String>): String = items.joinToString(ITEM_SEPARATOR)
|
||||
|
||||
/**
|
||||
* 文本类预览的那几项:行数 · 字符数(带口径)· 解码口径 · 服务端的截断标记。
|
||||
*
|
||||
* @param slice 正文区的切段结果([DocumentPreview.slice] 的产物)。
|
||||
* @param serverCharsLabel 服务端下发的注入字符数**已格式化**的那一份
|
||||
* (`documents[].chars` → [DocumentChips.charsLabel],如 `"8,000 字"`);`null` = 服务端没报
|
||||
* (`chars` 整键缺席,契约 v0.59 §1.2),此时改报本端预览内容的字数。
|
||||
* @param contextTruncated 服务端是否报告了「注入被预算截断」(`documents[].truncated`)。
|
||||
* ⚠️ 与 [PreviewSlice.truncated](**本端**预览截断)是两件事,见本对象 KDoc。
|
||||
*/
|
||||
fun textItems(
|
||||
slice: PreviewSlice,
|
||||
serverCharsLabel: String?,
|
||||
contextTruncated: Boolean,
|
||||
): List<String> = buildList {
|
||||
add(linesItem(slice))
|
||||
add(charsItem(slice.text, serverCharsLabel))
|
||||
// 本端解码口径:正文就是 String(bytes, UTF_8) 的产物,说得出、也只在说这一件事。
|
||||
add(DECODED_AS_UTF8)
|
||||
if (contextTruncated) add(TRUNCATED_FOR_CONTEXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 非文本类(PDF 这类二进制)的那几项:只有服务端**字节嗅探**所得的 mime。
|
||||
*
|
||||
* 体积与状态刻意不在这里(芯片行已经写着,见本对象 KDoc);服务端说「读不到」时下发的
|
||||
* **原因原话**也不在这里——它已经被 [DocumentPreviewGate.plan] 送进正文区那句人话里**原样显示**,
|
||||
* 这里是同一屏的第二个位置,重复一遍只会让那句话看着像两条不同的消息。
|
||||
*
|
||||
* @param serverMime DTO 的 `mime`(服务端嗅探所得基类型);本地待发附件**没有**这个结论
|
||||
* (还没上传过),传 `null` —— 这一项因此缺席,**不由本端按扩展名补一个**。
|
||||
*/
|
||||
fun fileItems(serverMime: String?): List<String> =
|
||||
listOfNotNull(serverMime?.trim()?.takeIf { it.isNotEmpty() })
|
||||
|
||||
/**
|
||||
* 图片的那几项:格式(mime)· 分辨率 · 体积 · 拍摄日期。
|
||||
*
|
||||
* 图片这一路没有附件芯片行,所以体积在这里是要显示的。每一项都可能在拿不到时缺席:
|
||||
* 格式(嗅探与平台声明都判不出来时)、分辨率(本端解不出边界时)、拍摄日期(EXIF 里没有
|
||||
* `DateTimeOriginal`,或那个值不是能认的日期时)——见各自的参数说明。
|
||||
*
|
||||
* @param mime 交给别的应用的类型([ImageOpenTypes.openType] 的产物)。⚠️ 退化到「图片通配」
|
||||
* 时**必须传 `null`**:`image/` 通配([ImageOpenTypes.ANY_IMAGE])不是格式,写出来等于给了一个什么都没说的答案。
|
||||
* @param width / @param height 本端按 `inJustDecodeBounds` 读到的像素;任一侧 ≤ 0 / 缺席即整项不出现。
|
||||
* @param sizeBytes 本端实际持有的字节数;[UNKNOWN_BYTES] 即整项不出现。
|
||||
* @param capturedAt EXIF `DateTimeOriginal` 的**原始字符串**(`yyyy:MM:dd HH:mm:ss`,
|
||||
* 没有时区、按本地墙钟读,见 [formatExifDateTime])。
|
||||
*/
|
||||
fun imageItems(
|
||||
mime: String?,
|
||||
width: Int? = null,
|
||||
height: Int? = null,
|
||||
sizeBytes: Long = UNKNOWN_BYTES,
|
||||
capturedAt: String? = null,
|
||||
): List<String> = buildList {
|
||||
mime?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
|
||||
if (width != null && height != null && width > 0 && height > 0) add("$width × $height")
|
||||
DocumentChips.sizeLabel(sizeBytes).takeIf { it.isNotEmpty() }?.let { add(it) }
|
||||
formatExifDateTime(capturedAt)?.let { add(CAPTURED_AT_PREFIX + it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* EXIF 的拍摄时间 → 人能读的样子(`"2019:07:04 20:31:05"` → `"拍摄于 2019年7月4日 20:31"`);
|
||||
* 认不出就回 `null`(宁可不显示,也不显示一个错的日期)。
|
||||
*
|
||||
* ## 两个必须说清的取舍
|
||||
* 1. **没有时区**。EXIF 的 `DateTimeOriginal` 是纯墙钟字符串(`yyyy:MM:dd HH:mm:ss`),规范里
|
||||
* 根本没有偏移量字段。所以这里**不做任何时区换算**,按字面数字当日历时间读——把它当 UTC
|
||||
* 再转本地,只会让一个原本正确的日期凭空错几个小时。
|
||||
* 2. **严格到不近人情**。只有逐位对上 `yyyy:MM:dd HH:mm:ss`(19 个字符、分隔符固定)且各段落在
|
||||
* 合法范围内才认。相机的「值未写入」占位符 `"0000:00:00 00:00:00"` 因此落在月份 0 上被判非法
|
||||
* ——这是对的,它本来就表示「不知道」。宽松解析(补零、猜分隔符)换来的是**看起来正常的错日期**,
|
||||
* 比不显示糟得多。
|
||||
*
|
||||
* 秒刻意丢掉:这一行是给人看的摘要,分钟已经足够定位(同秒的两张图对用户没有区别)。
|
||||
*/
|
||||
fun formatExifDateTime(raw: String?): String? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
val groups = EXIF_DATE_TIME.matchEntire(value) ?: return null
|
||||
// 正则已保证每段都是纯数字,`toInt()` 在这里不会抛。
|
||||
val (year, month, day, hour, minute, second) = groups.destructured
|
||||
val y = year.toInt()
|
||||
val mo = month.toInt()
|
||||
val d = day.toInt()
|
||||
val h = hour.toInt()
|
||||
val mi = minute.toInt()
|
||||
val s = second.toInt()
|
||||
if (y < MIN_YEAR) return null
|
||||
if (mo !in 1..12) return null
|
||||
if (d !in 1..31) return null
|
||||
if (h !in 0..23) return null
|
||||
if (mi !in 0..59) return null
|
||||
// 60 是闰秒,相机会写出来,不该判成坏日期。
|
||||
if (s !in 0..60) return null
|
||||
return String.format(Locale.ROOT, "%d年%d月%d日 %02d:%02d", y, mo, d, h, mi)
|
||||
}
|
||||
|
||||
/**
|
||||
* 行数那一项。
|
||||
*
|
||||
* 显示量按 [DocumentPreview.lineCount] **从正文本身重算**(不是取 [PreviewSlice.shownLines]):
|
||||
* 这一行的标题就是「你看到的这段有多少行」,重算让它与正文永远自洽。被本端截断时补上全文行数
|
||||
* ([PreviewSlice.totalLines] 是按整份字节数出来的,本端确实持有那份字节)——只说「显示 N 行」
|
||||
* 会让人以为文件就这么长。
|
||||
*/
|
||||
private fun linesItem(slice: PreviewSlice): String {
|
||||
val shown = grouped(DocumentPreview.lineCount(slice.text))
|
||||
return if (slice.truncated) {
|
||||
"显示 $shown 行 / 共 ${grouped(slice.totalLines)} 行"
|
||||
} else {
|
||||
"$shown 行"
|
||||
}
|
||||
}
|
||||
|
||||
/** 字符数一项:服务端报了就报它那句(补口径),没报才用本端的计数(另一句口径)。 */
|
||||
private fun charsItem(text: String, serverCharsLabel: String?): String =
|
||||
if (serverCharsLabel != null) {
|
||||
serverCharsLabel + SERVER_CHARS_SCOPE
|
||||
} else {
|
||||
grouped(codePointCount(text)) + " 字" + LOCAL_CHARS_SCOPE
|
||||
}
|
||||
|
||||
/**
|
||||
* 「字」数按 **Unicode 码点**算(`codePointCount`):一个 emoji / 一个汉字都算一个字。
|
||||
* 直接取 `String.length` 会把 BMP 之外的字符算成两个(UTF-16 代理对),与「字数」这个说法不符。
|
||||
*/
|
||||
private fun codePointCount(text: String): Int =
|
||||
text.codePointCount(0, text.length)
|
||||
|
||||
/** 千分位分组(只用于计数,纯展示)。 */
|
||||
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
|
||||
|
||||
// —— 每一项的措辞都收在这里,测试锚的也是这几句 ——
|
||||
|
||||
/** 本端解码口径。**限定语不许省**:省了就成了对文件真实编码的断言(见本对象 KDoc)。 */
|
||||
const val DECODED_AS_UTF8: String = "UTF-8(本端解码口径)"
|
||||
|
||||
/** 服务端 `chars` 的口径:那是**注入上下文**的字数,不是这份文件的字数。 */
|
||||
const val SERVER_CHARS_SCOPE: String = "(服务端注入口径)"
|
||||
|
||||
/** 本端自己数出来的字数口径:只数了预览内容(截断时就不是全文)。 */
|
||||
const val LOCAL_CHARS_SCOPE: String = "(本端预览内容)"
|
||||
|
||||
/** 服务端的 `truncated`:注入被预算截断,**正文照样进了上下文**(与「读不到」是两件事)。 */
|
||||
const val TRUNCATED_FOR_CONTEXT: String = "正文被截断(进上下文的部分)"
|
||||
|
||||
/** 拍摄日期前缀:一个光秃秃的日期在这一行里会被当成「文件修改时间」之类的东西。 */
|
||||
const val CAPTURED_AT_PREFIX: String = "拍摄于 "
|
||||
|
||||
/** 字节数拿不到时的占位:交给 [DocumentChips.sizeLabel],它会回空串(于是整项不出现)。 */
|
||||
const val UNKNOWN_BYTES: Long = -1L
|
||||
|
||||
private const val MIN_YEAR = 1
|
||||
|
||||
/** EXIF 规范里的日期时间形状;只认这一种,见 [formatExifDateTime]。 */
|
||||
private val EXIF_DATE_TIME =
|
||||
Regex("""(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})""")
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一段**要发出去的语音**:录音参数、WAV 封装、体积闸门与幂等键(纯函数,可穷举单测)。
|
||||
*
|
||||
* ## 为什么自己封 WAV,而不是用 `MediaRecorder`
|
||||
* 契约 v0.96 给 `voice_input.format` 的白名单是 **`wav|mp3|ogg`**,而 Android `MediaRecorder`
|
||||
* 的默认产物是 **`m4a/aac`**——**不在白名单里**。要用它就得另接一个编码器把 aac 转成 mp3/ogg,
|
||||
* 那是新依赖(项目纪律:加依赖先问)。而 `AudioRecord` 给的 PCM + 一个 44 字节的 RIFF 头
|
||||
* 就是 WAV:**零依赖、零转码、逐字节可预期**,也能在单测里把头的每个字段钉死。
|
||||
*
|
||||
* ## 采样参数:16 kHz / 单声道 / 16-bit
|
||||
* - **采样率 16 kHz**:语音识别的行业口径就是 16 kHz(电话带宽 8 kHz 会把擦音削掉,44.1 kHz
|
||||
* 对 STT 没有任何增益)。同一句话的字节数因此只有 44.1 kHz 的 **36%**——这一条在本端格外重要,
|
||||
* 因为 `voice_input.audio` 是**内联 base64**(4/3 膨胀)走一条 WS 文本帧。
|
||||
* - **单声道**:麦克风阵列的立体声对 STT 只有副作用(两路几乎相同,白背一倍体积)。
|
||||
* - **16-bit**:`AudioRecord` 的原生 PCM 编码(`ENCODING_PCM_16BIT`),不做压缩——压缩要依赖。
|
||||
*
|
||||
* 由此得出一个**只有一条**的换算:`1 ms = 32 字节`,`1 s = 32 000 字节`。
|
||||
*
|
||||
* ## 体积闸门(为什么是 5 MiB)
|
||||
* 契约 v0.96 的 `voice_input` **没写** `audio` 的体积上限(`chat.image` 写了:解码后 5 MiB /
|
||||
* 字符串 6990572 字符)。本端按**已知最严的那一档**设计,也就是 [MAX_AUDIO_BYTES] =
|
||||
* `ChatImageBudget.SERVER_MAX_DECODED_BYTES`(5 MiB,解码后)——同一个部署对「内联 base64 字段」
|
||||
* 已经明确接受的量级,voice 没有理由比它更宽。超限**明确拒绝**([VOICE_TOO_LARGE_MESSAGE]),
|
||||
* 绝不静默截断:截断后的音频在服务端听来就是「话说到一半断了」。
|
||||
*
|
||||
* 60 秒的录满长度([MAX_RECORDING_MILLIS])只用到这条闸门的 **36%**(1 920 044 字节),所以
|
||||
* 正常路径永远撞不到它——它是**第二道**闸(硬件偶尔会多给几块缓冲,谁也说不准),不是主判据。
|
||||
*
|
||||
* ## 时长下限
|
||||
* 按下就松手的误触会得到几十毫秒的音频:那不是「说话」,送给服务端只会换来一次空转写。
|
||||
* 不足 [MIN_RECORDING_MILLIS] 的录音在**本端**就被挡下并说明([VOICE_TOO_SHORT_MESSAGE])。
|
||||
*/
|
||||
object VoiceClip {
|
||||
|
||||
/** 采样率(Hz)。16 kHz = STT 的行业口径,见类注释。 */
|
||||
const val SAMPLE_RATE_HZ: Int = 16_000
|
||||
|
||||
/** 声道数:单声道(立体声对 STT 无益,只翻倍体积)。 */
|
||||
const val CHANNEL_COUNT: Int = 1
|
||||
|
||||
/** 位深:`AudioRecord` 的原生 16-bit PCM。 */
|
||||
const val BITS_PER_SAMPLE: Int = 16
|
||||
|
||||
/** 每采样的字节数。 */
|
||||
const val BYTES_PER_SAMPLE: Int = BITS_PER_SAMPLE / 8
|
||||
|
||||
/** RIFF/WAVE 的 PCM 头长度:`RIFF`(4) + size(4) + `WAVE`(4) + `fmt `(16+8) + `data`(8)。 */
|
||||
const val HEADER_BYTES: Int = 44
|
||||
|
||||
/** 上行 `format` 的取值。契约白名单 `wav|mp3|ogg` 里我们**只产出**这一个。 */
|
||||
const val FORMAT: String = "wav"
|
||||
|
||||
/** 每秒的 PCM 字节数(= [SAMPLE_RATE_HZ] × [CHANNEL_COUNT] × [BYTES_PER_SAMPLE])。 */
|
||||
val BYTES_PER_SECOND: Int = SAMPLE_RATE_HZ * CHANNEL_COUNT * BYTES_PER_SAMPLE
|
||||
|
||||
/**
|
||||
* 一段语音(**含头**)的字节上限:与 `chat.image` 解码后同一条线,见类注释。
|
||||
* 刻意**引用**那个常量而不是再写一遍 `5 * 1024 * 1024`:上限只留一处,改的时候不会漂。
|
||||
*/
|
||||
const val MAX_AUDIO_BYTES: Int = ChatImageBudget.SERVER_MAX_DECODED_BYTES
|
||||
|
||||
/**
|
||||
* 一次录音的**最长**时长(毫秒)。60 秒取自 IM 的通行口径(微信 / QQ 都是这个量级),
|
||||
* 也对得上服务端 STT 的现实:再长的语音转写质量与等待都不划算。
|
||||
*
|
||||
* 到点由录音机自己置「满了」标记(见 `VoiceRecorder`),UI 随即收尾发送——
|
||||
* 不是悄悄截断,用户看到的是「到 60 秒了,这段发出去」。
|
||||
*/
|
||||
const val MAX_RECORDING_MILLIS: Int = 60_000
|
||||
|
||||
/** 短于这个时长的录音**不送**(按下就松手的误触),见类注释。 */
|
||||
const val MIN_RECORDING_MILLIS: Int = 1_000
|
||||
|
||||
/** 60 秒对应的 PCM 字节数(录音机据此封顶,不在读循环里另算一遍公式)。 */
|
||||
val MAX_PCM_BYTES: Int = pcmBytesFor(MAX_RECORDING_MILLIS)
|
||||
|
||||
/** 这段 PCM 加上 WAV 头之后的**总**字节数。 */
|
||||
fun totalBytesFor(pcmBytes: Int): Int = if (pcmBytes <= 0) 0 else HEADER_BYTES + pcmBytes
|
||||
|
||||
/** 时长 → PCM 字节数(向下取整;`<= 0` 的时长回 0)。 */
|
||||
fun pcmBytesFor(durationMillis: Int): Int =
|
||||
if (durationMillis <= 0) 0 else (durationMillis.toLong() * BYTES_PER_SECOND / 1000L).toInt()
|
||||
|
||||
/** PCM 字节数 → 时长(毫秒,向下取整)。 */
|
||||
fun durationMillisOf(pcmBytes: Int): Int =
|
||||
if (pcmBytes <= 0) 0 else (pcmBytes.toLong() * 1000L / BYTES_PER_SECOND).toInt()
|
||||
|
||||
/** 总字节数是否在 [MAX_AUDIO_BYTES] 以内(**等于**算通过:`<=`,与 `withinServerLimit` 同口径)。 */
|
||||
fun withinLimit(totalBytes: Int): Boolean = totalBytes <= MAX_AUDIO_BYTES
|
||||
|
||||
/**
|
||||
* WAV 头(44 字节,PCM / 单块 / 无扩展)。
|
||||
*
|
||||
* 逐字段按 RIFF 规范写:`RIFF` + 文件长 - 8 + `WAVE` + `fmt `(16 字节,PCM=1) + 声道 /
|
||||
* 采样率 / 字节率 / 块对齐 / 位深 + `data` + 数据长。全部**小端**。
|
||||
*/
|
||||
fun wavHeader(pcmBytes: Int): ByteArray {
|
||||
val data = ByteArray(HEADER_BYTES)
|
||||
putAscii(data, 0, "RIFF")
|
||||
putIntLe(data, 4, HEADER_BYTES - 8 + pcmBytes)
|
||||
putAscii(data, 8, "WAVE")
|
||||
putAscii(data, 12, "fmt ")
|
||||
putIntLe(data, 16, 16)
|
||||
putShortLe(data, 20, AUDIO_FORMAT_PCM)
|
||||
putShortLe(data, 22, CHANNEL_COUNT)
|
||||
putIntLe(data, 24, SAMPLE_RATE_HZ)
|
||||
putIntLe(data, 28, BYTES_PER_SECOND)
|
||||
putShortLe(data, 32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
|
||||
putShortLe(data, 34, BITS_PER_SAMPLE)
|
||||
putAscii(data, 36, "data")
|
||||
putIntLe(data, 40, pcmBytes)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 头 + PCM = 一个可直接交给任何 `WAV` 解码器的字节数组。 */
|
||||
fun toWav(pcm: ByteArray): ByteArray {
|
||||
val header = wavHeader(pcm.size)
|
||||
val out = ByteArray(HEADER_BYTES + pcm.size)
|
||||
header.copyInto(out, 0)
|
||||
pcm.copyInto(out, HEADER_BYTES)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 这段 PCM 该不该发、发出去长什么样(**唯一**的准入判据,录音机与 VM 共用)。
|
||||
*
|
||||
* 判序是刻意的:**空 → 超限 → 太短 → 可发**。
|
||||
* - 空:一个字节都没有(设备没给 / 权限刚被撤),连「太短」都谈不上;
|
||||
* - 超限:长度或字节数已经越线,绝不能发(哪怕时长看起来正常——硬件也可能给出异常长的一块);
|
||||
* - 太短:见 [MIN_RECORDING_MILLIS]。
|
||||
*/
|
||||
fun plan(pcm: ByteArray): VoiceClipPlan {
|
||||
if (pcm.isEmpty()) return VoiceClipPlan.Empty
|
||||
val total = totalBytesFor(pcm.size)
|
||||
if (!withinLimit(total)) return VoiceClipPlan.TooLarge(total)
|
||||
val duration = durationMillisOf(pcm.size)
|
||||
if (duration < MIN_RECORDING_MILLIS) return VoiceClipPlan.TooShort(duration)
|
||||
return VoiceClipPlan.Ready(toWav(pcm), duration)
|
||||
}
|
||||
|
||||
/** RIFF 的 `audioFormat`:1 = PCM(整数)。 */
|
||||
private const val AUDIO_FORMAT_PCM: Int = 1
|
||||
|
||||
private fun putAscii(target: ByteArray, offset: Int, text: String) {
|
||||
for (index in text.indices) target[offset + index] = text[index].code.toByte()
|
||||
}
|
||||
|
||||
private fun putIntLe(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
|
||||
target[offset + 2] = ((value ushr 16) and 0xFF).toByte()
|
||||
target[offset + 3] = ((value ushr 24) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
private fun putShortLe(target: ByteArray, offset: Int, value: Int) {
|
||||
target[offset] = (value and 0xFF).toByte()
|
||||
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
/** [VoiceClip.plan] 的四档结论。 */
|
||||
sealed interface VoiceClipPlan {
|
||||
|
||||
/** 一段**可以发**的语音:已经封好 WAV,[durationMs] 是它的实际时长。 */
|
||||
class Ready(val wav: ByteArray, val durationMs: Int) : VoiceClipPlan
|
||||
|
||||
/** 一个字节都没录到。 */
|
||||
data object Empty : VoiceClipPlan
|
||||
|
||||
/** 短于 [VoiceClip.MIN_RECORDING_MILLIS](按下就松手那种误触)。 */
|
||||
data class TooShort(val durationMs: Int) : VoiceClipPlan
|
||||
|
||||
/** 总字节数超过 [VoiceClip.MAX_AUDIO_BYTES]。 */
|
||||
data class TooLarge(val totalBytes: Int) : VoiceClipPlan
|
||||
}
|
||||
|
||||
/** 空录音的如实告知(**不是错误**:什么都没发生,用户重来一次即可)。 */
|
||||
const val VOICE_EMPTY_MESSAGE: String = "没有录到声音,再按一次试试"
|
||||
|
||||
/** 太短的如实告知。 */
|
||||
const val VOICE_TOO_SHORT_MESSAGE: String = "录得太短了,说满一秒再发"
|
||||
|
||||
/**
|
||||
* 超限的如实告知。刻意说「没发出去」:那句话必须与事实上一致(这条一个字节都没上行),
|
||||
* 也不能把责任推给用户(他确实就说了那么久,是本端的闸门挡下的)。
|
||||
*/
|
||||
const val VOICE_TOO_LARGE_MESSAGE: String = "这段语音太长,本机没有发出去"
|
||||
|
||||
/** 这一条被本端挡下时该说的那句话;`null` = 可以发(纯函数)。 */
|
||||
fun voiceClipRejection(plan: VoiceClipPlan): String? = when (plan) {
|
||||
is VoiceClipPlan.Ready -> null
|
||||
VoiceClipPlan.Empty -> VOICE_EMPTY_MESSAGE
|
||||
is VoiceClipPlan.TooShort -> VOICE_TOO_SHORT_MESSAGE
|
||||
is VoiceClipPlan.TooLarge -> VOICE_TOO_LARGE_MESSAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音中 / 待重发那一段的时长文案(纯函数):`m:ss`,**从 0:00 起就有值**。
|
||||
*
|
||||
* 与接收侧的 [voiceDurationLabel] 差一条:那个对「不足一秒」回 `null`(服务端没给准数时宁可
|
||||
* 不显示),而这里显示的是**本端自己正在数的时间**——0:00 是事实,不是冒充。
|
||||
* 负数按 0 处理(时钟回拨 / 调用方算错时也不出现 `-1:-1`)。
|
||||
*/
|
||||
fun voiceElapsedLabel(elapsedMs: Int): String {
|
||||
val totalSeconds = elapsedMs.coerceAtLeast(0) / 1000
|
||||
return "${totalSeconds / 60}:${(totalSeconds % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/** 语音消息的幂等键前缀:与文本那条(`local-`)/ 分享那条(`share-`)同一手法,见 [voiceClientMsgIdFor]。 */
|
||||
const val VOICE_CLIENT_MSG_ID_PREFIX: String = "voice-"
|
||||
|
||||
/**
|
||||
* 一条**录好、还没发出去**的语音在本端的记账(只有小字段,audio 不在这里)。
|
||||
*
|
||||
* `clientMsgId` 是契约 v0.96 §3.15 的幂等键:同一个非空值服务端**只承认第一条**,重复到达回
|
||||
* `chat_duplicate`、不落库不起回合。所以「重发这一段」必须**复用**它([voiceClientMsgIdFor]),
|
||||
* 换了新键就是让服务端把同一句话再答一遍。
|
||||
*
|
||||
* `audio`(base64)刻意**不进** [top.yeij.cyrene.ui.chat.ChatUiState]:那是一格会被反复 `copy`
|
||||
* 并逐字段比较的状态(流式期间每秒几十次),几 MB 的字符串放进去等于每次比较都扫一遍它。
|
||||
* 字节留在 VM 自己那个字段上(生命周期与 VM 相同,转屏不丢)。
|
||||
*/
|
||||
data class VoiceDraftInfo(
|
||||
val clientMsgId: String,
|
||||
val durationMs: Int,
|
||||
val totalBytes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这一段该用哪个幂等键(纯函数):
|
||||
* - [draft] 非空 = **重发刚才没发出去的那一段** ⇒ 原样复用它当时的键(契约要求);
|
||||
* - [draft] 为空 = 一段**新**录音 ⇒ 现铸一个:前缀 + [mint] 给的随机串(随机而不是自增,
|
||||
* 理由与文本那条相同:服务端的判重窗口是整个会话历史)。
|
||||
*/
|
||||
fun voiceClientMsgIdFor(draft: VoiceDraftInfo?, mint: () -> String): String =
|
||||
draft?.clientMsgId ?: "$VOICE_CLIENT_MSG_ID_PREFIX${mint()}"
|
||||
@@ -0,0 +1,311 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceTooLargeException
|
||||
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
|
||||
|
||||
/**
|
||||
* `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得;
|
||||
* `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`);
|
||||
* `tts` = **我们自己合成的出站语音**(她的 `speak` 工具产出的那一段,后端 M57-M59 新增)。
|
||||
*
|
||||
* 三个取值在**接收侧**没有分叉:合成成功的那条与入站语音同形(`status = transcribed` + 四键
|
||||
* 副本齐全),照同一条路渲染;合成降级的那条是 `status = failed`(见 [VoiceStatus.FAILED])。
|
||||
*/
|
||||
object VoiceSource {
|
||||
|
||||
const val ASR: String = "asr"
|
||||
|
||||
const val PLATFORM: String = "platform"
|
||||
|
||||
const val TTS: String = "tts"
|
||||
}
|
||||
|
||||
/**
|
||||
* `voice.status` 的闭集(契约 §4.4,**三态**)。
|
||||
*
|
||||
* 与 [DocumentStatus] 同一条纪律:**只认闭集里的取值,认不出的不猜**——但两者收尾的方式不同。
|
||||
* 文档的未知 `status` 要**原样显示**(那是给用户看的结论),语音的未知 `status` 只是
|
||||
* 「本端不多说一句话」(正文由服务端给,见 [voiceNote])。
|
||||
*/
|
||||
object VoiceStatus {
|
||||
|
||||
/** 正文**就是**转写文本(出站方向 = 合成成功、有副本可播)。 */
|
||||
const val TRANSCRIBED: String = "transcribed"
|
||||
|
||||
/** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */
|
||||
const val EMPTY: String = "empty"
|
||||
|
||||
/**
|
||||
* 这一条**没有可播的语音**,正文是一句话(见 [isPlayableVoice])。
|
||||
*
|
||||
* 两个方向同形、语义统一(后端 M57-M59 预告,契约 v0.98 待正式回执):
|
||||
* - **入站**:没拿到转写文本 ⇒ 正文是服务端给的那行中文诚实提示;
|
||||
* - **出站**:她本想用语音、合成降级回文字 ⇒ 正文是普通文字,另带 `fallbackReason`
|
||||
* (见 [voiceFallbackNote])。
|
||||
*
|
||||
* 入站方向设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
|
||||
* 无字节一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
|
||||
*/
|
||||
const val FAILED: String = "failed"
|
||||
}
|
||||
|
||||
/**
|
||||
* 「转写成功但没识别出文字」时本端补的那一句。
|
||||
*
|
||||
* 为什么这句是**本端的**而不是服务端的原话:这一态的语义由契约定义(§4.4 的三态之一),而
|
||||
* `empty` 的正文按定义就是空的——不补一句的话,一条「微信语音、没有音频副本、也没转出文字」
|
||||
* 的消息在屏幕上**只剩一个「语音」标签**,看的人无从知道发生了什么。这不是替服务端改文案
|
||||
* (那句话说的是**这一态的语义**,不是失败原因),也**不覆盖**任何正文。
|
||||
*/
|
||||
const val VOICE_EMPTY_NOTE: String = "没有识别出文字"
|
||||
|
||||
/**
|
||||
* `status == "failed"` 的那一行**灰色小标**的固定前半句(后面可能跟服务端给的 `fallbackReason`)。
|
||||
*
|
||||
* 为什么要有这一行:这一态的消息 `voice` 键**在**、但**没有可播的语音**(见 [isPlayableVoice]),
|
||||
* 所以屏幕上不会有语音形态。少了这一行,一条「她本想发语音、合成降级成文字」的消息与一条普通文字
|
||||
* 消息**逐像素相同**——看的人无从知道这条本来是一段声音。它只说本端能确定的那件事(这条没有可播的
|
||||
* 声音),**不解释原因**(原因归服务端,见 [voiceFallbackNote] 里那段原话)。
|
||||
*
|
||||
* ⚠️ 措辞要同时站得住两个方向(入站转写失败 / 出站合成降级),所以**不写**「转写失败」也**不写**
|
||||
* 「合成失败」:本端分不清,也不该替服务端选一个说法。
|
||||
*/
|
||||
const val VOICE_FALLBACK_NOTE: String = "这条语音没有可播放的声音"
|
||||
|
||||
/**
|
||||
* 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。
|
||||
*
|
||||
* 与取字节失败([voicePlaybackFailureMessage])分开:两者要用户做的事不一样——字节没拿到是
|
||||
* 「网络 / 过期 / 服务端没有这份副本」,可以再点一次;这一段是「字节在手上,本机解不开」
|
||||
* (格式不支持、文件损坏),再点一次多半还是同样的结果。
|
||||
*/
|
||||
const val VOICE_UNPLAYABLE_MESSAGE: String = "这段语音本机播不了"
|
||||
|
||||
/**
|
||||
* 一条语音的**可播放副本**:四个键(`name` / `mime` / `size` / `url`)齐全时才构造得出来。
|
||||
*
|
||||
* 存在的理由是把「能不能播」这件事**收成一个类型**,而不是让 UI / 播放器各自去测四个 `!= null`:
|
||||
* 拿到 [VoiceAudio] 就等于「有副本、可以画播放控件、可以发请求」;拿不到就**一个播放控件都不画**
|
||||
* (不能给一个点下去必然失败的按钮)。
|
||||
*
|
||||
* @property durationMs 从 [VoiceDto] 原样带过来;`null` = 服务端没给,**不显示时长**。
|
||||
*/
|
||||
data class VoiceAudio(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
val url: String,
|
||||
val durationMs: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `voice` 键在不在(纯函数)—— 「这条消息**来自**一条语音」。
|
||||
*
|
||||
* 这是契约 §4.4 给的**唯一**来源判据:非语音消息整键缺席(不是 `{}` 也不是 `null`),
|
||||
* `content` 里有没有 `[语音]` 这类字样与本判据无关(QQ 的占位标记自 v0.67 起不再下发,
|
||||
* 别人也可以自己打出一模一样的字)。
|
||||
*
|
||||
* ⚠️ 它与「要不要画语音气泡」**不是同一件事**了:合成降级 / 转写失败的那些行 `voice` 键照样在,
|
||||
* 但**没有可播的语音**(见 [isPlayableVoice])。判「是不是语音消息」用本函数,判「画不画语音
|
||||
* 形态」用 [isVoiceMessage]——两个问题分开问,混用会让降级行被画成一个必然播不了的气泡。
|
||||
*/
|
||||
fun hasVoice(message: HistoryMessageDto): Boolean = message.voice != null
|
||||
|
||||
/**
|
||||
* 这条语音**有没有可播的语音形态**(纯函数):除了 `voice` 键要在,`status` 还不能是 `failed`。
|
||||
*
|
||||
* ## 为什么判据是「不是 `failed`」而不是「是 `transcribed` / `empty`」
|
||||
* 后端 M57-M59 预告(契约 v0.98 待正式回执)只点名了 `failed` 这一态**明确没有可播的语音**:
|
||||
* 入站是没拿到转写文本、出站是合成降级回文字,两个方向的正文都是一句话而不是一段语音。
|
||||
*
|
||||
* 反过来,认不出的 `status` **一律当可播**(fail-open)。理由是这个方向错得便宜、另一个方向错得
|
||||
* 贵:后端将来若新增一个成功态(比如 `transcribed` 之外的 `truncated`),fail-open 只是多画一枚
|
||||
* 语音形态(最坏是点了播不出来,有既有的失败文案兜着);fail-closed 则会把一条**真的有副本**的
|
||||
* 语音静默吞成纯文字,用户再也点不到那段声音——而且没有任何界面提示说明为什么。所以这里只把
|
||||
* 契约明确说过「没有语音」的那一态排除掉,其余交给「四键齐全才画播放控件」([voiceAudio])。
|
||||
*/
|
||||
fun isPlayableVoice(voice: VoiceDto): Boolean = voice.status != VoiceStatus.FAILED
|
||||
|
||||
/**
|
||||
* 🎤 **要不要画语音形态的判据** = `voice` 键存在 **且** `status != "failed"`(契约 §4.4 +
|
||||
* 后端 M57-M59 预告)。
|
||||
*
|
||||
* 只有这两个条件,而且**永远只有这两个**:
|
||||
* - `voice` 键缺席 ⇒ 不是语音消息([hasVoice]);
|
||||
* - `status == "failed"` ⇒ 这条**没有可播的语音**(入站转写失败 / 出站合成降级),正文照常按文字
|
||||
* 渲染,另加一行灰色小标([voiceFallbackNote])——**不**画语音形态,因为画出来就是一个必然
|
||||
* 播不了的控件。
|
||||
*
|
||||
* 入站语音(`asr` / `platform`)与合成成功的出站语音(`tts`)走的是**同一条**路:判据只看
|
||||
* `status`,不看 `source`。
|
||||
*/
|
||||
fun isVoiceMessage(message: HistoryMessageDto): Boolean {
|
||||
val voice = message.voice ?: return false
|
||||
return isPlayableVoice(voice)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条语音的**可播放副本**;四个键没凑齐(或名字 / MIME / URL 是空白)就回 `null`。
|
||||
*
|
||||
* 服务端保证「四者同生共死」,本端仍然逐项检查:**这条判据的下游是「给不给播放控件」**,
|
||||
* 而契约里那份保证一旦被某个部署打破,唯一的后果就是用户点到一个必然失败的按钮
|
||||
* ——宁可少画一个控件,也不要画一个假的。
|
||||
*
|
||||
* `size` 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西
|
||||
* (尤其**不反算时长**,见 [voiceDurationLabel])。
|
||||
*/
|
||||
fun voiceAudio(voice: VoiceDto?): VoiceAudio? {
|
||||
if (voice == null) return null
|
||||
val name = voice.name?.takeIf { it.isNotBlank() } ?: return null
|
||||
val mime = voice.mime?.takeIf { it.isNotBlank() } ?: return null
|
||||
val url = voice.url?.takeIf { it.isNotBlank() } ?: return null
|
||||
val size = voice.size ?: return null
|
||||
return VoiceAudio(name = name, mime = mime, size = size, url = url, durationMs = voice.durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 时长文案(纯函数):`m:ss`(如 `0:03` / `1:05`)。
|
||||
*
|
||||
* **缺席就不显示**是契约的硬要求(服务端「读不出来就省略键,绝不估算」,本端同理):
|
||||
* - `null` ⇒ 回 `null`(调用方因此**一个字符都不画**,更不会出现 `0:00` 那种冒充);
|
||||
* - `<= 0` ⇒ 也回 `null`:`0` 毫秒的语音不存在,把服务端的一个异常取值显示成 `0:00`
|
||||
* 就是拿一个编出来的数字冒充事实;
|
||||
* - **不足一秒**(`1..999`)⇒ 也回 `null`:`m:ss` 只能把它显示成 `0:00`,而 `0:00` 与
|
||||
* 「没有时长」在屏幕上是同一个样子——与其显示一个看起来像没读到的时间,不如什么都不显示
|
||||
* (渠道也不会真的发这种语音);
|
||||
* - 不设上限:语音消息不会长到需要小时制,超过 99 分钟就照实显示 `124:03`(**不折算**成
|
||||
* 「2:04:03」——那是另一种自己发明的表达)。
|
||||
*/
|
||||
fun voiceDurationLabel(durationMs: Int?): String? {
|
||||
if (durationMs == null || durationMs < 1_000) return null
|
||||
val totalSeconds = durationMs / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音形态**之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
|
||||
*
|
||||
* 只对 `status == "empty"` 且**正文为空**时成立:
|
||||
* - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补);
|
||||
* - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾**
|
||||
* (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话);
|
||||
* - 未知 `status`(含空串):**不猜**,只显示正文。
|
||||
*
|
||||
* ⚠️ `failed` 那一态**不走这里**(它连语音形态都不画):正文由服务端给,本端另加一行小标
|
||||
* ([voiceFallbackNote])。本函数仍对 `failed` 回 `null`,因为「语音形态旁边多说一句」这个位置
|
||||
* 对那一态根本不存在。
|
||||
*/
|
||||
fun voiceNote(status: String, content: String): String? =
|
||||
if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null
|
||||
|
||||
/**
|
||||
* `status == "failed"` 时那行灰色小标该写什么;别的状态回 `null`(纯函数)。
|
||||
*
|
||||
* 两段拼成一句话:**固定前半句**([VOICE_FALLBACK_NOTE],说的是本端能确定的事实)+ 服务端给的
|
||||
* `fallbackReason`(**一个字符都不改**,原样放进括号里)。
|
||||
*
|
||||
* ## 为什么原样放、不做映射
|
||||
* `fallbackReason` 的取值集合**后端还没定**(`devdocs/02` 里我们已经问了「是闭集还是自由文本」,
|
||||
* 尚未答复;契约 v0.98 的正式回执也还没到)。在有答案之前,本端**不猜**它是不是闭集、更不替它
|
||||
* 编一套友好文案:认不出的取值被映射成一句话,比原样显示更难排查——用户报「显示成『语音超时』」
|
||||
* 时,我们连服务端到底给了什么都不知道。原样显示,最坏是屏幕上出现一个 `tts_timeout` 这样的词,
|
||||
* 而它**正是**我们和后端对账时要的那个字符串。
|
||||
*
|
||||
* ## `fallbackReason` 缺席时
|
||||
* 只有固定前半句。这一档同时是**空正文 + `failed`** 的兜底:这一态的消息正文按契约应当非空
|
||||
* (服务端那行诚实提示 / 降级后的原文本),但本端**不靠**这条保证——真收到一条空正文的 `failed`
|
||||
* 行时,屏幕上至少还有这行小标,而不是一个**什么都没有**的空白(那正是空气泡那种最糟的呈现)。
|
||||
*/
|
||||
fun voiceFallbackNote(voice: VoiceDto?): String? {
|
||||
if (voice == null || voice.status != VoiceStatus.FAILED) return null
|
||||
val reason = voice.fallbackReason?.takeIf { it.isNotBlank() } ?: return VOICE_FALLBACK_NOTE
|
||||
return "$VOICE_FALLBACK_NOTE($reason)"
|
||||
}
|
||||
|
||||
/**
|
||||
* 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。
|
||||
*
|
||||
* 契约明写 `url` 是唯一的派生字段(`/api/v1/media/<name>`),**客户端绝不自己拼路径**——所以
|
||||
* 本函数只做「保留 path、换成当前 host」这一件事,与 [ImageHostRebase] 同一取向
|
||||
* (图片那条要换的是**别人给过的绝对地址**里的 host,这里要接的是**服务端给的相对路径**)。
|
||||
*
|
||||
* 判据取最小容忍度,任何一条不满足就回 `null`(调用方据此如实报「暂时播不了」,**绝不**
|
||||
* 退回「自己拼一条 `/api/v1/media/<name>`」——那正是契约禁止的那件事):
|
||||
* - [url] 必须以 `/api/v1/media/` 开头,且后面**恰好**是一段名字:非空、不是 `.` / `..`、
|
||||
* 不含 `/` `\` `%` `?` `#` 与 NUL;
|
||||
* - [baseUrl] 必须能解出 `scheme://authority`(空串 / 没有 host 都解不出)。
|
||||
*
|
||||
* 用的是 [baseUrl] 的 scheme + authority(所以 `http → https`、端口变更都跟着走),**不用**
|
||||
* baseUrl 里的路径:这条地址挂在根路径上,部署形态本来就没有路径前缀这一说。
|
||||
*/
|
||||
fun voiceMediaUrl(url: String, baseUrl: String): String? {
|
||||
val name = url.removePrefix(MEDIA_PREFIX)
|
||||
if (!url.startsWith(MEDIA_PREFIX) || !isSafeMediaName(name)) return null
|
||||
val origin = originOf(baseUrl) ?: return null
|
||||
return origin + url
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 `documentLoadFailureMessage` / `imageLoadFailureMessage` 同一纪律:服务端能说清的用它的
|
||||
* 原话,本端能分类的分开说,**绝不把失败渲染成「静音」**——一条点了没反应的语音消息在用户眼里
|
||||
* 与「坏了」没有区别。
|
||||
*
|
||||
* 三档本地失败分开写,因为它们要用户做的事不一样:地址拼不出来 = 本端环境不对(重新登录 / 换
|
||||
* 服务器);太大 = 这条本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
|
||||
*/
|
||||
fun voicePlaybackFailureMessage(e: Throwable): String = when (e) {
|
||||
is VoiceUrlUnavailableException -> "当前服务器地址不可用,暂时播不了这条语音"
|
||||
is VoiceTooLargeException -> "这条语音太大,本机放不了"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时播不了这条语音"
|
||||
is CyreneException -> when {
|
||||
// ⚠️ 刻意**不**走 `toDocumentFailure()`:那个映射只认文档那四个码(`document_not_found`),
|
||||
// `media_not_found` 在它眼里是「未知码」⇒ 会把「服务端上已经没有这条语音了」说成
|
||||
// 「语音加载失败,media not found」。这一档本端说得准,就说得准一点。
|
||||
e.error.isMediaNotFound() -> "服务端上已经没有这条语音了"
|
||||
else -> "语音加载失败," + e.error.userMessage
|
||||
}
|
||||
|
||||
else -> "语音加载失败,请稍后重试"
|
||||
}
|
||||
|
||||
/** 服务端对「名字非法 / 穿越 / 不存在」统一回这一个码(U19:**不区分**,不给探测面)。 */
|
||||
private const val MEDIA_NOT_FOUND_CODE = "media_not_found"
|
||||
|
||||
private fun CyreneError.isMediaNotFound(): Boolean =
|
||||
this is CyreneError.Server && code == MEDIA_NOT_FOUND_CODE
|
||||
|
||||
/** 服务端相对路径的前缀(契约 §1.2 U19)。 */
|
||||
private const val MEDIA_PREFIX = "/api/v1/media/"
|
||||
|
||||
/**
|
||||
* 媒体名是不是「恰好一段」:不许有分隔符、百分号编码、查询串,也不许是 `.` / `..`。
|
||||
*
|
||||
* 与 `ImageHostRebase` 的同名判据同一条理由:这个字符串来自**别人给的**数据,
|
||||
* 看不懂的形态就不该被我们拼进一条请求地址。
|
||||
*/
|
||||
private fun isSafeMediaName(name: String): Boolean {
|
||||
if (name.isEmpty() || name == "." || name == "..") return false
|
||||
return name.none { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }
|
||||
}
|
||||
|
||||
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
|
||||
private fun originOf(baseUrl: String): String? {
|
||||
val rest = when {
|
||||
baseUrl.startsWith("http://", ignoreCase = true) -> baseUrl.substring("http://".length)
|
||||
baseUrl.startsWith("https://", ignoreCase = true) -> baseUrl.substring("https://".length)
|
||||
else -> return null
|
||||
}
|
||||
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, end)
|
||||
if (authority.isBlank()) return null
|
||||
return baseUrl.take(baseUrl.length - rest.length) + authority
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* `voice_output` 的**判据**(纯函数,可穷举单测):契约 v0.96 §3.15 下行帧。
|
||||
*
|
||||
* 帧形状(后端 `回执三十六`):
|
||||
* ```json
|
||||
* { "type": "voice_output",
|
||||
* "data": { "audio": "<base64>", "format": "wav", "text": "...", "reply": "她的话",
|
||||
* "sessionId": "web", "deviceId?": "...", "provider?": "silent|emotiontts|..." } }
|
||||
* ```
|
||||
*
|
||||
* ## `text` 与 `reply` 是两个东西(本端怎么用)
|
||||
* - **[text]**:这一轮**转写出来的话**——也就是我们那段音频被识别成什么。它与
|
||||
* `voice_input.text`(「已自行转写则跳过 STT」)同一个键名、同一个语义,只是方向相反。
|
||||
* - **[reply]**:**她说的话**(音频念的就是它),也就是用户真正要听/要看的那一句。
|
||||
*
|
||||
* 于是「屏幕上该显示哪一句」的答案是 [voiceReplyText]:**`reply` 优先**,它缺席时才退到 `text`
|
||||
* (宁可显示转写也不显示空白),两者都缺席就什么都不显示(绝不编一句)。
|
||||
*
|
||||
* ## `provider` 是**降级信号**,不是装饰
|
||||
* `provider == "silent"` = 这一轮**没有真音频**(`audio` 空,但 `text` / `reply` 有)。
|
||||
* 契约明写「UI 别把它画成播放器」——画了就是一个点下去必然失败的按钮(与接收侧
|
||||
* `voiceAudio` 的「四个键不齐就不画播放控件」同一条纪律)。
|
||||
*
|
||||
* 除此之外 `audio` 空白也按「没有音频」处理([voiceOutputHasAudio]):契约说 silent 时
|
||||
* `audio` 为空,反过来「空 audio + 没给 provider」也绝不能画出一个播放器。
|
||||
*
|
||||
* ## 它与 `voice` 键那条(接收侧放别人发来的语音)**互不相干**
|
||||
* 见 [isVoiceMessage]/[voiceAudio]:那是消息 DTO 的历史副本,这里是**本端刚发出去的那一段**的
|
||||
* 实时应答——两者不共用判据,共用只会把两套字段口径搅在一起。
|
||||
*/
|
||||
|
||||
/** `provider` 里那个**降级档**的取值(契约 v0.96)。 */
|
||||
object VoiceProvider {
|
||||
|
||||
/** 这一轮**没有真音频**(`audio` 空、`text`/`reply` 有)。 */
|
||||
const val SILENT: String = "silent"
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一帧是不是「没有真音频」那一档(纯函数)。
|
||||
*
|
||||
* 只认字面 `silent`(忽略首尾空白与大小写:它是机器可读码,不是文案)。未知取值一律**不当**
|
||||
* silent——那是另一个 provider,`audio` 在不在由 [voiceOutputHasAudio] 按字节判,不靠猜。
|
||||
*/
|
||||
fun isSilentVoiceProvider(provider: String?): Boolean =
|
||||
provider?.trim().equals(VoiceProvider.SILENT, ignoreCase = true)
|
||||
|
||||
/**
|
||||
* 这一轮**有没有真音频**(纯函数):`provider != silent` **且** `audio` 非空白。
|
||||
*
|
||||
* 两个条件都不许省:silent 档的 `audio` 契约上就是空的(万一真给了也不算数,因为服务端已经
|
||||
* 明说这一轮没发音),而非 silent 档的 `audio` 也可能因为编码失败缺席。
|
||||
*/
|
||||
fun voiceOutputHasAudio(audio: String?, provider: String?): Boolean =
|
||||
!isSilentVoiceProvider(provider) && !audio.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* 屏幕上该显示的那一句(纯函数,见文件头「`text` 与 `reply` 是两个东西」)。
|
||||
* `reply` 非空白 ⇒ 它;否则 `text` 非空白 ⇒ 它;都没有 ⇒ `null`(一个字符都不显示)。
|
||||
*/
|
||||
fun voiceReplyText(reply: String?, text: String?): String? =
|
||||
reply?.takeIf { it.isNotBlank() } ?: text?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* 这张卡片下面要补的那一句(纯函数:不需要补时回 `null`)。
|
||||
*
|
||||
* 两档**都要解释,但说的不是同一件事**:
|
||||
* - [silent]:**服务端**这一轮没有真音频(`provider == "silent"`)⇒「她只回了文字」。不解释的话,
|
||||
* 用户按着麦克风说话、回来的却是一段文字,分不清「她选择只回文字」与「播放器坏了」。
|
||||
* - 有音频却没落地([hasAudioFile] 为假):那是**本端**解码 / 写缓存失败 ⇒ 说「这段语音本机没
|
||||
* 准备好」,绝不把本机的故障说成「她只回了文字」(那是替服务端下了一个它没下的结论)。
|
||||
*/
|
||||
fun voiceReplyNote(silent: Boolean, hasAudioFile: Boolean): String? = when {
|
||||
silent -> VOICE_SILENT_NOTE
|
||||
!hasAudioFile -> VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这一轮没有音频」的说明。刻意说清是**服务端**这一轮没给音频(`provider` 的降级信号),
|
||||
* 而不是「本机播不了」——后者会把人引向「换个播放器」这种无用的动作。
|
||||
*/
|
||||
const val VOICE_SILENT_NOTE: String = "这一轮她只回了文字,没有语音"
|
||||
|
||||
/**
|
||||
* 「音频有、但本机没准备好」的说明(解码 / 写缓存失败)。
|
||||
*
|
||||
* 与 [VOICE_SILENT_NOTE] 分开是必须的:这一档的真相是**本端**的问题,而文字照样在
|
||||
* ([voiceReplyText] 已经把它取出来了),所以用户至少还能读到她说的话。
|
||||
*/
|
||||
const val VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE: String = "这段语音本机没能准备好,先看文字"
|
||||
@@ -0,0 +1,121 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 语音播放的**状态**(纯数据,可单测)。
|
||||
*
|
||||
* ## 为什么要有它,而不是让每个气泡各自 `remember { mutableStateOf(false) }`
|
||||
* 同一时刻**只能有一条语音在响**(一台设备一个喇叭,两条语音叠着放一定是 bug),而
|
||||
* 「哪一条在响」是**跨气泡**的事实:用户点了另一条,前一条必须停下来并释放解码器。
|
||||
* 于是这份状态由**一条播放通道**持有,气泡只是它的视图。四个态刻意只有四个:
|
||||
*
|
||||
* - [Idle]:什么都没在放(也是「停下来之后」唯一的终点);
|
||||
* - [Loading]:字节在取(首次播放要先下载;第二次起走本地缓存,一闪而过);
|
||||
* - [Playing]:真的在响(`MediaPlayer` 已经 `start()` 过,还没走完 / 还没被停);
|
||||
* - [Failed]:**这一次尝试**没成功,[Failed.message] 是给用户看的那句话。
|
||||
*
|
||||
* [Failed] 携带的是**这条消息**的失败(不是全局横幅):用户点的是这一条,出问题的是这一条。
|
||||
* 它**不粘**:再点一次就从 [Loading] 重新开始(见 [pressed])。
|
||||
*
|
||||
* ## 每条转换都带 `messageId`,因为回调会迟到
|
||||
* `MediaPlayer` 的回调(prepared / completion / error)与取字节的协程都可能**在用户已经改主意
|
||||
* 之后**才到达。所以每一次转换都要**比对目标**:过期的回调**一个都不许**改状态
|
||||
* ([prepared] / [failed] / [finished] 的同一条纪律)。这不是防御性编程的洁癖——
|
||||
* 少了它就会出现「播放器已经换了、界面还显示上一条在响」。
|
||||
*/
|
||||
sealed interface VoicePlaybackState {
|
||||
|
||||
/** 没有被这条通道占着的消息(初始态,也是停 / 出错 / 放完之后的终点)。 */
|
||||
data object Idle : VoicePlaybackState
|
||||
|
||||
/** 正在取这一条的字节(下载 / 读缓存)。 */
|
||||
data class Loading(val messageId: String) : VoicePlaybackState
|
||||
|
||||
/** 这一条正在响。 */
|
||||
data class Playing(val messageId: String) : VoicePlaybackState
|
||||
|
||||
/** 这一条的上一次尝试没成;[message] 是给用户看的那句话(见 `voicePlaybackFailureMessage`)。 */
|
||||
data class Failed(val messageId: String, val message: String) : VoicePlaybackState
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条消息此刻正被这条播放通道占着吗([VoicePlaybackState.Loading] 或
|
||||
* [VoicePlaybackState.Playing])。
|
||||
*
|
||||
* [VoicePlaybackState.Failed]**不算**:它表示这条已经不在放,用户再点一下就是「重试」。
|
||||
*/
|
||||
fun VoicePlaybackState.isActiveFor(messageId: String): Boolean = when (this) {
|
||||
is VoicePlaybackState.Loading -> this.messageId == messageId
|
||||
is VoicePlaybackState.Playing -> this.messageId == messageId
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** 这条消息**正在响**吗(只认 [VoicePlaybackState.Playing],加载中不算)。 */
|
||||
fun VoicePlaybackState.isPlayingFor(messageId: String): Boolean =
|
||||
this is VoicePlaybackState.Playing && messageId == this.messageId
|
||||
|
||||
/**
|
||||
* 这条消息**正在取字节**吗(只认 [VoicePlaybackState.Loading])。
|
||||
*
|
||||
* 与 [isPlayingFor] 分开是必须的:「正在加载」与「正在响」对用户是两件事,界面上那句
|
||||
* 「加载中…」只能在**真的在加载**时出现——混着用会让一条 30 秒的语音在整段播放期间都写着
|
||||
* 「加载中…」。
|
||||
*/
|
||||
fun VoicePlaybackState.isLoadingFor(messageId: String): Boolean =
|
||||
this is VoicePlaybackState.Loading && messageId == this.messageId
|
||||
|
||||
/**
|
||||
* 这条消息上一次尝试失败时那句提示;不是它 / 没失败过就回 `null`(气泡据此决定画不画那一行)。
|
||||
*/
|
||||
fun VoicePlaybackState.failureOf(messageId: String): String? =
|
||||
(this as? VoicePlaybackState.Failed)?.takeIf { it.messageId == messageId }?.message
|
||||
|
||||
/**
|
||||
* 用户点了这条消息的播放 / 暂停。
|
||||
*
|
||||
* - 点的是**正在放 / 正在加载的那一条** ⇒ 回 [VoicePlaybackState.Idle](= 停,调用方据此停下
|
||||
* 并释放;「再点一下取消」对加载中同样成立——用户不该被一个慢下载困住);
|
||||
* - 点的是别的(或上一次失败的)那一条 ⇒ 换成它的 [VoicePlaybackState.Loading]。
|
||||
*
|
||||
* 有一条**调用方职责**不在状态里:进入 [VoicePlaybackState.Loading] 之前必须把上一条真的停掉
|
||||
* (见 `ui.chat.VoicePlayer.toggle`)。纯函数只说「状态应该变成什么」。
|
||||
*/
|
||||
fun VoicePlaybackState.pressed(messageId: String): VoicePlaybackState =
|
||||
if (isActiveFor(messageId)) VoicePlaybackState.Idle else VoicePlaybackState.Loading(messageId)
|
||||
|
||||
/**
|
||||
* 字节到手、`MediaPlayer` 已经 `start()` 过:`Loading(同一条)` ⇒ `Playing(同一条)`。
|
||||
*
|
||||
* 目标不对(用户已经点了别的 / 点了停)或当前不是 `Loading` 时**原样返回**——迟到的回调不许
|
||||
* 把界面从「另一条在响」拽回来。
|
||||
*/
|
||||
fun VoicePlaybackState.prepared(messageId: String): VoicePlaybackState =
|
||||
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
|
||||
VoicePlaybackState.Playing(messageId)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字节 / 起播失败:`Loading(同一条)` ⇒ `Failed(同一条, message)`;其余情况原样返回。
|
||||
*
|
||||
* 只从 [VoicePlaybackState.Loading] 出发是刻意的:**正在响的那一条**不会被一个迟到的失败
|
||||
* 回调说成失败(`Playing` 阶段的解码错误走 [finished] 那条路,由播放器自己先停下再报)。
|
||||
*/
|
||||
fun VoicePlaybackState.failed(messageId: String, message: String): VoicePlaybackState =
|
||||
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
|
||||
VoicePlaybackState.Failed(messageId, message)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/** 放完了:`Playing(同一条)` ⇒ [VoicePlaybackState.Idle](播完就回初始外观,不留痕迹)。 */
|
||||
fun VoicePlaybackState.finished(messageId: String): VoicePlaybackState =
|
||||
if (isPlayingFor(messageId)) VoicePlaybackState.Idle else this
|
||||
|
||||
/**
|
||||
* 停下来(用户点了停 / 换了另一条 / 离开会话 / 组件销毁)⇒ [VoicePlaybackState.Idle]。
|
||||
*
|
||||
* **任何态都收**,包括 [VoicePlaybackState.Failed]:失败那句话的生命周期与这一次尝试绑定,
|
||||
* 换了目标或页面重进就不该再挂着。
|
||||
*/
|
||||
val VoicePlaybackState.silenced: VoicePlaybackState get() = VoicePlaybackState.Idle
|
||||
@@ -0,0 +1,167 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/**
|
||||
* 「把这条消息念出来」这条路上,**本端要说的那几句话**,以及失败该分到哪一档。
|
||||
*
|
||||
* 这个文件里全是纯函数 / 常量,没有 Android、没有网络、没有状态:单测直接调(见
|
||||
* `VoiceSpeakTest`)。真正的取字节在 `data.voice.SpeechClient`,播放复用 `ui.chat.VoicePlayer`。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从一条消息解析出来的气泡里抽出**要念的那段纯文本**。
|
||||
*
|
||||
* ## 为什么只念 `body`
|
||||
* [ParsedBubble] 把一条气泡拆成三段:`head`(说话人抬头)、`body`(正文)、`tail`(旁白 / 动作)。
|
||||
* 念出来时**只念正文**:
|
||||
* - `head` 是「昔涟:」这种抬头,念出来像在念字幕;
|
||||
* - `tail` 里的旁白(`*她笑了笑*`)与动作(`[动作]`)是**舞台指示**,语音合成念它只会变成一串
|
||||
* 莫名其妙的字面量。
|
||||
*
|
||||
* ## 为什么按 `\n` 拼、且空就回 `null`
|
||||
* 一条消息可能被切成多个气泡(连续说两段);`\n` 让合成的停顿落在段落之间,比空格自然。
|
||||
* 全都为空(纯附件消息、纯旁白消息、或者解析完什么都不剩)时回 `null` —— 调用方据此**不显示**
|
||||
* 朗读入口,而不是让用户点一个必然拿到 `400` 的按钮。
|
||||
*
|
||||
* 取完 `trim`:首尾空白会变成合成音频开头 / 结尾的静音,没有任何用处。
|
||||
*/
|
||||
fun ttsTextOf(bubbles: List<ParsedBubble>): String? =
|
||||
bubbles.asSequence()
|
||||
.map { it.body }
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString("\n")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* 一条消息(`content` + `mode`)里**要念的那段文本**;`null` = 没有可念的正文。
|
||||
*
|
||||
* 这是「这条消息能不能朗读」的**唯一判据**:`ChatViewModel.speak` 用它决定发不发请求,
|
||||
* `MessageBubble` 用它决定画不画那枚图标。两处必须是同一个答案 —— 画了却发不出去、或者能发
|
||||
* 却不给入口,都是 bug。
|
||||
*
|
||||
* ## 为什么要看 `mode`
|
||||
* `chat`(或缺席)是「按 markup 切气泡的聊天式渲染」;其余 mode 下气泡里放的是**原文**,
|
||||
* 一条 markup 都不解析(`MessageBubble` 就是这么画的)。朗读要念的显然也是**用户看到的那段字**:
|
||||
* - 聊天式 ⇒ 走 [ttsTextOf] 剥掉抬头 / 旁白;
|
||||
* - 其余 ⇒ 原文原样,只 `trim`。
|
||||
*
|
||||
* 判据本身与 `MessageBubble` 里那句 `isChat` 一字不差,刻意留在这里一份:多写一份就会出现
|
||||
* 「界面按一种切法、朗读按另一种切法」的分叉。
|
||||
*/
|
||||
fun ttsTextOf(message: HistoryMessageDto): String? =
|
||||
if (message.mode.isNullOrBlank() || message.mode == "chat") {
|
||||
ttsTextOf(parse(message.content).filterNot { it.isEmpty })
|
||||
} else {
|
||||
message.content.trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次朗读失败该归到哪一档 —— 判据只看**服务端到底说了什么**,不看 HTTP 码的表面。
|
||||
*
|
||||
* ## 为什么分两档
|
||||
* 这两档对用户的意思完全不同,混成一句就等于什么都没说:
|
||||
* - [Soft]:**这会儿**没读出来(网络断了、令牌过期、网关抽风)。过一会儿再点是**有意义**的,
|
||||
* 所以文案只陈述事实、不诱导、也不吓人;
|
||||
* - [Unconfigured]:**配置**上就没这条路的。服务端明说了是哪种缺(`tts_persona_unmapped`
|
||||
* 没有发音人映射 / `tts_no_character` 连生效人格都没有),重试**永远不会**成功 —— 所以文案要说
|
||||
* 清「缺什么」,并且**不提重试**(提了就是在骗人点)。
|
||||
*/
|
||||
sealed interface TtsFailure {
|
||||
|
||||
/** 要说给用户的那一句话。 */
|
||||
val message: String
|
||||
|
||||
/** 再点一次**可能**有用吗?([Unconfigured] 恒 `false`:重试永远不会成功。) */
|
||||
val retryable: Boolean
|
||||
|
||||
/** 这会儿没读出来:不解释原因(本端确实不知道是哪一段断了),也不诱导重试。 */
|
||||
data object Soft : TtsFailure {
|
||||
override val message: String = TTS_SOFT_FAILURE_MESSAGE
|
||||
override val retryable: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端明说了配置缺一块。[code] 就是服务端那个错误码(原样留着,排查时对得上号)。
|
||||
*/
|
||||
data class Unconfigured(val code: String) : TtsFailure {
|
||||
override val message: String = when (code) {
|
||||
TTS_CODE_PERSONA_UNMAPPED -> TTS_PERSONA_UNMAPPED_MESSAGE
|
||||
TTS_CODE_NO_CHARACTER -> TTS_NO_CHARACTER_MESSAGE
|
||||
// 兜底:服务端回了一个本端还不认识的「配置缺失」类错误码。不猜它的意思,
|
||||
// 但也不装作是临时故障——它毕竟带着一个明确的 code。
|
||||
else -> TTS_UNCONFIGURED_MESSAGE
|
||||
}
|
||||
override val retryable: Boolean = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次朗读的异常分到 [TtsFailure] 两档里。
|
||||
*
|
||||
* 只有「服务端明确回了一个**配置缺失**类的 `code`」才进 [TtsFailure.Unconfigured];其余一切
|
||||
* ——网络失败、解析失败、401(令牌过期,续期后还能用)、`voice_unavailable` / `tts_failed` /
|
||||
* `tts_empty`(对面这会儿不行,不是本端的配置问题)——都进 [TtsFailure.Soft]。
|
||||
*
|
||||
* `tts_empty`(静默空音频)刻意归到柔和档:那是**这一次**合成出来的东西不能用,换个时间再试
|
||||
* 完全可能成功,说成「配置没配好」就是错的。
|
||||
*/
|
||||
fun ttsFailureOf(error: Throwable): TtsFailure {
|
||||
val cause = (error as? CyreneException)?.error ?: return TtsFailure.Soft
|
||||
return when (cause) {
|
||||
is CyreneError.Server -> when (cause.code) {
|
||||
TTS_CODE_PERSONA_UNMAPPED,
|
||||
TTS_CODE_NO_CHARACTER,
|
||||
-> TtsFailure.Unconfigured(cause.code)
|
||||
|
||||
else -> TtsFailure.Soft
|
||||
}
|
||||
|
||||
is CyreneError.Unauthorized -> TtsFailure.Soft
|
||||
is CyreneError.Local -> TtsFailure.Soft
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 朗读在某一条播放通道上的**键**:`tts:<messageId>`。
|
||||
*
|
||||
* 播放通道只有一条(`ui.chat.VoicePlayer` 的那个实例,与语音副本共用),所以键必须与语音副本
|
||||
* (键 = `voice` 的 `messageId`)分得开:前缀就是那道分界线。同一条消息的朗读与它的语音副本
|
||||
* 因此**互斥**(点其中一个会停掉另一个)——这是对的,同一时刻只该有一个声音。
|
||||
*/
|
||||
fun ttsClipIdOf(messageId: String): String = TTS_CLIP_PREFIX + messageId
|
||||
|
||||
/** 朗读入口那枚图标的读屏名字(也是设备验收时 `uiautomator` 的 `content-desc`)。 */
|
||||
const val TTS_SPEAK_DESCRIPTION: String = "朗读这条消息"
|
||||
|
||||
/** 正在朗读时那枚图标的读屏名字(点它 = 停)。 */
|
||||
const val TTS_STOP_DESCRIPTION: String = "停止朗读"
|
||||
|
||||
/** 正在取合成音频时那枚图标的读屏名字。 */
|
||||
const val TTS_LOADING_DESCRIPTION: String = "正在准备朗读"
|
||||
|
||||
/** 柔和档:这会儿没读出来。**不说原因**(本端确实不知道断在哪一段),也不提重试。 */
|
||||
const val TTS_SOFT_FAILURE_MESSAGE: String = "这会儿没能把这段读出来"
|
||||
|
||||
/** `tts_persona_unmapped`:人格有,但这个人格没有配发音人。 */
|
||||
const val TTS_PERSONA_UNMAPPED_MESSAGE: String = "这个角色还没配好发音人"
|
||||
|
||||
/** `tts_no_character`:连生效人格都没有(会话没绑定、也没有全局默认人格)。 */
|
||||
const val TTS_NO_CHARACTER_MESSAGE: String = "这个会话还没有配好发音人"
|
||||
|
||||
/** 服务端回了配置缺失类错误码、但本端还不认识那一个时的兜底文案。 */
|
||||
const val TTS_UNCONFIGURED_MESSAGE: String = "这个角色还没有配好朗读的发音人"
|
||||
|
||||
/** 朗读播放通道的键前缀(与语音副本的键 `messageId` 区分开)。 */
|
||||
const val TTS_CLIP_PREFIX: String = "tts:"
|
||||
|
||||
/** 服务端错误码:人格没有发音人映射。 */
|
||||
const val TTS_CODE_PERSONA_UNMAPPED: String = "tts_persona_unmapped"
|
||||
|
||||
/** 服务端错误码:连生效人格都没有。 */
|
||||
const val TTS_CODE_NO_CHARACTER: String = "tts_no_character"
|
||||
@@ -14,11 +14,28 @@ import kotlin.math.min
|
||||
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
|
||||
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
|
||||
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
|
||||
* - `<br>`(`<br>` / `<br/>` / `</br>` 三种写法等价,大小写不敏感,属性不解析):**与 `<p>` 同级
|
||||
* 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 `<br>` 始终处理为多条消息
|
||||
* 隔断」)。它**不是行内标签**:`<p>甲<br>乙</p>` ⇒ 两条气泡,`甲<br>乙`(无 `<p>`)⇒ 同样两条;
|
||||
* 中间的空气泡照旧丢弃(`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`)。切分在 [tokenize] 的**段/块这一层**
|
||||
* 完成([appendRegion]),所以行内解析层(`InlineMarkup`)几乎看不见它。
|
||||
* ⚠️ `回执三十三 §三②` 把「**始终**分隔」收紧了:**只有普通文本层的 `<br>` 才分隔**——
|
||||
* `<code>` / `<action>` / `<narration>` **内部**的不分隔([appendRegion] 的保护区,
|
||||
* 口径与理由写在它和 [protectedSpans] 的 KDoc 里)。
|
||||
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
|
||||
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
|
||||
* 注记内部**不做行内渲染**(既有口径),所以里面的 `<br>` 与 `<b>` 一样只是字面文本。
|
||||
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
|
||||
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
|
||||
* 关键词 旁白/内心 → narration,其余 → action。
|
||||
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<u>`
|
||||
* (**六项**标签名——`u` 由 `回执三十三 §一` 加回、`s` 仍不在内),成对才认、**标签名大小写不敏感**、
|
||||
* **属性一律不解析**(`<b class=x>` 就是粗体)、白名单外按纯文本原样显示、
|
||||
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
|
||||
* (口径与理由见 `parseInlineMarkup`)。
|
||||
* 它在**段落与 `<br>` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**),
|
||||
* 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
|
||||
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
|
||||
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
|
||||
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
|
||||
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
|
||||
@@ -35,7 +52,10 @@ import kotlin.math.min
|
||||
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
|
||||
*
|
||||
* `ParsedBubble` 结构(每条气泡渲染一个):
|
||||
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
|
||||
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
|
||||
* 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
|
||||
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
|
||||
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
|
||||
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
|
||||
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
|
||||
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
|
||||
@@ -47,7 +67,17 @@ import kotlin.math.min
|
||||
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
|
||||
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
|
||||
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* 行内开标签同样入栈扣留(词表见 [INLINE_TAG_NAMES],判据见 [parseInlineTag]):不扣的话
|
||||
* 中间态会把 `<b>` 当字面吐上屏(`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的
|
||||
* 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`<B class=x>` 在扣留侧同样是
|
||||
* 开标签,否则 `日落 <B class=x>18` 会先按字面吐上屏、收口时再变成粗体。
|
||||
* `<br>` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`<br`、`<br/`、
|
||||
* `</br`)时按词表扣留([isPotentialTagPrefix] → [couldBeTagName]),完整的 `<br>` 直接放行
|
||||
* ——分隔符一出现,前缀就已经可解析了。
|
||||
* **保护区内的 `<br` 另外还有一道**:`<code>` / `<action>` / `<narration>` 的开标签本来就在
|
||||
* 栈里,所以整个作用域(连同里面还没写完的 `<br`)一起被扣到闭合为止,残缺的 `<br` 不会
|
||||
* 在半截作用域里先露一次脸(由 `streamingWithholdsPartialLineBreakInsideProtectedScopes` 钉着)。
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`、`<br`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
||||
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
|
||||
@@ -83,6 +113,16 @@ data class ParsedBubble(
|
||||
val parts: List<ContentPart>,
|
||||
val head: List<ContentPart>,
|
||||
val tail: List<ContentPart>,
|
||||
/**
|
||||
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>` / `<u>`,见 [parseInlineMarkup]):非空时
|
||||
* 拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
|
||||
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
|
||||
*
|
||||
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
|
||||
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
|
||||
* 惊动任何一个既有调用方。
|
||||
*/
|
||||
val runs: List<InlineRun> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
|
||||
@@ -114,15 +154,42 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
|
||||
private const val FULL_STAR_MAX_LEN = 40
|
||||
|
||||
/**
|
||||
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
||||
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
|
||||
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
||||
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
|
||||
*
|
||||
* `<br>` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
|
||||
* [couldBeLineBreakNamePrefix],否则 `<BR` 会被漏扣、中间态把 `<BR` 当正文吐上屏。
|
||||
*/
|
||||
private val TAG_NAMES = listOf("action", "narration", "p")
|
||||
private val STRUCTURE_TAG_NAMES = listOf("action", "narration", "p")
|
||||
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
|
||||
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
|
||||
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
|
||||
private val CLOSE_P_TAG_RE = Regex("^</p>$")
|
||||
|
||||
/** `<br>` 分隔符的标签名([BR_TAG_RE] 与流式扣留侧共用,别在两处各写一遍)。 */
|
||||
private const val LINE_BREAK_TAG_NAME = "br"
|
||||
|
||||
/**
|
||||
* `<br>` **分隔符**(契约 v0.89 更正:`<br>` 不是行内标签,而是与 `<p>` 同级的「气泡/消息分隔符」)。
|
||||
*
|
||||
* 认下来的形状:`<br>` / `<br/>` / `<br />` / `</br>` / `<br class=x>` …——
|
||||
* - **三种写法等价**,前导 `/` 与自闭合 `/` 都照收(HTML5 口径,契约写死);
|
||||
* - **大小写不敏感**(`<BR>` / `<Br>` 同样算):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,
|
||||
* 且后端 IM 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 的原话);
|
||||
* - **属性不解析**(`<br class=x>` 照样分隔),但名字后面必须紧跟 `>`、空白或 `/`:
|
||||
* `<br2>` / `<boolean>` / `<br-x>` 因此**不是**分隔符,只是普通文本(这一条是「字符一个不丢」的闸门)。
|
||||
*
|
||||
* ⚠️ 已知边界:它**不看外层标签的属性结构**——所以写成 `<b title="<br>">甲</b>` 这种「属性里带 `<br>`」的
|
||||
* 文本会被切开(`回执三十三 §三③` 登记为两端口径相同、不修)。`<p …>` 开标签是唯一例外,因为它在更早
|
||||
* 一层就被 [P_BLOCK_RE] 整体吃掉了,属性里的 `<br>` 根本走不到这里。真实产出里没有这种形状
|
||||
* (属性本身就不是产出侧的东西)。
|
||||
*
|
||||
* ⚠️ 它只认**普通文本层**的那些:`<code>` / `<action>` / `<narration>` **内部**的 `<br>` 由
|
||||
* [appendRegion] 按保护区跳过(`回执三十三 §三②`)——所以 `findAll` 的结果**不再是**「所有分隔点」,
|
||||
* 用它时要么走 [appendRegion](会跳保护区),要么自己判一遍(别处没第二个消费者)。
|
||||
*/
|
||||
private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
|
||||
fun parse(raw: String): List<ParsedBubble> {
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
@@ -170,44 +237,217 @@ private sealed interface Tok {
|
||||
object Break : Tok
|
||||
}
|
||||
|
||||
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
|
||||
/**
|
||||
* 先切 `<p>` 块(决定气泡边界),再按区剥落单标签与切 `<br>` 分隔符,最后在每个文本区做旁白/星号解析。
|
||||
*/
|
||||
private fun tokenize(raw: String): List<Tok> {
|
||||
val pMatches = P_BLOCK_RE.findAll(raw).toList()
|
||||
// 完整块必然以 `</p>` 收尾,所以最后一个 `</p>` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话,
|
||||
// 每个 `<p` 候选点都会带着懒惰的 `([\s\S]*?)</p>` 一路回溯到串尾——正文里 N 个落单标签时
|
||||
// 这里就是 O(N²):实测 20000 个要 8.2 秒(同一输入下 `stripLooseTags` 那边还要 23 秒,见
|
||||
// [ContentAfterQuery])。切掉后同一输入 1 毫秒内出结果,且匹配结果逐字不变(每个匹配都整体落在
|
||||
// 被切下的前缀里,正则的尝试顺序也没变)。
|
||||
val lastClose = raw.lastIndexOf("</p>")
|
||||
val pMatches =
|
||||
if (lastClose < 0) {
|
||||
emptyList()
|
||||
} else {
|
||||
P_BLOCK_RE.findAll(raw.substring(0, lastClose + "</p>".length)).toList()
|
||||
}
|
||||
val out = ArrayList<Tok>()
|
||||
if (pMatches.isEmpty()) {
|
||||
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
|
||||
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
|
||||
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
|
||||
appendAnnotations(
|
||||
stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false),
|
||||
out,
|
||||
splitBlankLines = true,
|
||||
)
|
||||
appendRegion(raw, out, markupMode = false, stripStrayRoleTags = false, splitBlankLines = true)
|
||||
return out
|
||||
}
|
||||
var last = 0
|
||||
for (m in pMatches) {
|
||||
val lead = raw.substring(last, m.range.first)
|
||||
if (lead.isNotEmpty()) {
|
||||
appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false)
|
||||
appendRegion(lead, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
|
||||
}
|
||||
out.add(Tok.Break)
|
||||
val inner = m.groupValues[2]
|
||||
if (inner.isNotEmpty()) {
|
||||
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
|
||||
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
|
||||
appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false)
|
||||
appendRegion(inner, out, markupMode = true, stripStrayRoleTags = false, splitBlankLines = false)
|
||||
}
|
||||
out.add(Tok.Break)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
val trail = raw.substring(last)
|
||||
if (trail.isNotEmpty()) {
|
||||
appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false)
|
||||
appendRegion(trail, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个文本区:先剥落单标签([stripLooseTags],`markupMode` / `stripStrayRoleTags` 由调用方给),
|
||||
* 再按 `<br>` 分隔符切成若干段,每段各自做旁白/星号解析([appendAnnotations]),段与段之间补一个
|
||||
* [Tok.Break](气泡边界)。
|
||||
*
|
||||
* **为什么 `<br>` 在这一层切**:契约 v0.89 的更正把 `<br>` 定成**与 `<p>` 同级的分隔符**
|
||||
* (`回执三十一`「对消息内的 `<br>` 始终处理为多条消息隔断」)。它既然与 `<p>` 同级,就该与 `<p>`
|
||||
* 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
|
||||
* 行内层(`InlineMarkup`)因此几乎看不见 `<br>` —— 那里只剩六个白名单标签(唯一例外见下面的保护区:
|
||||
* `<code>` 内部的那个 `<br>` 会随正文交到行内层,在那里按「内容」字面显示)。
|
||||
*
|
||||
* **顺序:先切分、再逐段各自做行内解析**(`<b>甲<br>乙</b>` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理,
|
||||
* 两段都回落原文)。理由:
|
||||
* 1. 「与 `<p>` 同级」这句话本身就规定了层序——分隔符先于行内解析,行内解析永远只看到**一段**;
|
||||
* 跨过分隔符的标签在结构上不可能成立(一个气泡 = 一条消息,IM 出站也是先按气泡切开再剥标签,
|
||||
* 后端 `sanitize.go` 的「成对平衡 ⇒ 剥标签留文本 / 不平衡 ⇒ 原样保留」同样是在**单条消息内**
|
||||
* 判平衡的)⇒ 两端对同一段文本的结论天然一致;
|
||||
* 2. 反过来(先整体行内解析、再按 `<br>` 切)会让「渲染文本」与「气泡」互相依赖:`<b>甲<br>乙</b>`
|
||||
* 会先变成一个粗体片段再被切开,切点落在片段内部,`body` / `runs` 的一致性(`runs` 拼起来
|
||||
* **就是** `body`)当场就没了;
|
||||
* 3. 实现上也最省:行内那层一个字节都不用改(它本来就逐气泡跑一次)。
|
||||
*
|
||||
* 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `<p>` 后面还有没有正文」看的是
|
||||
* 整段原文,`<br>` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。
|
||||
*
|
||||
* ## 保护区:`<code>` 与角色注记**内部**的 `<br>` 不分隔(`回执三十三 §三②`)
|
||||
* `回执三十一` 说的是「**始终**分隔」,`回执三十三` 把它收紧了:**只有普通文本层的 `<br>` 才分隔**。
|
||||
* 后端给的两条理由在我们这边一字不差地同样成立,所以照改:
|
||||
* 1. **会漏注记正文**:`<action>她<br>点点头</action>` 若切成两段,就成了「她」「点点头」**两条消息**
|
||||
* ——注记本来是不该当正文露出来的那一层,切开等于把它摆到台面上(后端原话:「那等于把注记正文
|
||||
* 发给了对方」);
|
||||
* 2. **`<code>` 会被切坏**:它是**成对**元素,从中间切开必然两半都不平衡,按「不平衡 ⇒ 不渲染」的
|
||||
* 硬口径**整段降级为字面量**,用户直接看到裸标签。
|
||||
*
|
||||
* 实现:先扫出保护区区间([protectedSpans]),切分时**跳过**落在区间里的 `<br>`(它留在原文里,随该
|
||||
* 作用域自己的语义走)。扫的是**已经剥过落单标签**的那一份文本,与切分用的是同一个对象。
|
||||
*
|
||||
* ### 作用域内部算什么语义(我们定的口径):**普通字符、逐字原样**(不换行、也不消掉)
|
||||
* - `<code>`:这是它**既有的**口径——「里面连白名单标签也按内容处理」,`<code><b>甲</b></code>`
|
||||
* 显示字面的 `<b>甲</b>`(`codeContentIsNotParsed` 钉着)。`<br>` 在这一层本来不是标签,用不着
|
||||
* 另立规矩;`<code>` 也正好是「想原样显示尖括号怎么办」的现成答案。
|
||||
* - `<action>` / `<narration>`:**必须是字面**。注记那一行画的是 `parts` 里的原文
|
||||
* (`MessageBubble.AnnotationLine` 直接 `parts.joinToString`),而 `parts` = **逐字原文**是红线
|
||||
* ——换成 `\n` 或删掉,就是在静默改用户内容(编辑框、搜索、通知读的都是它)。顺带也就与既有
|
||||
* 口径对齐:`<action>笑<b>了</b></action>` 里的 `<b>` 同样是字面(`narrationTextIsLeftExactlyAsWritten`)。
|
||||
* - 两个作用域取**同一条**规矩:它们是同一件事(`<br>` 落在作用域内部),各给一种读法才是真的
|
||||
* 「各端各说各话」。
|
||||
* - 为什么不把它当换行(`<code>甲<br>乙</code>` ⇒ `甲\n乙`):那必须在一处破例——要么在这里改掉
|
||||
* 片段文本(`parts` 当场不再是原文),要么在行内层给 `<br>` 单开一条「代码里它算换行」的规则
|
||||
* (W68 刚把「`<br>` → `\n`」从行内层整体删掉,而且那会让**同两个字符**在 `<code>` 与 `<action>`
|
||||
* 里各是一种意思)。两条都不值当,所以不做。
|
||||
*
|
||||
* ### 未闭合的作用域:**不算作用域**,里面的 `<br>` 照旧分隔
|
||||
* 判据只看「**成对**」,且与各自的消费侧同一把尺子:`<code>` 要找到它的 `</code>`(大小写不敏感、
|
||||
* 可带属性——行内层就这么认),`<action>` / `<narration>` 要找到**同名**闭合(只认小写、不带属性
|
||||
* ——`ROLE_RE` 就这么认)。找不到闭合的开标签不是作用域,于是 `<code>甲<br>乙` 照旧切成两条。
|
||||
* - 理由一:**消费侧本来就不认它**。未闭合的 `<code>` 在行内层不是作用域(整段降级为原文),
|
||||
* 未闭合的 `<action>` 在 `ROLE_RE` 那里也配不出旁白。去保护一个**消费侧根本不认**的作用域,
|
||||
* 等于让「切分层」与「消费层」对同一段文本有两种结构判断——正是本项目一路在防的那种分叉
|
||||
* (W68/W69 的教训:消费侧与扣留侧的词表必须同源)。
|
||||
* - 理由二:**爆炸半径**。若把「未闭合 ⇒ 保护到这一段文本结尾」也算上,一个漏掉 `</code>` 的笔误
|
||||
* 就会把**后面所有** `<br>` 一起吞掉、把本该分开的多条消息并成一条。「一个畸形标签改变整段的
|
||||
* 分段」比「照旧分隔」坏得多。
|
||||
* - 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切;
|
||||
* 我们选与消费侧一致的那种。
|
||||
* - **两支各算各的**([protectedSpans] 用两个独立状态):`<code>` 与角色注记各自配对,因为那本来
|
||||
* 就是两个消费侧各自的尺子——行内层在 `<code>` 里只认自己的闭合、别的一律当内容;`ROLE_RE`
|
||||
* 则完全不看 `<code>`(它在代码里照样配对)。所以「某一支没闭合」只影响**那一支**:
|
||||
* `<code>甲<action>她<br>点点头</action>乙` 里那个**已经配对好**的 `<action>` 照样保护它的
|
||||
* `<br>`(不产生区间的只是没闭合的 `<code>` 那一支)。
|
||||
*/
|
||||
private fun appendRegion(
|
||||
text: String,
|
||||
out: MutableList<Tok>,
|
||||
markupMode: Boolean,
|
||||
stripStrayRoleTags: Boolean,
|
||||
splitBlankLines: Boolean,
|
||||
) {
|
||||
val stripped = stripLooseTags(text, markupMode, stripStrayRoleTags)
|
||||
// 保护区只在「这一区里真有作用域」时才扫:绝大多数消息连 `<` 都没有(一个字节都不多花)。
|
||||
val protected = if (stripped.indexOf('<') < 0) emptyList() else protectedSpans(stripped)
|
||||
var last = 0
|
||||
var span = 0
|
||||
for (m in BR_TAG_RE.findAll(stripped)) {
|
||||
val at = m.range.first
|
||||
// 区间按起点升序,跟着 `<br>` 的推进往前走一个游标就够(不回头、不重扫)。
|
||||
while (span < protected.size && protected[span].last < at) span++
|
||||
if (span < protected.size && at in protected[span]) continue
|
||||
val piece = stripped.substring(last, at)
|
||||
if (piece.isNotEmpty()) appendAnnotations(piece, out, splitBlankLines)
|
||||
out.add(Tok.Break)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
val tail = stripped.substring(last)
|
||||
if (tail.isNotEmpty()) appendAnnotations(tail, out, splitBlankLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫出这一区里**已配对**的保护区区间(左闭右开,按**起点**升序):`<code>…</code>` 与
|
||||
* `<action>…</action>` / `<narration>…</narration>`——[appendRegion] 据此跳过落在里面的 `<br>`
|
||||
* (口径与理由写在它的 KDoc 里)。
|
||||
*
|
||||
* 一趟线性扫描,判据全部借现成的尺子:「哪儿是一个标签」用行内层的 [inlineTagEnd];`code` 用
|
||||
* 行内层的 [openInlineTagName] / [closeInlineTagName](大小写不敏感、可带属性);角色注记用
|
||||
* [openNoteTagName] / [closeNoteTagName](与 `ROLE_RE` 同口径:只认小写、不带属性、找**同名**闭合)。
|
||||
*
|
||||
* **两支各自独立配对**,因为那本来就是两个消费侧各自的尺子:`<code>` 那一支只认自己的开/闭
|
||||
* (里面别的标签都是内容,与行内层一致);角色那一支同理(与 `ROLE_RE` 的非贪婪配对一致——它
|
||||
* 在 `<code>` 里照样配对,两边各算各的)。任一支没闭合 ⇒ **那一支**不产生区间。
|
||||
*
|
||||
* `indexOf('<')` + 逐段推进的扫描区间互不重叠(见 [inlineTagEnd]),每个 `<br>` 的判定是两个游标
|
||||
* 各走一次,因此整体仍是 O(n):成串的尖括号或成串的作用域都不会让它退回 O(N²)。
|
||||
*/
|
||||
private fun protectedSpans(text: String): List<IntRange> {
|
||||
val codeSpans = ArrayList<IntRange>(2)
|
||||
val noteSpans = ArrayList<IntRange>(2)
|
||||
var openCode = -1
|
||||
var openNoteName: String? = null
|
||||
var openNote = -1
|
||||
var i = 0
|
||||
while (true) {
|
||||
val lt = text.indexOf('<', i)
|
||||
if (lt < 0) break
|
||||
val gt = inlineTagEnd(text, lt)
|
||||
if (gt < 0) {
|
||||
// 这一处起头的东西不是标签:往后挪**一个字符**(与 `claimTags` 同一把尺子,
|
||||
// 不能跳到某个「下一个 `>`」——那会把中间真正的标签一起跳过)。
|
||||
i = lt + 1
|
||||
continue
|
||||
}
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
val end = gt + 1
|
||||
if (openCode < 0) {
|
||||
if (openInlineTagName(tag) == CODE) openCode = lt
|
||||
} else if (closeInlineTagName(tag) == CODE) {
|
||||
codeSpans.add(openCode until end)
|
||||
openCode = -1
|
||||
}
|
||||
val noteOpen = if (openNoteName == null) openNoteTagName(tag) else null
|
||||
if (noteOpen != null) {
|
||||
openNoteName = noteOpen
|
||||
openNote = lt
|
||||
} else if (openNoteName != null && closeNoteTagName(tag) == openNoteName) {
|
||||
noteSpans.add(openNote until end)
|
||||
openNoteName = null
|
||||
}
|
||||
i = end
|
||||
}
|
||||
// 两支各自升序,合起来也按**起点**升序——[appendRegion] 的包含判定(「第一个右端 ≥ 这个 `<br>`
|
||||
// 的区间」)以「按起点升序」为前提。合并后两支的区间可能相互嵌套(`<code>` 套注记、注记套
|
||||
// `<code>`),那不影响判定:嵌套的那些都在同一个升序序列里。
|
||||
return when {
|
||||
noteSpans.isEmpty() -> codeSpans
|
||||
codeSpans.isEmpty() -> noteSpans
|
||||
else -> (codeSpans + noteSpans).sortedBy { it.first }
|
||||
}
|
||||
}
|
||||
|
||||
/** 角色注记的**开**标签名(只认小写的 `action` / `narration`、不带属性——与 `ROLE_RE` 同口径)。 */
|
||||
private fun openNoteTagName(tag: String): String? = OPEN_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
|
||||
|
||||
/** 角色注记的**闭**标签名(同上;配对要求**同名**,与 `ROLE_RE` 的 `</\s*\1\s*>` 一致)。 */
|
||||
private fun closeNoteTagName(tag: String): String? = CLOSE_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
|
||||
|
||||
/**
|
||||
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
|
||||
*
|
||||
@@ -224,6 +464,9 @@ private fun tokenize(raw: String): List<Tok> {
|
||||
private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String {
|
||||
if (text.indexOf('<') < 0) return text
|
||||
val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList()
|
||||
// 「这个落单 `<p>` 后面还有没有正文」按位置记表:正文里 N 个落单标签,现扫是 O(N²)(见
|
||||
// [ContentAfterQuery] 的 KDoc)。常规消息(没有落单 `<p>` 开标签)一次都不建表。
|
||||
val contentAfter = ContentAfterQuery(text)
|
||||
val out = StringBuilder(text.length)
|
||||
var last = 0
|
||||
var i = 0
|
||||
@@ -234,7 +477,7 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
|
||||
if (gt < 0) break
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
val drop = when {
|
||||
OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1)
|
||||
OPEN_P_TAG_RE.matches(tag) -> markupMode || !contentAfter.hasContentAfter(gt + 1)
|
||||
CLOSE_P_TAG_RE.matches(tag) -> true
|
||||
!stripStrayRoleTags -> false
|
||||
roleRanges.any { lt in it } -> false
|
||||
@@ -253,27 +496,78 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** [from] 之后是否还有正文:跳过空白与后续的 `<p>` 族标签后仍非空即为有。 */
|
||||
private fun hasContentAfter(text: String, from: Int): Boolean {
|
||||
var i = from
|
||||
while (i < text.length) {
|
||||
if (text[i].isWhitespace()) {
|
||||
i++
|
||||
continue
|
||||
/**
|
||||
* [stripLooseTags] 判落单 `<p>` 用的「[from] 之后还有没有正文」查询:空白跳过,`<p>` / `</p>`
|
||||
* 整段跳过,其余字符(别的标签、落单的 `<`)一律算有正文——跳过空白与后续 `<p>` 族标签后仍非空
|
||||
* 即为有。判据与原实现逐字一致,只多了记忆化。
|
||||
*
|
||||
* **为什么要记忆化**:这条判据原本每次查询都一路现扫到串尾,而 [stripLooseTags] 对**每一个**
|
||||
* 落单 `<p>` 都要问一次,正文里 N 个落单标签就是 O(N²)——20000 个落单 `<p>` 的输入实测这条判据
|
||||
* 要 23 秒(同一次 `parse` 合计 31 秒),而 `parse` 跑在主线程上(`ui/chat/MessageBubble.kt` 的
|
||||
* `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。
|
||||
*
|
||||
* **为什么可以只算一次**:答案只取决于**位置**——同一个位置再问一次,走的一定是同一条链、到的
|
||||
* 一定是同一个终点。所以走链时把沿途每个位置的答案落表,之后 O(1) 命中;每个位置至多被算一次,
|
||||
* N 个落单标签整体就是线性的(同一输入实测这条判据从 23 秒降到毫秒级)。
|
||||
*
|
||||
* **为什么不改变语义**:判定用的就是原来那段扫描本身([next] 逐分支对应原循环),没有另立一套
|
||||
* 「什么算 `<p>` 标签」的判据——仍是同一组 [OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE]。落表的答案是同一条
|
||||
* 扫描推出来的,因此与逐次现扫逐字相同;连「起点落在标签内部」这种查询也一致(原扫描看到的是
|
||||
* 标签里的字符、判有正文,[next] 在这里同样返回 null)。
|
||||
*
|
||||
* 惰性:没遇到落单 `<p>` 开标签的常规消息一次也不建表,一个字节都不多花。
|
||||
*/
|
||||
private class ContentAfterQuery(private val text: String) {
|
||||
/** 每个位置的答案:`0` = 还没算、[NOISE_ONLY] = 后面只剩空白与 `<p>` 族标签、[HAS_CONTENT] = 有正文。 */
|
||||
private val answers: ByteArray by lazy(LazyThreadSafetyMode.NONE) {
|
||||
ByteArray(text.length + 1).also { it[text.length] = NOISE_ONLY }
|
||||
}
|
||||
|
||||
/** 本次走链途经的位置,用来把 [answers] 一次落表。 */
|
||||
private val path: IntArray by lazy(LazyThreadSafetyMode.NONE) { IntArray(text.length) }
|
||||
|
||||
/** 与原先「每次现扫到串尾」的判定逐字等价。 */
|
||||
fun hasContentAfter(from: Int): Boolean {
|
||||
var walked = 0
|
||||
var i = from
|
||||
while (true) {
|
||||
val known = answers[i]
|
||||
if (known != UNKNOWN) return finish(walked, known)
|
||||
path[walked++] = i
|
||||
val step = next(i)
|
||||
if (step == null) return finish(walked, HAS_CONTENT)
|
||||
i = step
|
||||
}
|
||||
if (text[i] == '<') {
|
||||
}
|
||||
|
||||
/** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */
|
||||
private fun finish(walked: Int, answer: Byte): Boolean {
|
||||
for (k in 0 until walked) answers[path[k]] = answer
|
||||
return answer == HAS_CONTENT
|
||||
}
|
||||
|
||||
/**
|
||||
* 原扫描的一步:空白 → 下一位;`<p>` 族标签 → 标签之后;其余(别的标签、落单的 `<`)→ `null`,
|
||||
* 即「有正文」,扫描到此为止。走到 `text.length` 时由 [answers] 里预置的答案收口。
|
||||
*/
|
||||
private fun next(i: Int): Int? {
|
||||
val c = text[i]
|
||||
if (c.isWhitespace()) return i + 1
|
||||
if (c == '<') {
|
||||
val gt = text.indexOf('>', i)
|
||||
if (gt >= 0) {
|
||||
val tag = text.substring(i, gt + 1)
|
||||
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) {
|
||||
i = gt + 1
|
||||
continue
|
||||
}
|
||||
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) return gt + 1
|
||||
}
|
||||
}
|
||||
return true
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UNKNOWN: Byte = 0
|
||||
const val NOISE_ONLY: Byte = 1
|
||||
const val HAS_CONTENT: Byte = 2
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
|
||||
@@ -416,9 +710,21 @@ private fun toBubble(tokens: List<Tok>): ParsedBubble? {
|
||||
tail.add(part)
|
||||
}
|
||||
}
|
||||
val bodyText = body.toString().trim()
|
||||
// 行内标签在**段落与 `<br>` 都定稿之后**才消费(就在这一行,且是**逐气泡各自一份**):上面那套
|
||||
// 分段与归属一个字都不动,分段判据因此完全看不见行内标签(`<br>` 已经在 [appendRegion] 那层
|
||||
// 变成气泡边界了)。runs 为空 = 这段正文按字面画(没有可渲染的行内标签、或行内标签结构不平衡
|
||||
// 被整段降级),一个字都不会变。
|
||||
val rawBody = body.toString().trim()
|
||||
val runs = parseInlineMarkup(rawBody)
|
||||
val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text }
|
||||
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
|
||||
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
|
||||
return ParsedBubble(
|
||||
body = bodyText,
|
||||
parts = parts.toList(),
|
||||
head = head.toList(),
|
||||
tail = tail.toList(),
|
||||
runs = runs,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -463,28 +769,62 @@ private fun stablePrefixEnd(buf: String): Int {
|
||||
|
||||
private fun openTagName(tag: String): String? {
|
||||
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
|
||||
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
|
||||
if (OPEN_P_TAG_RE.matches(tag)) return "p"
|
||||
// 行内开标签也入栈(`<B class=x>` 这类形态由 [parseInlineTag] 判,与解析侧同源)。
|
||||
// `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在它前面,后面的话再也
|
||||
// 显示不出来)——[openInlineTagName] 对 `<br>` / `<br/>` / `</br>` 一律返回 null。
|
||||
return openInlineTagName(tag)
|
||||
}
|
||||
|
||||
private fun closeTagName(tag: String): String? {
|
||||
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
|
||||
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
|
||||
if (CLOSE_P_TAG_RE.matches(tag)) return "p"
|
||||
return closeInlineTagName(tag)
|
||||
}
|
||||
|
||||
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
|
||||
/**
|
||||
* 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。
|
||||
*
|
||||
* 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
|
||||
* - 段落/旁白只认小写(`<P>` / `<Action>` 原样显示,既有契约)——名字里出现大写就与它无关;
|
||||
* - 行内标签与 `<br>` 分隔符**大小写不敏感**(契约 v0.89,[parseInlineTag] / [BR_TAG_RE])
|
||||
* ——`<B` 与 `<b`、`<BR` 与 `<br` 一样要扣留。
|
||||
* 少扣了就是「中间态把标签当正文吐上屏、收口时才吃掉」,屏幕上显示过的字会改口。
|
||||
* 名字之后是空白或 `/` 都算「还可能补全」(属性 / 自闭合,v0.89 允许带属性,所以 `<b class=x>`
|
||||
* 与 `<br/` 这两个中间态也必须扣住);其它字符说明它压根不是标签(`<boolean>`、`<b2`)。
|
||||
*/
|
||||
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
|
||||
var j = lt + 1
|
||||
if (j < buf.length && buf[j] == '/') j++
|
||||
while (j < buf.length && buf[j].isWhitespace()) j++
|
||||
val nameStart = j
|
||||
while (j < buf.length && buf[j] in 'a'..'z') j++
|
||||
while (j < buf.length && isTagNameLetter(buf[j])) j++
|
||||
val name = buf.substring(nameStart, j)
|
||||
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
|
||||
if (name.isEmpty()) return false
|
||||
if (!buf[j].isWhitespace()) return false
|
||||
return TAG_NAMES.any { it.startsWith(name) }
|
||||
if (name.isEmpty()) return j >= buf.length
|
||||
if (!couldBeTagName(name)) return false
|
||||
if (j >= buf.length) return true
|
||||
return buf[j].isWhitespace() || buf[j] == '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* [name] 是否可能是某个已知标签名的前缀;三半各按自己的大小写口径(见 [isPotentialTagPrefix]):
|
||||
* 段落/旁白小写、`<br>` 与行内标签大小写不敏感。
|
||||
*/
|
||||
private fun couldBeTagName(name: String): Boolean {
|
||||
if (name.all { it in 'a'..'z' } && STRUCTURE_TAG_NAMES.any { it.startsWith(name) }) return true
|
||||
val lower = name.lowercase()
|
||||
if (couldBeLineBreakNamePrefix(lower)) return true
|
||||
return couldBeInlineTagNamePrefix(lower)
|
||||
}
|
||||
|
||||
/**
|
||||
* [name](已小写)是否可能是 `<br>` 分隔符的标签名前缀——`<br` / `</br` / `<BR` 这种残缺形态要不要
|
||||
* 扣留由它说了算。判据与 [BR_TAG_RE] 同口径(大小写不敏感的 `br`):两边一分叉,
|
||||
* `甲<br` 会先把字面 `<br` 吐上屏,等 `>` 到了再变成两条气泡。
|
||||
*/
|
||||
private fun couldBeLineBreakNamePrefix(name: String): Boolean =
|
||||
name.isNotEmpty() && LINE_BREAK_TAG_NAME.startsWith(name)
|
||||
|
||||
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
|
||||
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
|
||||
var count = 0
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
package top.yeij.cyrene.domain.parse
|
||||
|
||||
/*
|
||||
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
|
||||
*
|
||||
* 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 `<b>`
|
||||
* 在她那台设备上粗、在这台上不粗)。四条硬口径:
|
||||
* 1. 白名单 = `b` / `strong` · `i` / `em` · `code` · `u`(**成对**,六项标签名);标签名**大小写不敏感**;
|
||||
* 2. **属性一律不解析**:`<b class=x onclick=y>` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
|
||||
* 3. **白名单外一律按纯文本原样显示**:`<script>` 显示成字面量,字符一个不丢;
|
||||
* 4. **结构不平衡 ⇒ 整段降级为原文**(未闭合 / 落单闭标签 / 交错)——粗粒度是有意的,换来
|
||||
* 「字符一个不丢」可以被逐字断言。
|
||||
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`code` → 等宽 + 轻微底色(且**里面的标签不解析**),
|
||||
* `u` → 下划线。`s` **不在白名单里**:它按第 3 条逐字原样显示(见下)。
|
||||
*
|
||||
* ## `br` 不在这里(契约同日更正)
|
||||
* `回执三十一`:「对消息内的 `<br>` **始终处理为多条消息隔断**」⇒ `<br>` **不是行内标签**,而是与
|
||||
* `<p>` 同级的**气泡/消息分隔符**,切分在 [tokenize](ChatContentParser.kt) 的段/块那一层
|
||||
* (`ChatContentParser.appendRegion`)完成,`<br>` / `<br/>` / `</br>` 三种写法等价。
|
||||
* 这一层因此**只认成对标签**:`<br>` 到了这里只是几个普通字符,与 `<br2>`、`<boolean>` 同类。
|
||||
* 它**真的会**走到这里,只是只有一条路:`<code>` 作用域内部——分隔符切分层有一个**保护区**
|
||||
* (`ChatContentParser.protectedSpans`,`回执三十三 §三②`),落在 `<code>` / `<action>` / `<narration>`
|
||||
* 内部的 `<br>` 不分隔,于是 `<code>` 里的那个 `<br>` 会随正文一起交到这一层,在代码作用域里按
|
||||
* 「内容」处理(字面显示,与 `<code><b>甲</b></code>` 里的 `<b>` 同一规矩)。
|
||||
*
|
||||
* ## 白名单六项(`u` 加回、`s` 不做)
|
||||
* `回执三十` 先把白名单收窄到五项、撤掉 `u`/`s`(理由是「多支持 = 制造跨端不一致」),但
|
||||
* `回执三十三 §一` 更正:**那份「收窄到五项」是后端的疏漏**——主人随后裁定下划线保留、后端把 `u`
|
||||
* 加回了契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。最终白名单 = **六项**:
|
||||
* `b` / `strong` / `i` / `em` / `code` / **`u`**,`u` 在这里渲染成下划线。
|
||||
* **`s` 仍不在内**(逐字原样)——「多支持反而制造跨端不一致」这条理由对它依然成立:Web 不渲染它。
|
||||
*
|
||||
* ## 为什么是「整段降级」而不是「只降级那一处」
|
||||
* W67 那版是**局部**降级:`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗,只有后面那半个 `<b>` 留着字面。
|
||||
* 契约把「结构不平衡」定成**整段不渲染**:一处不平衡,这一整段就一个标签都不认、原文逐字显示。
|
||||
* 代价是「好好的一对 `<b>` 会被邻居的落单标签连累」,换来的是**可以逐字断言**的粗粒度:
|
||||
* 「渲染文本 == 原文」或者「渲染文本 == 原文消费掉全部配对标签」,只有这两种形状,没有中间态
|
||||
* (局部降级那种「一半认、一半留」在跨端对齐时最容易各说各话)。见 [parseInlineMarkup]。
|
||||
*
|
||||
* ## 「整段」是哪一段(我们采用的口径 + 理由)
|
||||
* **整段 = 该气泡的正文**,也就是 [toBubble](ChatContentParser.kt) 里 `body.toString().trim()`
|
||||
* 出来的那一段(`ParsedBubble.body` 对应的文本,通常正好是一个 `<p>…</p>` 块的正文,也可能是
|
||||
* 块外文本并入相邻气泡后的那一份)。不是整条消息,也不是单个 `<p>` 块:
|
||||
* - **不是整条消息**:一条消息可以有多个分隔(`<p>` / `<br>`)= 多条气泡,一个气泡里写坏了一个
|
||||
* 标签,不该把**别的**气泡一起按字面画出来——那既不是契约想说的「这一段」,也会让「她写坏了
|
||||
* 一个标签」这种局部事故看起来像整条消息都坏了。
|
||||
* - **不是单个 `<p>` 块**(虽然大多数时候两者重合):本端渲染的**最小单位就是气泡**,
|
||||
* `body` / `runs` 也都是按气泡算的。降级粒度与渲染粒度取同一个,实现上才有一条清晰的边界
|
||||
* (`toBubble` 一次调用 = 一次判定),断言也才写得出「这条气泡是原文、那条气泡照常渲染」。
|
||||
* - 后端已认可这一点(`回执三十`:「契约只承诺『不平衡 ⇒ 不渲染、字符一个不丢』,**具体粒度算
|
||||
* 实现细节**」)。
|
||||
* - 因为切分(`<p>` / `<br>`)**先于**行内解析,跨分隔符的标签天然分属两段:
|
||||
* `<b>甲<br>乙</b>` ⇒ 两段各不平衡 ⇒ 两段都回落原文。
|
||||
*
|
||||
* ## 为什么不改 `parts`,只在 `ParsedBubble` 上加一个字段
|
||||
* `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、
|
||||
* 搜索片段(`SessionSearchModel.plainSearchText`)、通知文案(`ProactiveNotify`)读的都是 `parts`,
|
||||
* 在那里把标签改掉就是**静默改用户内容**。所以行内解析不改片段,只在 `toBubble` 里对**已经定稿的
|
||||
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(白名单标签已消费),
|
||||
* 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。
|
||||
*
|
||||
* ## 与段落解析的分工(为什么不在 `tokenize` 里做)
|
||||
* 行内解析发生在**段落/分隔符边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 与
|
||||
* `<br>` 分气泡、空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。
|
||||
* 这也让 W63 在 `tokenize` 里去掉的两处 O(N²) 一行都不受影响:这里是对最终正文的一趟线性扫描。
|
||||
*
|
||||
* ## 大小写不敏感(v0.89)与「扣留侧必须一起改」
|
||||
* `<B>` / `<STRONG>` 都认——但**消费侧与流式扣留侧([ChatContentParser.stablePrefixEnd])
|
||||
* 必须同源**,否则两边分叉:`<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>`
|
||||
* 到了再吃掉,**屏幕上已经显示过的字就改口了**。所以标签名的字母判据([isTagNameLetter])与
|
||||
* 「这个名字还可能补全吗」([couldBeInlineTagNamePrefix],以及 `ChatContentParser.isPotentialTagPrefix`
|
||||
* 里的 `couldBeTagName`)都收在**这一份文件**里,两边引用同一份名单。
|
||||
* 段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍然**只认小写**:这是既有契约
|
||||
* (`plainAngleBracketsAreNeverEaten` 钉着 `<P>` 原样、`MessageEditText` 同一口径),
|
||||
* 且契约明确「`<action>`/`<narration>` 的既有行为逐字未改」。
|
||||
*
|
||||
* ## 明确不做(逐条理由)
|
||||
* 1. **块级(`<p>` 之外的 `<div>` / `<h1>` / 列表…)不做**:契约写死「段落仍走 `<p>`」。
|
||||
* 2. **`<a href>` 不做**:它不是一个样式,是一个**点击行为**(要唤浏览器),必须配一套安全策略
|
||||
* (只认 http/https、不许 `intent://` / `javascript:` 之类)。契约把它划成独立安全面,实测 0 命中。
|
||||
* 3. **Markdown 的 `*` / `_` 一律不做**:星号这个字形在本项目**已经被占走了**——`ROLE_RE` 把全角
|
||||
* `*…*` 与半角 `*(动作|旁白|内心|神态|轻声)…*` 判成旁白/动作(`*笑*` 是动作,不是斜体),
|
||||
* 既有测试钉着这条语义。再给同两个星号一个「斜体」的说法,同一条消息就有两种读法,还要先猜
|
||||
* 哪一种是她的本意——那是改既有语义,不是加渲染。
|
||||
* 4. **`#` 标题 / `- ` 列表 / 表格 / ``` 围栏 / `[链接](url)` / `~~删除~~` 一律不做**:真后端最近
|
||||
* 100 条实测 0 命中;而且这些是**行级**规则,会把「正文里真有一行以 `- ` 开头」这种普通文本
|
||||
* 改写成别的东西——0 命中 + 有改写风险 = 不做。
|
||||
* 5. **HTML 实体(`<` `&` ` ` …)不做**:实测 0 命中;实体是一张**开放的清单**
|
||||
* (`'` / `'` / 各种命名实体……),做一半比不做更糟——会出现「`&` 解了、`'`
|
||||
* 却原样显示」这种谁也说不清的口径。想在正文里显示尖括号,今天有 `<code>` 这一档。
|
||||
* 6. **`<script>` / `<style>` 之类只会「原样显示」,绝不会被当 HTML 注入**:渲染侧是
|
||||
* `AnnotatedString` + `SpanStyle`(结构化),正文从来没有、也不会走任何 HTML 解析/注入路径。
|
||||
*
|
||||
* 契约与边界口径见 [parseInlineMarkup]。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
|
||||
*
|
||||
* 只有这四种:换行不是样式——`<br>` 是气泡分隔符(切在 `ChatContentParser.tokenize` 那一层),
|
||||
* 它只会在 `<code>` 作用域内部作为**内容**走到这里(按文件头那条口径字面显示),永远不成样式。
|
||||
*/
|
||||
enum class InlineStyle {
|
||||
Bold,
|
||||
Italic,
|
||||
Code,
|
||||
|
||||
/** `<u>`:下划线(渲染成 `TextDecoration.Underline`)。`s` 不在白名单里,所以没有对应项。 */
|
||||
Underline,
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文里**样式一致的一段连续文本**:片段按顺序拼起来就是渲染文本,样式区间由「起始下标 + 长度」
|
||||
* 就地累加得到(第 n 段的区间 = 前面各段长度之和 until 加上自己)。
|
||||
*
|
||||
* 相邻且样式相同的片段由 [parseInlineMarkup] 合并,所以同一段文本只有一种片段形状——
|
||||
* 断言起来就是在断言「哪段字是什么样式」。
|
||||
*/
|
||||
data class InlineRun(val text: String, val styles: Set<InlineStyle>)
|
||||
|
||||
/**
|
||||
* `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。
|
||||
* `internal` 是因为 `ChatContentParser.protectedSpans` 也要判它(保护区扫描),
|
||||
* 名字只写这一处、两边不许各写一遍。
|
||||
*/
|
||||
internal const val CODE = "code"
|
||||
|
||||
/**
|
||||
* 成对标签的名字(契约 v0.89 最终版的**六项**,见文件头):`strong` / `em` 是 `b` / `i` 的同义写法,
|
||||
* `u` 是 `回执三十三 §一` 加回来的下划线。
|
||||
* 只写这一处,[parseInlineTag] 与 [couldBeInlineTagNamePrefix] 都由它推出来。
|
||||
* `br` 不在这里(它是分隔符,见文件头);`s` 也不在(逐字原样)。
|
||||
*/
|
||||
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE, "u")
|
||||
|
||||
/**
|
||||
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
|
||||
* 同一份名单既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
|
||||
* 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。
|
||||
*/
|
||||
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES
|
||||
|
||||
/**
|
||||
* 白名单行内标签认下来的两种形态。**认不出来的(白名单外、形状不对)一律返回 null**,
|
||||
* 由调用方当普通文本原样显示(契约第 3 条:字符一个不丢)。
|
||||
*/
|
||||
internal sealed interface InlineTag {
|
||||
val name: String
|
||||
|
||||
/** 成对的开标签(`<b>`、`<STRONG>`、`<b class=x onclick=y>`…):要入栈等它的闭合。 */
|
||||
data class Open(override val name: String) : InlineTag
|
||||
|
||||
/** 成对的闭标签(`</b>`、`</EM>`…)。 */
|
||||
data class Close(override val name: String) : InlineTag
|
||||
}
|
||||
|
||||
/** 标签名里允许出现的字母:ASCII 大小写,消费侧与扣留侧共用这一把尺子。 */
|
||||
internal fun isTagNameLetter(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
|
||||
|
||||
/**
|
||||
* 把一段 `<…>` 文本判成白名单行内标签(不是则 null)。**一处判据,两个消费者**:解析侧用它认标签,
|
||||
* 流式扣留侧用它算「这个开标签要不要入栈扣留」——同源才不会分叉。
|
||||
*
|
||||
* 认下来的形状(`<` 与名字之间可以有空白,与既有 `</\s*(action|narration)\s*>` 同一口径):
|
||||
* - 名字 = ASCII 字母串,**大小写不敏感**,落成小写后必须在 [INLINE_TAG_NAMES] 里;
|
||||
* - 名字之后紧跟的必须是 `>`、空白或 `/`——`<boolean>` / `<br2>` / `<b-x>` 因此都不是标签
|
||||
* (否则 `b` 会把一大票以 b 开头的普通单词吃成粗体),这是「字符一个不丢」的第一道闸门;
|
||||
* - 再往后到 `>` 之间的一切(属性、`/`、空白)**整段丢掉**:契约第 2 条,只取标签名。
|
||||
* 丢掉的只是白名单标签自己那对尖括号里的东西,正文一个字符都不动。
|
||||
*
|
||||
* `<br>` 在这里返回 null(它不再是行内标签,是气泡分隔符)——所以它落到「白名单外」那条路:
|
||||
* 它只是几个普通字符。真实链路上**只有一条路**能把它送到这一层:`<code>` 作用域内部
|
||||
* (分隔符切分层的保护区不分隔它,见 [appendRegion] 与 `ChatContentParser.protectedSpans`)。
|
||||
*/
|
||||
internal fun parseInlineTag(tag: String): InlineTag? {
|
||||
val end = tag.length - 1
|
||||
if (end < 2 || tag[0] != '<' || tag[end] != '>') return null
|
||||
var i = 1
|
||||
val closing = tag[i] == '/'
|
||||
if (closing) i++
|
||||
while (i < end && tag[i].isWhitespace()) i++
|
||||
val nameStart = i
|
||||
while (i < end && isTagNameLetter(tag[i])) i++
|
||||
if (i == nameStart) return null
|
||||
val name = tag.substring(nameStart, i).lowercase()
|
||||
if (i < end && !tag[i].isWhitespace() && tag[i] != '/') return null
|
||||
if (name !in PAIRED_TAG_NAMES) return null
|
||||
return if (closing) InlineTag.Close(name) else InlineTag.Open(name)
|
||||
}
|
||||
|
||||
/** 开标签名(不是行内开标签则 null)。 */
|
||||
internal fun openInlineTagName(tag: String): String? =
|
||||
(parseInlineTag(tag) as? InlineTag.Open)?.name
|
||||
|
||||
/** 闭标签名(不是行内闭标签则 null)。 */
|
||||
internal fun closeInlineTagName(tag: String): String? =
|
||||
(parseInlineTag(tag) as? InlineTag.Close)?.name
|
||||
|
||||
/**
|
||||
* [name](已小写)是否可能是某个行内标签名的**前缀**——流式扣留侧判「残缺的 `<b` 还可能补全吗」
|
||||
* 用它。大小写不敏感与 [parseInlineTag] 一致,名单也是同一份。
|
||||
*/
|
||||
internal fun couldBeInlineTagNamePrefix(name: String): Boolean =
|
||||
INLINE_TAG_NAMES.any { it.startsWith(name) }
|
||||
|
||||
/** 标签名 → 样式;`strong` / `em` 落到与 `b` / `i` 同一个样式上。 */
|
||||
internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
|
||||
"i", "em" -> InlineStyle.Italic
|
||||
CODE -> InlineStyle.Code
|
||||
"u" -> InlineStyle.Underline
|
||||
else -> InlineStyle.Bold
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一段**已经定稿的正文文本**解析成行内片段(纯函数)。
|
||||
*
|
||||
* 契约(都由单测钉着):
|
||||
* 1. **返回空列表 = 这段文本按字面渲染**(正文一个字符都不动)。三种情形都落在这里:
|
||||
* 没有可渲染的行内标签、**结构不平衡**(契约第 4 条,整段降级)、以及整段只有标记没有字。
|
||||
* 这是绝大多数消息的路径(实测 100 条里只有 2 条带 `<b>`),也是 `plainBubble` 那条路。
|
||||
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:白名单标签已被消费,且两端空白已去(见下)。
|
||||
* **除此之外逐字等于原文**——白名单外的标签(`<script>`)、属性、`<code>` 里的内容全都原样在
|
||||
* 片段里。
|
||||
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>`)时回落成第 1 条,
|
||||
* 把标记原样显示——宁可让她的话里多几个尖括号,也不能给出一条空白气泡。
|
||||
*
|
||||
* ## 口径一:配成对且正确嵌套,否则**整段**降级为原文(契约第 4 条)
|
||||
* 判定只看白名单标签的结构(白名单外的尖括号一律是普通字符,不参与判定):
|
||||
* - **未闭合的开标签**(`<b>没有闭合`、`日落 <b>18:35`)、**落单的闭标签**(`没有闭合</b>`、
|
||||
* 只有 `</b>`)、**交错**(`<b>甲<i>乙</b>丙</i>`:`</b>` 关不掉栈顶的 `i`)——三者任一出现,
|
||||
* 这一整段**一个标签都不认**、原文逐字显示。
|
||||
* - 代价与收益都写清:`甲<b>粗</b>乙<b>没闭合` 里的 `粗` 也不再加粗(粗粒度),
|
||||
* 换来的是渲染文本与原文之间只有两种关系(相等 / 消费掉全部配对标签),跨端可以逐字对齐。
|
||||
* - 判据只有这一条,不发明「宽容关闭」(`</b>` 顺手把里面没关的 `<i>` 也关掉)、也不按行/按句
|
||||
* 局部生效:那种写法每端都能自圆其说,合起来就是跨端不一致。
|
||||
* - **跨分隔符的标签各自算**:`<b>甲<br>乙</b>` 里的 `<br>` 在更早一层就把气泡切开了,
|
||||
* 到这里是两段文本(`<b>甲` 与 `乙</b>`),各自不平衡 ⇒ 各自回落原文。层序与理由见
|
||||
* `ChatContentParser.appendRegion` 的 KDoc。
|
||||
*
|
||||
* ## 口径二:`<code>` 里不再解析(与契约第 3 条一致,不是例外)
|
||||
* `<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`(等宽 + 底色),`<code>1 < 2 && a<b</code>`
|
||||
* 原样显示。它与契约第 3 条(白名单外按纯文本显示)**不冲突、方向一致**,但**不是同一条规则**:
|
||||
* - 第 3 条管的是**白名单外的标签名**(`<script>`、`<div>`)——它们从来不是「标签」;
|
||||
* - `<code>` 是一条**作用域**规则:在白名单标签 `<code>` 内部,连白名单标签也按内容处理。
|
||||
* `code` 表达的是「这段是代码」,代码里的 `<b>` 就是四个字符——这是 W67 就定下的口径,
|
||||
* 契约没有就「`<code>` 内部」单独措辞,我们**保留原行为**(换 HTML 那种「元素照常嵌套解析」的
|
||||
* 语义,改动只是删掉 [claimTags] 里的跳过分支,等后端确权再动);
|
||||
* - 两者的结果都落在同一条硬口径上:**字符一个不丢**。`<code>` 也正好是「想原样显示尖括号
|
||||
* 怎么办」的现成答案。
|
||||
* 内部的东西**不参与结构判定**:`<code><b>甲</code>` 里的 `<b>` 不算未闭合(它只是内容),
|
||||
* 所以不会因为它把整段拖成字面——判定与显示用的都是同一套「里面不算标签」的口径。
|
||||
* 代价是代码里的 `<b>` 不生效(实测 0 命中,也不存在「代码里也要粗体」这个需求)。
|
||||
*
|
||||
* ## 为什么两端空白要去掉
|
||||
* 被标签让出来的空白(`<b> 甲 </b>` 里那两个字)留着的唯一效果是「气泡里看起来多了两个空格」,
|
||||
* 而正文两端的空白本来就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见
|
||||
* 字符都不会掉;它同时兜住契约第 3 条:整段只有空白(`<b> </b>`)时回落字面,绝不给出一条
|
||||
* 看起来空白的怪气泡。
|
||||
*
|
||||
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇「到 `>` 或下一个 `<` 为止」的窗,绝不
|
||||
* `indexOf('>')` 一路找配对);正文里没有 `<` 时连集合都不建,一次就返回。
|
||||
*/
|
||||
fun parseInlineMarkup(text: String): List<InlineRun> {
|
||||
if (text.indexOf('<') < 0) return emptyList()
|
||||
// 结构不平衡 ⇒ 整段降级为原文(消费侧返回空列表 = 调用方按字面渲染)。
|
||||
val claimed = claimTags(text) ?: return emptyList()
|
||||
if (!claimed.any) return emptyList()
|
||||
// 整段被吃空(`<b></b>` 这类只有标记没有字的形状)⇒ 回落字面路径,见契约第 3 条。
|
||||
return runsOf(text, claimed).trimEdges().takeIf { it.isNotEmpty() } ?: emptyList()
|
||||
}
|
||||
|
||||
/** 一趟扫下来的「哪些标签被认领」:开/闭标签的起始下标。 */
|
||||
private class ClaimedTags {
|
||||
val openStarts = HashSet<Int>()
|
||||
val closeStarts = HashSet<Int>()
|
||||
|
||||
val any: Boolean
|
||||
get() = openStarts.isNotEmpty() || closeStarts.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一趟:扫出配成对且正确嵌套的那些标签(起始下标落在 [ClaimedTags] 的两个集合里)。
|
||||
*
|
||||
* 返回 **null = 结构不平衡**(未闭合 / 落单闭标签 / 交错),调用方据此整段降级为原文。
|
||||
* 因为不平衡时整段都不认,所以有返回值时**每一个**白名单成对标签都是配好的(闭标签只关得掉栈顶
|
||||
* 那个同名开标签,严格嵌套)。
|
||||
*
|
||||
* 白名单外的尖括号**完全不参与判定**:它们是普通字符(契约第 1、3 条),既不入栈、也不会因为
|
||||
* 「没闭合」把整段拖成字面——`甲<b>粗</b><script>` 里粗体照常生效,`<script` 原样显示。
|
||||
*
|
||||
* `<code>` 开着的期间**整段跳过**:里面的标签一个都不认、也不算结构(见 [parseInlineMarkup] 的
|
||||
* 口径二)。因为跳过的是「认不认」,所以 `<code>` 自己那一对仍照常被认领。
|
||||
*/
|
||||
private fun claimTags(text: String): ClaimedTags? {
|
||||
val claimed = ClaimedTags()
|
||||
val open = ArrayList<Pair<String, Int>>()
|
||||
var i = 0
|
||||
while (true) {
|
||||
val lt = text.indexOf('<', i)
|
||||
if (lt < 0) break
|
||||
val gt = inlineTagEnd(text, lt)
|
||||
if (gt < 0) {
|
||||
// 这一处起头的东西不是行内标签:往后挪**一个字符**再找。不能跳到某个「下一个 `>`」——
|
||||
// 那会连同中间真正的标签一起跳过(`1 < 2 <b>甲</b>` 里的 `<b>` 就是这么丢的)。
|
||||
i = lt + 1
|
||||
continue
|
||||
}
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
val parsed = parseInlineTag(tag)
|
||||
if (open.lastOrNull()?.first == CODE) {
|
||||
// `<code>` 里的一切都是它的内容:`<b>` 是四个字符,也都不算结构(所以里面那个「未闭合的
|
||||
// `<b>`」不会把整段拖成字面)。只有闭合它的 `</code>` 例外。
|
||||
if (parsed == InlineTag.Close(CODE)) {
|
||||
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
|
||||
claimed.closeStarts.add(lt)
|
||||
}
|
||||
} else {
|
||||
when (parsed) {
|
||||
is InlineTag.Open -> open.add(parsed.name to lt)
|
||||
is InlineTag.Close ->
|
||||
// 落单的闭标签(栈空)、或交错(`</b>` 关不掉栈顶的 `i`)⇒ 整段降级。
|
||||
if (open.lastOrNull()?.first != parsed.name) return null
|
||||
else {
|
||||
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
|
||||
claimed.closeStarts.add(lt)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
i = gt + 1
|
||||
}
|
||||
// 还有开标签没配到闭合 ⇒ 整段降级。
|
||||
if (open.isNotEmpty()) return null
|
||||
return claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* [lt] 处这个 `<` 起头的**行内**标签在哪结束(不是行内标签则 -1)。
|
||||
*
|
||||
* 一路走到 `>` 或**下一个 `<`** 为止:契约要求「属性一律不解析」,标签多长因此不再有上界
|
||||
* (`<b class="…" onclick="…">` 可以很长),W67 那版「只看一扇定宽窗」的做法行不通了。
|
||||
* 仍然刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
|
||||
* (W63 在同一片区域刚踩过这个坑),而且它还会把中间真正的标签一起跳过。
|
||||
* 逐段推进仍是线性的:每次从 `lt + 1` 扫到「下一个 `<`」或串尾,这些区间互不重叠。
|
||||
*
|
||||
* `internal` 是因为**标签边界只该有一把尺子**:分隔符切分层扫 `<br>` 保护区
|
||||
* (`ChatContentParser.protectedSpans`)时也用它,两边若各写一套,「哪儿是一个标签」就会分叉。
|
||||
*/
|
||||
internal fun inlineTagEnd(text: String, lt: Int): Int {
|
||||
var i = lt + 1
|
||||
while (i < text.length) {
|
||||
when (text[i]) {
|
||||
'>' -> return i
|
||||
'<' -> return -1
|
||||
}
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字),其余**逐字**进片段
|
||||
* (白名单外的标签、`<code>` 里的内容、没有被认领的整段标记,都在这里原样吐回)。
|
||||
* 因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。
|
||||
*/
|
||||
private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
|
||||
val runs = ArrayList<InlineRun>()
|
||||
val piece = StringBuilder()
|
||||
val open = ArrayList<String>()
|
||||
var styles = emptySet<InlineStyle>()
|
||||
|
||||
fun flush() {
|
||||
if (piece.isEmpty()) return
|
||||
val text0 = piece.toString()
|
||||
piece.setLength(0)
|
||||
val last = runs.lastOrNull()
|
||||
if (last != null && last.styles == styles) {
|
||||
runs[runs.lastIndex] = last.copy(text = last.text + text0)
|
||||
} else {
|
||||
runs.add(InlineRun(text0, styles))
|
||||
}
|
||||
}
|
||||
|
||||
var i = 0
|
||||
while (true) {
|
||||
val lt = text.indexOf('<', i)
|
||||
if (lt < 0) {
|
||||
piece.append(text, i, text.length)
|
||||
break
|
||||
}
|
||||
if (lt > i) piece.append(text, i, lt)
|
||||
val gt = inlineTagEnd(text, lt)
|
||||
if (gt < 0) {
|
||||
// 这一处起头的东西不是行内标签(`1 < 2` 里那个 `<`、残缺的 `<b`、属性里带 `<` 的…):
|
||||
// 把这个字符原样收下、从下一个字符继续。**必须与第一趟用同一把尺子**——这里若改用
|
||||
// `indexOf('>', lt)` 找标签边界,第一趟认下的那对标签就会在这里被当成一个字面整段吞掉
|
||||
// (`1 < 2 <b>甲</b>`)。
|
||||
piece.append('<')
|
||||
i = lt + 1
|
||||
continue
|
||||
}
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
when {
|
||||
claimed.openStarts.contains(lt) -> {
|
||||
flush()
|
||||
open.add(openInlineTagName(tag) ?: closeInlineTagName(tag).orEmpty())
|
||||
styles = stylesOf(open)
|
||||
}
|
||||
|
||||
claimed.closeStarts.contains(lt) -> {
|
||||
flush()
|
||||
open.removeAt(open.lastIndex)
|
||||
styles = stylesOf(open)
|
||||
}
|
||||
|
||||
else -> piece.append(tag)
|
||||
}
|
||||
i = gt + 1
|
||||
}
|
||||
flush()
|
||||
return runs
|
||||
}
|
||||
|
||||
private fun stylesOf(open: List<String>): Set<InlineStyle> =
|
||||
if (open.isEmpty()) emptySet() else open.mapTo(LinkedHashSet(), ::inlineStyleOf)
|
||||
|
||||
/** 去掉渲染文本两端的空白,并同步裁掉因此变空的片段——只吃空白,一个可见字符都不会掉。 */
|
||||
private fun List<InlineRun>.trimEdges(): List<InlineRun> {
|
||||
var head = 0
|
||||
for (run in this) {
|
||||
val leading = run.text.takeWhile { it.isWhitespace() }.length
|
||||
head += leading
|
||||
if (leading < run.text.length) break
|
||||
}
|
||||
var tail = 0
|
||||
for (i in indices.reversed()) {
|
||||
val trailing = this[i].text.takeLastWhile { it.isWhitespace() }.length
|
||||
tail += trailing
|
||||
if (trailing < this[i].text.length) break
|
||||
}
|
||||
val total = sumOf { it.text.length }
|
||||
val from = minOf(head, total)
|
||||
val to = total - minOf(tail, total - from)
|
||||
return cut(from, to)
|
||||
}
|
||||
|
||||
/** 按字符下标从两端裁掉(只用于裁空白,所以裁点必然落在片段边界或空白中间)。 */
|
||||
private fun List<InlineRun>.cut(from: Int, to: Int): List<InlineRun> {
|
||||
if (from == 0 && to == sumOf { it.text.length }) return this
|
||||
val out = ArrayList<InlineRun>(size)
|
||||
var pos = 0
|
||||
for (run in this) {
|
||||
val start = pos
|
||||
pos += run.text.length
|
||||
val head = maxOf(start, from)
|
||||
val end = minOf(pos, to)
|
||||
if (head < end) out.add(run.copy(text = run.text.substring(head - start, end - start)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -12,9 +12,27 @@ package top.yeij.cyrene.domain.share
|
||||
*
|
||||
* 还有第三件要说的:[contentUnreadable]——对方给了东西、本端**连地址都没认下来**。它连丢件计数
|
||||
* 都留不下(本端一条都没接收),所以单独一格;否则「分享点下去什么都不发生」正是它造成的。
|
||||
*
|
||||
* 正文那一格也多带一件要看的东西:[textSource]——这一段是从 `EXTRA_TEXT` 来的,还是从
|
||||
* `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的。有些发送方根本不填 `EXTRA_TEXT`,本端为此把取正文
|
||||
* 改成一条回落链([ShareRules.extract]);而回落来的正文与真正文长得一模一样,用户只能靠这一格分辨。
|
||||
*/
|
||||
data class SharedContent(
|
||||
/**
|
||||
* 这次分享的正文(两侧空白已去掉;全空白算没有正文,见 [ShareRules.extract])。
|
||||
*
|
||||
* 它可能是从 `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的(有些发送方根本不填 `EXTRA_TEXT`),
|
||||
* 来源记在 [textSource] 里:回落来的那一段与真正文长得一模一样,用户只能靠那一格分辨。
|
||||
*/
|
||||
val text: String = "",
|
||||
/**
|
||||
* 正文是从哪个 extra 拿到的(默认 [ShareTextSource.Text] = 正文本来就是正文)。
|
||||
*
|
||||
* ⚠️ 它**不参与发送的内容**:这条消息发出去的正文只有 [text] 本身。它的唯一去处是**本端渲染层**
|
||||
* 的那一句如实小字(见 `shareTextOriginNotice`)——回落来的正文必须能让用户察觉,否则「预览里只有
|
||||
* 一句话」会被读成「对方发来的就是这句正文」。
|
||||
*/
|
||||
val textSource: ShareTextSource = ShareTextSource.Text,
|
||||
val images: List<String> = emptyList(),
|
||||
/**
|
||||
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
|
||||
@@ -31,13 +49,14 @@ data class SharedContent(
|
||||
/** 已接收但**拷贝失败**的文件条数。 */
|
||||
val failedDocuments: Int = 0,
|
||||
/**
|
||||
* 这次分享**带着内容迹象**(`EXTRA_STREAM` 有值、或 `ClipData` 有条目),却一条可用内容都没
|
||||
* 提取出来。
|
||||
* 这次分享**带着内容迹象**(三个文本 extra 里躺着值、或 `EXTRA_STREAM` 有值、或 `ClipData`
|
||||
* 有条目),却一条可用内容都没提取出来。
|
||||
*
|
||||
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
|
||||
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连地址都没认下来」(多为发送方把
|
||||
* `EXTRA_STREAM` 塞成了本端认不出的形态,或用的是本端不读的 scheme)。它是**唯一**一种既没有
|
||||
* 内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是「用户点了分享、界面毫无反应」。
|
||||
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连可用的内容都没认下来」(多为发送方把
|
||||
* `EXTRA_STREAM` 塞成了本端认不出的形态、用本端不读的 scheme,或把正文放在本端认不出的地方)。
|
||||
* 它是**唯一**一种既没有内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是
|
||||
* 「用户点了分享、界面毫无反应」。
|
||||
*/
|
||||
val contentUnreadable: Boolean = false,
|
||||
) {
|
||||
@@ -55,13 +74,43 @@ data class SharedContent(
|
||||
*
|
||||
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
|
||||
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
|
||||
* 整条分享里什么都没有(`EXTRA_TEXT` 没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
|
||||
* 时它是 `false`:那次分享确实无话可说,静默才是对的。
|
||||
* 整条分享里什么都没有(三个文本 extra 都没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
|
||||
* 时它是 `false`:那次分享确实无话可说,静默才是对的。而「有值、只是本端一段都读不出来」(含
|
||||
* 空白与类型不符)**不**属于「什么都没有」:它由调用方以 `textPresent` 如实报进来,正是
|
||||
* [contentUnreadable] 那一格要接的。
|
||||
*/
|
||||
val worthDelivering: Boolean
|
||||
get() = !isEmpty || droppedImages > 0 || droppedDocuments > 0 || contentUnreadable
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次分享的正文**是从哪个 extra 拿到的**。
|
||||
*
|
||||
* ## 为什么要把来源留下来
|
||||
* 真实世界里有些发送方不填 `EXTRA_TEXT`:内容在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。本端
|
||||
* 此前只读 `EXTRA_TEXT`,那条分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句解释
|
||||
* (正是本项目最忌讳的那种失败)。所以取正文改成一条**回落链**([ShareRules.extract])。
|
||||
*
|
||||
* 但「这段字其实只是对方给的标题」必须能让用户分辨:预览里只有一句话时,他会以为对方发来的就是这句
|
||||
* 正文。于是一并记下**来源**,由渲染层如实标一句(`shareTextOriginNotice`)——那句话只出现在界面上,
|
||||
* **绝不**进正文(来源标记不许拼进用户正文是项目红线,见 [ShareRules] 的「为什么正文一个字都不加」)。
|
||||
*
|
||||
* ## 为什么分成三档而不是一个布尔
|
||||
* 三档是**如实登记**对方把内容放在哪个字段里:排查时「这条是从 SUBJECT 还是 TITLE 来的」是要看得见
|
||||
* 的事实,而这两种形态在真实发送方里的出现频率差得很远(SUBJECT 常见、TITLE 罕见)。但两者对用户
|
||||
* 说的是同一句话——标题与主题在用户眼里本来就是同一件事。
|
||||
*/
|
||||
enum class ShareTextSource {
|
||||
/** `EXTRA_TEXT`:规范路径,也是唯一一条「正文本来就是正文」的路。 */
|
||||
Text,
|
||||
|
||||
/** 从 `EXTRA_SUBJECT` 回落的(对方没给 `EXTRA_TEXT`,或给的是一段空白)。 */
|
||||
Subject,
|
||||
|
||||
/** 从 `EXTRA_TITLE` 回落的。 */
|
||||
Title,
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份分享进来的**非图片**文件。
|
||||
*
|
||||
@@ -120,10 +169,19 @@ fun classifySharedStream(mime: String?): SharedStreamKind {
|
||||
* ⚠️ **字符串形态只认 `content://`**(判据见 [stringStreamUri]):那是本端**新开**的一条读入口,
|
||||
* 松不得。被拒的条目**逐条跳过**——一次分享里有几条坏的,不该把整次分享废掉。
|
||||
*
|
||||
* ## 正文的回落链(本模块补上的另一条兼容读取)
|
||||
* 真实世界里有发送方**不填** `EXTRA_TEXT`:内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。
|
||||
* 本端此前只读 `EXTRA_TEXT`,那样一次分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句
|
||||
* 解释。所以取正文改成 `EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE` 的**回落链**([extract]),
|
||||
* 来源记在 [SharedContent.textSource] 里,由渲染层如实标一句(见 [ShareTextSource])。
|
||||
*
|
||||
* ## 为什么正文一个字都不加
|
||||
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
|
||||
* 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端
|
||||
* 渲染层。把标记拼进正文 = 让用户正文冒充系统指令,是项目红线。分享进来的正文**原样**发出。
|
||||
*
|
||||
* 回落来的正文(从 `EXTRA_SUBJECT` / `EXTRA_TITLE` 取到的那一段)同样**原样**发出:来源提示是界面
|
||||
* 上的一句小字,**不是**正文的一部分——所以面板里那段预览与真正发出去的那条消息逐字相同。
|
||||
*/
|
||||
object ShareRules {
|
||||
|
||||
@@ -170,9 +228,49 @@ object ShareRules {
|
||||
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
|
||||
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
|
||||
|
||||
/**
|
||||
* 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。
|
||||
*
|
||||
* ## 两个事实合起来只剩两种答案
|
||||
* - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张
|
||||
* 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断
|
||||
* (后端口径 `02` F4:他们那边不需要做任何事)。
|
||||
* - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断
|
||||
* 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个**
|
||||
* 这样的意思。
|
||||
*
|
||||
* 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。
|
||||
*
|
||||
* ## 为什么提成一个纯函数
|
||||
* 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传
|
||||
* `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直
|
||||
* 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是
|
||||
* 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与
|
||||
* [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。
|
||||
*
|
||||
* @param fromShare 这条(这一批)是不是分享进来的
|
||||
* @param index 在这次发送切出的消息里的位置(0 = 第一条)
|
||||
*/
|
||||
fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0
|
||||
|
||||
/**
|
||||
* 从分享 Intent 的原始字段提取内容。
|
||||
*
|
||||
* ## 正文的回落链:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`
|
||||
* 取**第一个非空白**的那一段当正文(口径与原先只读 `text` 时**完全一致**:两侧去空白、全空白算
|
||||
* 没有);来源记在 [SharedContent.textSource] 里,好让渲染层如实说一句(见 [ShareTextSource])。
|
||||
*
|
||||
* 为什么要有这条链:真实世界里有些发送方**不填** `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`
|
||||
* (少数在 `EXTRA_TITLE`)里。此前只读 `EXTRA_TEXT` ⇒ 那样一次分享**整条静默**——用户点了分享,
|
||||
* 界面毫无反应,后端零请求,也没有一句解释。前后两种口径的对外差别只有一个:**`EXTRA_TEXT`
|
||||
* 非空时回落链一定不生效**(它是第一站),所以既有情形的结果一个字都没变。
|
||||
*
|
||||
* ## 三个文本 extra 都没换来正文时**不许闭嘴**
|
||||
* 只要三个里**任意一个躺着原始值**(哪怕是一段空白、哪怕类型读不出来),这一次就不是「用户什么都
|
||||
* 没分享」,而是「本端什么都没读出来」——[SharedContent.contentUnreadable] 如实告知,绝不静默。
|
||||
* 「里面躺着值」这件事只有调用方知道(纯函数看不到原始值),所以由 [textPresent] 报进来,与
|
||||
* [streamPresent] 同一个形态。
|
||||
*
|
||||
* ## 优先顺序与去重(一次分享里多种来源并存时)
|
||||
* 顺序 = `EXTRA_STREAM` 的规范形态([stream] 在前、[streams] 在后)→ 字符串兼容形态(保持原顺序)
|
||||
* → `ClipData`(只在前两者一条都没认下时兜底)。同一个地址串被两处各给一次时**只留先到的那一条**:
|
||||
@@ -184,7 +282,10 @@ object ShareRules {
|
||||
*
|
||||
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
|
||||
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
|
||||
* HTML 富文本),读成 `String` 会直接拿到 `null`
|
||||
* HTML 富文本),读成 `String` 会直接拿到 `null`。**回落链的第一站**
|
||||
* @param subject `EXTRA_SUBJECT`——第二站。发送方「对象 / 标题」那个字段,正是有些 App 拿来放
|
||||
* 正文的地方(`EXTRA_TEXT` 一个值都没有)
|
||||
* @param title `EXTRA_TITLE`——第三站(少见,但真实发送方里确实有)
|
||||
* @param stream `EXTRA_STREAM` 的单值形态([ACTION_SEND] 那个只放一条的字段)
|
||||
* @param streams `EXTRA_STREAM` 的列表形态([ACTION_SEND_MULTIPLE] 的字段)
|
||||
* @param streamStrings `EXTRA_STREAM` 的**字符串兼容形态**(`String` / `String[]` /
|
||||
@@ -195,6 +296,10 @@ object ShareRules {
|
||||
* @param streamPresent `EXTRA_STREAM` 里确实躺着**原始值**——由调用方读了原始值判出来(本函数
|
||||
* 看不到那个值),含本端**认不出形态**的那种。它只有一个用处:形态认不出时本端既不猜、也不该
|
||||
* 因此闭嘴,那照样是一次「带着内容迹象却什么都没读进来」。
|
||||
* @param textPresent 三个文本 extra([text] / [subject] / [title])里**至少一个躺着原始值**——
|
||||
* 同样由读了原始值的调用方判出来,含**读不出类型**与**只是一段空白**的那两种(`textPresent`
|
||||
* 与 [streamPresent] 一样是「原料里有」「认下来的内容里没有」,用后者判会把「有值但读不出来」
|
||||
* 误当成「什么都没分享」)。正文一旦读出来(哪怕是从标题回落的)它就不起作用了。
|
||||
* @param clipUris `ClipData` 各条目的 URI,纯文本条目是 `null`(本端**不读** `ClipData` 里的
|
||||
* 文本,所以「只有纯文本条目」等同于「读不进来」,用来判 [SharedContent.contentUnreadable])
|
||||
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
|
||||
@@ -204,17 +309,30 @@ object ShareRules {
|
||||
fun extract(
|
||||
action: String?,
|
||||
text: CharSequence? = null,
|
||||
subject: CharSequence? = null,
|
||||
title: CharSequence? = null,
|
||||
stream: String? = null,
|
||||
streams: List<String>? = null,
|
||||
streamStrings: List<String> = emptyList(),
|
||||
streamPresent: Boolean = false,
|
||||
textPresent: Boolean = false,
|
||||
clipUris: List<String?> = emptyList(),
|
||||
mimeOf: (String) -> String? = { null },
|
||||
): SharedContent {
|
||||
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
|
||||
if (!recognized) return SharedContent()
|
||||
|
||||
val body = text?.toString().orEmpty().trim()
|
||||
// 回落链(见 KDoc):三段一起按同一个口径归一(`toString` → 两侧 trim → 非空才算有),
|
||||
// 取先到的那个**非空白**值。`EXTRA_TEXT` 非空时它一定赢 ⇒ 既有情形逐字不变。
|
||||
val picked = listOf(
|
||||
ShareTextSource.Text to text,
|
||||
ShareTextSource.Subject to subject,
|
||||
ShareTextSource.Title to title,
|
||||
).firstNotNullOfOrNull { (source, value) ->
|
||||
value?.toString()?.trim()?.takeIf(String::isNotEmpty)?.let { source to it }
|
||||
}
|
||||
val body = picked?.second.orEmpty()
|
||||
val textSource = picked?.first ?: ShareTextSource.Text
|
||||
|
||||
// 两个字段一并收:单值 / 列表是历史形态(当年靠 getParcelableExtra 与
|
||||
// getParcelableArrayListExtra 两个读法分开取,才不得不按 action 二选一),
|
||||
@@ -239,9 +357,9 @@ object ShareRules {
|
||||
}
|
||||
}
|
||||
|
||||
// 「带着内容迹象」的判据取的是**原料**(EXTRA_STREAM 里躺着的原始值 / ClipData 的条目数),
|
||||
// 不是「认下来的地址」——「有值但一条都认不出」正是要说的那种情形,用后者判反倒会把它当成
|
||||
// 「什么都没分享」而闭嘴。
|
||||
// 「带着内容迹象」的判据取的是**原料**(三个文本 extra 里躺着的原始值 / EXTRA_STREAM 里躺着的
|
||||
// 原始值 / ClipData 的条目数),不是「认下来的内容」——「有值但一条都认不出」正是要说的那种
|
||||
// 情形,用后者判反倒会把它当成「什么都没分享」而闭嘴。
|
||||
val hadStreamValue = streamPresent ||
|
||||
!stream.isNullOrBlank() ||
|
||||
streams.orEmpty().any(String::isNotBlank) ||
|
||||
@@ -249,6 +367,7 @@ object ShareRules {
|
||||
|
||||
return SharedContent(
|
||||
text = body,
|
||||
textSource = textSource,
|
||||
images = images.take(MAX_IMAGES),
|
||||
// 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI;
|
||||
// ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。
|
||||
@@ -256,7 +375,7 @@ object ShareRules {
|
||||
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
|
||||
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
|
||||
contentUnreadable = unique.isEmpty() && body.isEmpty() &&
|
||||
(hadStreamValue || clipUris.isNotEmpty()),
|
||||
(hadStreamValue || textPresent || clipUris.isNotEmpty()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
@@ -63,6 +65,9 @@ import top.yeij.cyrene.core.prefs.accountFingerprint
|
||||
import top.yeij.cyrene.core.session.ChatSessionSlots
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheStore
|
||||
import top.yeij.cyrene.data.repository.ImageCacheStore
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.data.voice.VoiceRecorder
|
||||
import top.yeij.cyrene.data.voice.VoiceRecorderState
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||
@@ -75,7 +80,10 @@ import top.yeij.cyrene.domain.chat.DocumentTarget
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.ImageSource
|
||||
import top.yeij.cyrene.domain.chat.SharedTextPlan
|
||||
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
import top.yukonga.miuix.kmp.basic.Button
|
||||
@@ -92,6 +100,7 @@ import top.yukonga.miuix.kmp.icon.basic.Search
|
||||
import top.yukonga.miuix.kmp.icon.basic.Sidebar
|
||||
import top.yukonga.miuix.kmp.icon.extended.File
|
||||
import top.yukonga.miuix.kmp.icon.extended.Image
|
||||
import top.yukonga.miuix.kmp.icon.extended.Mic
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 判定「贴着底部」的容差(px)。 */
|
||||
@@ -109,6 +118,14 @@ private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L
|
||||
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
|
||||
private const val SEARCH_NOTICE_MILLIS = 8_000L
|
||||
|
||||
/**
|
||||
* 麦克风相关的告知(没授权 / 起不来)挂多久(毫秒)。
|
||||
*
|
||||
* 与同页其它几条「动作刚发生的如实告知」取同一个值:它描述的也是刚刚那一次按键,
|
||||
* 不是持续状态(权限是不是真的没有,用户再按一次麦克风就知道了)。
|
||||
*/
|
||||
private const val VOICE_FAILURE_NOTICE_MILLIS = 8_000L
|
||||
|
||||
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
|
||||
private val InputBottomSpacer = 22.dp
|
||||
|
||||
@@ -386,6 +403,136 @@ fun ChatScreen(
|
||||
}
|
||||
var imageTarget by remember { mutableStateOf<ImageSource?>(null) }
|
||||
|
||||
// —— 语音播放(接收侧)——
|
||||
// 取字节的缓存层:服务端相对路径(`voice.url`)接到**当前服务器地址**上再发请求,字节落在
|
||||
// `cacheDir/media/`(按存储名内容寻址 ⇒ 重复播放一个请求都不发)。`baseUrlProvider` 每次现读:
|
||||
// 换服务器必然重进本组合,而 `mediaApi` 那条客户端也按 baseUrl 重建(见 AppContainer)。
|
||||
val voiceStore = remember(container) {
|
||||
VoiceStore(
|
||||
download = container.mediaRepository::download,
|
||||
cacheDir = container.appContext.cacheDir,
|
||||
baseUrlProvider = { container.authManager.currentBaseUrl },
|
||||
)
|
||||
}
|
||||
// 一条播放通道归**这一个会话**:换会话 = 换实例 = 上一条立刻停并释放;离开聊天页(本组合被
|
||||
// 丢弃)时 `onDispose` 释放(MediaPlayer 不 release 会一直占着解码器,见 VoicePlayer 的 KDoc)。
|
||||
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
|
||||
val voicePlayback by voicePlayer.state.collectAsState()
|
||||
|
||||
// —— 语音输入(契约 v0.96 §3.15 ①「用户按住麦克风说话」)——
|
||||
// 录音机归**这一个会话**:换会话 = 换实例(与上面那条播放通道同一个生命周期纪律),
|
||||
// 离开聊天页时 `onDispose` 收掉麦克风——一段没发出去的录音不该在后台继续占着它。
|
||||
//
|
||||
// ⚠️ 录音中**转屏**会重建组合 ⇒ 这一段丢失。本端不为此加一个「录音仓」:录音是几秒钟的
|
||||
// 动作,转屏 / 分屏重建时丢掉一次比在 VM 里维护一条 AudioRecord 生命周期便宜得多。
|
||||
val recorder = remember(sessionId) { VoiceRecorder() }
|
||||
DisposableEffect(recorder) {
|
||||
onDispose { recorder.release() }
|
||||
}
|
||||
val recording by recorder.state.collectAsState()
|
||||
val recordingState = recording as? VoiceRecorderState.Recording
|
||||
// 麦克风起不来 / 没授权时的如实告知(本端的问题,不走 VM 的 `error` 通道:
|
||||
// 那条通道的语义是「服务端 / 发送」,而这里一次都没上过路)。
|
||||
var voiceFailure by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(voiceFailure) {
|
||||
if (voiceFailure != null) {
|
||||
delay(VOICE_FAILURE_NOTICE_MILLIS)
|
||||
voiceFailure = null
|
||||
}
|
||||
}
|
||||
|
||||
val requestAudioPermission = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
// 授权刚拿到手就直接开录(用户按麦克风的意思就是「现在说」,不必再按第二次)。
|
||||
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
|
||||
} else {
|
||||
voiceFailure = VOICE_PERMISSION_DENIED_MESSAGE
|
||||
}
|
||||
}
|
||||
|
||||
// 开始录音:有权限就直接起;没有就申请。起不来时**如实说一句**,绝不假装在录。
|
||||
fun startVoiceRecording() {
|
||||
// 已经在录就不重入(录音中麦克风那枚按钮本来就不在屏幕上,这里是第二道闸)。
|
||||
if (recorder.state.value is VoiceRecorderState.Recording) return
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
|
||||
} else {
|
||||
voiceFailure = null
|
||||
requestAudioPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
// 「这一次收尾已经在飞」:防双击「发送」,也防它和「录满 60 秒自动收尾」撞在同一帧上。
|
||||
// 第二次进入时录音机已经不在录了,`stop()` 会回 `null`——那会被误读成「没有录到声音」。
|
||||
var finishingVoice by remember { mutableStateOf(false) }
|
||||
// 结束录音并交给 VM:准入判据(空 / 太短 / 超限)全在 `VoiceClip.plan` 里,这里一个字节
|
||||
// 都不自己判——空录音也照旧交给它,好让用户拿到那句「没有录到声音」。
|
||||
fun finishVoiceRecording() {
|
||||
if (finishingVoice) return
|
||||
finishingVoice = true
|
||||
scope.launch {
|
||||
try {
|
||||
val pcm = recorder.stop()
|
||||
viewModel.sendVoice(pcm ?: ByteArray(0))
|
||||
} finally {
|
||||
finishingVoice = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 录满 60 秒:录音机只置位不自动发送(见 VoiceRecorder 的 KDoc),收尾由这一层做。
|
||||
val reachedLimit = recordingState?.reachedLimit == true
|
||||
LaunchedEffect(reachedLimit) {
|
||||
if (reachedLimit) finishVoiceRecording()
|
||||
}
|
||||
|
||||
// `voice_output` 到了就放(契约:这一帧只定向回本连接、断线不补投 ⇒ 不播就永远听不到)。
|
||||
// 键里带 `file`:音频先落成本地文件再进状态,落好那一刻才是真正能播的时刻。
|
||||
// `autoPlay` 只用一次:这一帧到达时用户可能在别的会话,切回来时不该突然响一段旧语音
|
||||
// (判据见 VoiceReply.autoPlay)。「已经在放 / 正在载入」时不重新触发——否则第二次状态
|
||||
// 到达会把正在响的那条停掉。
|
||||
val voiceReply = state.voiceReply
|
||||
LaunchedEffect(voiceReply?.id, voiceReply?.file) {
|
||||
val reply = voiceReply ?: return@LaunchedEffect
|
||||
val file = reply.file ?: return@LaunchedEffect
|
||||
if (!reply.autoPlay) return@LaunchedEffect
|
||||
viewModel.consumeVoiceReplyAutoPlay()
|
||||
if (!voicePlayer.state.value.isActiveFor(reply.id)) {
|
||||
voicePlayer.toggleClip(reply.id) { Result.success(file) }
|
||||
}
|
||||
}
|
||||
|
||||
// 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:<messageId>`,
|
||||
// 与语音副本天然互斥——点一个停另一个)。键里带 `file`:`null → 文件` 那一刻才是真正能播的
|
||||
// 时刻。`autoPlay` 只用一次,理由与上面那条完全相同(用户可能已经切走了)。
|
||||
//
|
||||
// ⚠️ `autoPlay` 必须进键:用户「放 → 停 → 再点同一条」时 `(clipId, file)` 一对**没变**
|
||||
// (同一份缓存文件),只按那一对做键就再也不会起播。置假那一次会把键改成 `false`、下一轮
|
||||
// 点击改回 `true`,于是每一点都重新点着一次;置假本身触发的重入在第一行就返回了,不成环。
|
||||
//
|
||||
// 起播之后这一格**不再变**:播放走到哪一步是播放通道的事,VM 不需要知道(见 `TtsClip` 的
|
||||
// 类 KDoc)。
|
||||
val ttsClip = state.ttsClip
|
||||
LaunchedEffect(ttsClip?.clipId, ttsClip?.file, ttsClip?.autoPlay) {
|
||||
val clip = ttsClip ?: return@LaunchedEffect
|
||||
val file = clip.file ?: return@LaunchedEffect
|
||||
if (!clip.autoPlay) return@LaunchedEffect
|
||||
viewModel.consumeTtsAutoPlay()
|
||||
if (!voicePlayer.state.value.isActiveFor(clip.clipId)) {
|
||||
voicePlayer.toggleClip(clip.clipId) { Result.success(file) }
|
||||
}
|
||||
}
|
||||
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
|
||||
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
|
||||
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
|
||||
DisposableEffect(viewModel) {
|
||||
onDispose { viewModel.dropPendingTtsAutoPlay() }
|
||||
}
|
||||
|
||||
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
|
||||
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
|
||||
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
|
||||
@@ -552,8 +699,29 @@ fun ChatScreen(
|
||||
images = state.draftImages,
|
||||
documents = state.draftDocuments,
|
||||
busyText = busyLabel(state),
|
||||
recordingMs = recordingState?.elapsedMs,
|
||||
voiceDraft = state.voiceDraft,
|
||||
voiceReply = state.voiceReply,
|
||||
voicePlayback = voicePlayback,
|
||||
onValueChange = viewModel::onDraftChange,
|
||||
onSend = viewModel::send,
|
||||
onStartRecording = { startVoiceRecording() },
|
||||
onStopRecording = { finishVoiceRecording() },
|
||||
onCancelRecording = { recorder.cancel() },
|
||||
onRetryVoice = viewModel::retryVoice,
|
||||
onDiscardVoice = viewModel::discardVoice,
|
||||
// 播放 / 停止与关闭都在这一个动作里收口:关掉卡片之后再响一秒是明确的 bug。
|
||||
onToggleVoiceReply = {
|
||||
val reply = state.voiceReply
|
||||
val file = reply?.file
|
||||
if (reply != null && file != null) {
|
||||
voicePlayer.toggleClip(reply.id) { Result.success(file) }
|
||||
}
|
||||
},
|
||||
onDismissVoiceReply = {
|
||||
voicePlayer.stop()
|
||||
viewModel.dismissVoiceReply()
|
||||
},
|
||||
onPickImage = {
|
||||
imagePicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
@@ -615,6 +783,14 @@ fun ChatScreen(
|
||||
// 自己的外观,①②⑤ 与今天逐字相同。判定是纯函数,接线层不自己猜。
|
||||
phase = state.phaseOf(item.message),
|
||||
legBadge = state.legBadgeForMessage(item.message),
|
||||
// 语音播放(接收侧):状态来自**这一个会话**的那条播放通道,
|
||||
// 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。
|
||||
voicePlayback = voicePlayback,
|
||||
// 朗读(W75):**只有这一条**的朗读状态进来(对不上的当没点过)。
|
||||
// 播放状态仍是同一条通道——朗读的键是 `tts:<messageId>`,所以两枚
|
||||
// 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。
|
||||
ttsClip = state.ttsClip?.takeIf { it.messageId == item.message.id },
|
||||
ttsPlayback = voicePlayback,
|
||||
onDocumentClick = { ref ->
|
||||
previewNotice = null
|
||||
previewTarget = DocumentTarget.of(ref)
|
||||
@@ -629,6 +805,21 @@ fun ChatScreen(
|
||||
// 各自用同一份产品口径的纯函数判定。
|
||||
onEdit = viewModel::startEdit,
|
||||
onDelete = viewModel::startDelete,
|
||||
onVoiceToggle = voicePlayer::toggle,
|
||||
// 朗读:请求、失败分类、落盘全在 VM;但**这一下是停还是念**必须在
|
||||
// 这里分(判据是纯函数 [ttsSpeakIntent])。直通 `viewModel::speak`
|
||||
// 会让「停止朗读」变成空操作(P1-1):播放中 `TtsClip.pending` 是假
|
||||
// ⇒ VM 的防抖放行、整格换成新 pending、缓存命中秒回,而下面那个起播
|
||||
// effect 又因为「已经在放同一条」跳过 `toggleClip`——三处各自都对,
|
||||
// 合起来点了没反应。唯一带停止语义的入口在播放通道上,所以在这一层分。
|
||||
onSpeak = { message ->
|
||||
val clip = state.ttsClip
|
||||
?.takeIf { it.messageId == message.id }
|
||||
when (ttsSpeakIntent(voicePlayer.state.value, message.id, clip)) {
|
||||
TtsSpeakIntent.Stop -> voicePlayer.stop()
|
||||
TtsSpeakIntent.Speak -> viewModel.speak(message)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
is ChatDisplayItem.Pending -> PendingBubble(
|
||||
@@ -732,6 +923,14 @@ fun ChatScreen(
|
||||
state.sendQueueNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
// 语音输入被本端挡下(没录到 / 太短 / 太大):与上面几条同档的如实告知。
|
||||
state.voiceNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
// 麦克风起不来 / 没授权:这是真错误(功能整个不可用),所以用红色。
|
||||
voiceFailure?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.error)
|
||||
}
|
||||
searchNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
@@ -885,14 +1084,43 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入栏:文本 / 图片 / 附件 / **语音**(契约 v0.96 §3.15 ①)。
|
||||
*
|
||||
* ## 三段语音形态各占一行(从上到下)
|
||||
* 1. [VoiceReplyRow]——她的语音回复(`voice_output` 的产物)。放在最上面,因为它说的是
|
||||
* 「刚刚那一轮的答复」,而下面几行是用户还要做的动作;
|
||||
* 2. [VoiceDraftRow]——一段录好但没发出去的语音(重发 / 丢弃);
|
||||
* 3. **录音中**:整行换成 [VoiceRecordingRow](录音中 / 取消 / 发送)。
|
||||
*
|
||||
* ## 录音中为什么把整行换掉
|
||||
* 录音是一条有去无回的动作:那一行里同时留着一个「发送」按钮(文本的)与一个「结束录音」按钮,
|
||||
* 手指落错就是把半句话当成文本发出去。换掉之后**文本发送那条路一个字节都没变**(不录音时
|
||||
* 逐字与之前相同),而录音中屏幕上有且只有与该动作有关的两个选择。
|
||||
*
|
||||
* ## 语音入口为什么放在「发送」左边
|
||||
* 与图片 / 附件同一侧(都是「加内容」),发送键仍在最右(肌肉记忆不变)。麦克风常驻——
|
||||
* 它是与打字并列的**另一种输入方式**,不是某个附加能力。
|
||||
*/
|
||||
@Composable
|
||||
private fun InputBar(
|
||||
value: String,
|
||||
images: List<String>,
|
||||
documents: List<DraftDocument>,
|
||||
busyText: String,
|
||||
recordingMs: Int?,
|
||||
voiceDraft: VoiceDraftInfo?,
|
||||
voiceReply: VoiceReply?,
|
||||
voicePlayback: VoicePlaybackState,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onStartRecording: () -> Unit,
|
||||
onStopRecording: () -> Unit,
|
||||
onCancelRecording: () -> Unit,
|
||||
onRetryVoice: () -> Unit,
|
||||
onDiscardVoice: () -> Unit,
|
||||
onToggleVoiceReply: () -> Unit,
|
||||
onDismissVoiceReply: () -> Unit,
|
||||
onPickImage: () -> Unit,
|
||||
onRemoveImage: (Int) -> Unit,
|
||||
onPickDocument: () -> Unit,
|
||||
@@ -905,6 +1133,25 @@ private fun InputBar(
|
||||
.imePadding()
|
||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
// 她的语音回复:最上面一行,因为它说的是「刚刚那一轮的答复」。
|
||||
voiceReply?.let { reply ->
|
||||
VoiceReplyRow(
|
||||
reply = reply,
|
||||
playback = voicePlayback,
|
||||
onTogglePlay = onToggleVoiceReply,
|
||||
onDismiss = onDismissVoiceReply,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
// 录好但没发出去的那一段:只在 `voice_input` 没能上行时出现。
|
||||
voiceDraft?.let { draft ->
|
||||
VoiceDraftRow(
|
||||
draft = draft,
|
||||
onRetry = onRetryVoice,
|
||||
onDiscard = onDiscardVoice,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
DraftImagePreviews(images = images, onRemove = onRemoveImage)
|
||||
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
|
||||
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
|
||||
@@ -913,46 +1160,64 @@ private fun InputBar(
|
||||
onRemove = onRemoveDocument,
|
||||
onClick = onPreviewDocument,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
|
||||
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
|
||||
if (images.size < ShareRules.MAX_IMAGES) {
|
||||
IconButton(onClick = onPickImage) {
|
||||
if (recordingMs != null) {
|
||||
VoiceRecordingRow(
|
||||
elapsedMs = recordingMs,
|
||||
onCancel = onCancelRecording,
|
||||
onSend = onStopRecording,
|
||||
)
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
|
||||
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
|
||||
if (images.size < ShareRules.MAX_IMAGES) {
|
||||
IconButton(onClick = onPickImage) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Image,
|
||||
contentDescription = "发送图片",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
|
||||
IconButton(onClick = onPickDocument) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Image,
|
||||
contentDescription = "发送图片",
|
||||
imageVector = MiuixIcons.File,
|
||||
contentDescription = "添加文件附件",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
|
||||
IconButton(onClick = onPickDocument) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.File,
|
||||
contentDescription = "添加文件附件",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = "和昔涟说点什么…",
|
||||
useLabelAsPlaceholder = true,
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = "和昔涟说点什么…",
|
||||
useLabelAsPlaceholder = true,
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = onSend,
|
||||
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
||||
// 纯附件回合服务端**支持**)。
|
||||
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
|
||||
) {
|
||||
Text("发送")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
// 麦克风(点按开始 / 点按结束,见 [VOICE_RECORD_DESCRIPTION] 的措辞)。
|
||||
// 换行后的入口与图片 / 附件同档;不读偏好、不占文本发送的任何一条判据。
|
||||
IconButton(onClick = onStartRecording) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = VOICE_RECORD_DESCRIPTION,
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = onSend,
|
||||
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
||||
// 纯附件回合服务端**支持**)。
|
||||
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
|
||||
) {
|
||||
Text("发送")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
|
||||
|
||||
@@ -2,9 +2,11 @@ package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -28,9 +30,12 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.image.ChatImagePreparer
|
||||
import top.yeij.cyrene.data.image.PreparedImage
|
||||
import top.yeij.cyrene.data.repository.ChatAck
|
||||
import top.yeij.cyrene.data.repository.DocumentCache
|
||||
import top.yeij.cyrene.data.repository.MessageEditDraft
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.PagedMessages
|
||||
import top.yeij.cyrene.data.repository.VoiceStore
|
||||
import top.yeij.cyrene.data.voice.SpeechClient
|
||||
import top.yeij.cyrene.data.repository.applyMessageEdit
|
||||
import top.yeij.cyrene.data.repository.historyForSession
|
||||
import top.yeij.cyrene.data.repository.mergeMessages
|
||||
@@ -55,6 +60,9 @@ import top.yeij.cyrene.domain.chat.SendChannel
|
||||
import top.yeij.cyrene.domain.chat.SharedTextPlan
|
||||
import top.yeij.cyrene.domain.chat.TurnBinding
|
||||
import top.yeij.cyrene.domain.chat.TurnBucket
|
||||
import top.yeij.cyrene.domain.chat.VoiceClip
|
||||
import top.yeij.cyrene.domain.chat.VoiceClipPlan
|
||||
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
|
||||
import top.yeij.cyrene.domain.chat.allClosed
|
||||
import top.yeij.cyrene.domain.chat.closingBucketOf
|
||||
import top.yeij.cyrene.domain.chat.closingTurn
|
||||
@@ -64,7 +72,14 @@ import top.yeij.cyrene.domain.chat.parallelForSend
|
||||
import top.yeij.cyrene.domain.chat.parallelWireValue
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import top.yeij.cyrene.domain.chat.toDocumentFailure
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
import top.yeij.cyrene.domain.chat.ttsFailureOf
|
||||
import top.yeij.cyrene.domain.chat.ttsTextOf
|
||||
import top.yeij.cyrene.domain.chat.turnBucketOf
|
||||
import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor
|
||||
import top.yeij.cyrene.domain.chat.voiceClipRejection
|
||||
import top.yeij.cyrene.domain.chat.voiceOutputHasAudio
|
||||
import top.yeij.cyrene.domain.chat.voiceReplyText
|
||||
import top.yeij.cyrene.domain.chat.withSteeredMessage
|
||||
import top.yeij.cyrene.domain.chat.withTurnBegin
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
@@ -109,6 +124,15 @@ data class PendingMessage(
|
||||
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
|
||||
*/
|
||||
val documents: List<DraftDocument> = emptyList(),
|
||||
/**
|
||||
* 这一条是**语音**时它的时长(毫秒);`null` = 不是语音(绝大多数消息)。
|
||||
*
|
||||
* 语音的 [text] 恒为空:本端不做本地转写,用户说的话要等服务端 STT 之后才会作为
|
||||
* user 消息的 `content` 落库。而气泡**必须立刻出现**(用户刚说完,屏幕上没有东西就是
|
||||
* 「没发出去」的样子)⇒ 这一条消息在正文之外还需要一个「这是语音、多长」的可见凭据。
|
||||
* 它只影响渲染(`PendingBubble` 画那一枚「语音 · 0:07」),不参与发送与指纹。
|
||||
*/
|
||||
val voiceDurationMs: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -395,9 +419,24 @@ data class ToolCallState(
|
||||
val tool: String = "",
|
||||
val arguments: String = "",
|
||||
val output: String = "",
|
||||
/** `tool_call.end` 已到(参数流完整)。 */
|
||||
/**
|
||||
* 执行结果摘要(`tool_execution.end` 的可选 `resultSummary`;服务端当前**未部署**该键)。
|
||||
*
|
||||
* `null` 与 `""` 都是**「不知道」**:`null` 是键缺席(老服务端 / 这一轮没拿到结果),
|
||||
* `""` 是万一被空串顶位。两者**都不等于**「没有输出」——那是我们替服务端下的结论,
|
||||
* 契约明确不要这么读。渲染判据见 [toolResultTextOf]。
|
||||
*/
|
||||
val resultSummary: String? = null,
|
||||
/**
|
||||
* `tool_call.end` 已到(参数流完整)——**或者**连接重建收口时这一张卡不再可能续上
|
||||
* ([settledAfterReconnect])。
|
||||
*
|
||||
* 两个来源合并成一位是刻意的:它在本端**只服务渲染**([ToolCard] 在 `phase == null` 时用
|
||||
* 它选标题),而两种成因要说的都是同一件事——**这一张卡没有省略号可挂了**(见
|
||||
* [settledAfterReconnect] 为什么必须去掉那个省略号)。
|
||||
*/
|
||||
val done: Boolean = false,
|
||||
/** 执行阶段;`null` = 尚未进入执行。 */
|
||||
/** 执行阶段;`null` = 尚未进入执行,或收口后不再有「进行中」可言(见 [settledAfterReconnect])。 */
|
||||
val phase: ToolPhase? = null,
|
||||
)
|
||||
|
||||
@@ -422,11 +461,35 @@ fun toolExecutionTargetIndex(tools: List<ToolCallState>, id: String?): Int {
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具卡的**结果区该显示什么**(纯函数):返回 `null` = **一个像素都不画**。
|
||||
*
|
||||
* 判据只有两条来源,优先级如下:
|
||||
* 1. [ToolCallState.resultSummary] 非空白 ⇒ 显示它。它是服务端脱敏 + 截断 ≤500 字符的**成品**,
|
||||
* 本端**只展示**:不再截断、不解析、不当 markdown。
|
||||
* 2. 否则回落到 [ToolCallState.output](`tool_execution.update` 累积的局部输出)——这是这张卡
|
||||
* 今天唯一的来源,**逐字节保持不变**。
|
||||
*
|
||||
* ⚠️ 两个方向都不能反过来读:
|
||||
* - 摘要**缺席或为空**时**绝不**合成「没有输出」/「无结果」这类文案。契约明说缺席只表示
|
||||
* 「不知道」(这一轮的结果没拿到,可能是失败 / 取消 / 超时 / 未落图),替服务端下结论就是撒谎。
|
||||
* - 空串与 `null` **同等对待**(都按不画):服务端承诺不会发空串,但只要它真发了,
|
||||
* 渲染出一个空的「结果」区块比什么都不画更糟。
|
||||
*/
|
||||
fun toolResultTextOf(tool: ToolCallState): String? {
|
||||
val summary = tool.resultSummary
|
||||
if (!summary.isNullOrBlank()) return summary
|
||||
return tool.output.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一帧 `tool_execution.*` 应用到工具卡列表(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字,
|
||||
* `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。
|
||||
*
|
||||
* [resultSummary] 与 `delta` 不同:它是**整段成品**,非空白时直接顶替(不是追加)。缺席(`null`)
|
||||
* 或空白时**保留卡上已有的值**——三帧里只有 `.end` 会带它,一次落不到不该把上一次的抹掉。
|
||||
*/
|
||||
fun applyToolExecution(
|
||||
tools: List<ToolCallState>,
|
||||
@@ -434,6 +497,7 @@ fun applyToolExecution(
|
||||
tool: String,
|
||||
phase: String,
|
||||
delta: String? = null,
|
||||
resultSummary: String? = null,
|
||||
): List<ToolCallState> {
|
||||
val target = toolExecutionTargetIndex(tools, id)
|
||||
if (target < 0) return tools
|
||||
@@ -445,11 +509,48 @@ fun applyToolExecution(
|
||||
tool = tool.ifBlank { state.tool },
|
||||
phase = toolPhaseOf(phase) ?: state.phase,
|
||||
output = if (delta.isNullOrEmpty()) state.output else state.output + delta,
|
||||
resultSummary = resultSummary?.takeIf { it.isNotBlank() } ?: state.resultSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接重建时一张工具卡的收口(纯函数):**还在跑**的那一张不再声称自己在跑。
|
||||
*
|
||||
* 现场(主人报的):后端卡在工具执行里被重启 ⇒ `tool_execution.end` 永远不会来 ⇒ 卡片永久停在
|
||||
* 「执行 X…」/「调用工具…」那一格,一个**进行中**的字面承诺就此永远挂在那里。
|
||||
*
|
||||
* ## 为什么落点是 `phase = null` + `done = true`,而不是别的
|
||||
* 1. **不许**给一个不知道结局的工具下结论:`phase = Error` 会说出「工具调用失败」——服务端从没
|
||||
* 这么说过(它可能只是本端没等到),这正是 `toolResultTextOf` 那条纪律(「摘要缺席只表示
|
||||
* *不知道*」)同一个方向的事,只是换了一张卡;
|
||||
* 2. **不许**给 [ToolPhase] 加第四个取值:它逐字对齐后端流程图的 tool 节点 status
|
||||
* (契约 v0.50 §3.1/§3.5),加一个本端专有的档就是在本端自造一套枚举,而枚举是**契约面**;
|
||||
* 3. 于是只剩「把它退回**还没进入执行**那一档」这一条路:`phase = null` + `done = true` 在
|
||||
* [ToolCard] 里渲染成「工具调用 · <工具名>」——**不再有省略号、也不下任何结论**。这比
|
||||
* 「执行 X…」多一点诚实:工具调用确实发生过(`tool_call.*` 帧都在),而它的结局本端不知道。
|
||||
*
|
||||
* `done = true` 是为了让 `phase` 已是 `null` 的那张卡(只到了 `tool_call.*`、没到
|
||||
* `tool_execution.*`)也从「调用工具…」的省略号里出来——**省略号就是「还在跑」**,
|
||||
* 收口之后它不再在跑,那个省略号必须去掉。
|
||||
*
|
||||
* 幂等:已经 `Done` / `Error` 的卡**一个字节都不动**(那是服务端给的结论,本端不许改写它,
|
||||
* 也不许把它降级成 `null`);重复调用返回同一个实例。
|
||||
*/
|
||||
fun List<ToolCallState>.settledAfterReconnect(): List<ToolCallState> =
|
||||
if (none { it.phase == ToolPhase.Running || !it.done }) {
|
||||
this
|
||||
} else {
|
||||
map {
|
||||
when {
|
||||
it.phase == ToolPhase.Running -> it.copy(phase = null, done = true)
|
||||
!it.done -> it.copy(done = true)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在流式的助手回复。 */
|
||||
data class StreamingState(
|
||||
val bubbles: List<ParsedBubble> = emptyList(),
|
||||
@@ -539,6 +640,76 @@ fun ChatUiState.applyMessageActionFailure(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点「朗读」这一步的状态迁移(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* 返回 `null` = **这一次点击该被忽略**:同一条消息的请求还在飞([TtsClip.pending])。这是防抖的
|
||||
* 唯一判据。已经落好(`file != null`)或已经失败过(`failure != null`)的**不算**在飞 ——
|
||||
* 前者是「再放一遍」,后者是用户手动重试,两种都该放行。
|
||||
*
|
||||
* 返回新状态 = 放行,并且这一格已经换成「请求在飞」。`autoPlay` 保持默认的真:字节到手那一刻
|
||||
* 要起播一次(见 [TtsClip.autoPlay]),置假只在真的起播之后(`ChatViewModel.consumeTtsAutoPlay`)。
|
||||
*/
|
||||
fun ChatUiState.beginTtsSpeak(messageId: String, clipId: String): ChatUiState? {
|
||||
val current = ttsClip
|
||||
if (current != null && current.clipId == clipId && current.pending) return null
|
||||
return copy(ttsClip = TtsClip(messageId, clipId, null, null, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求回来了,把结论落到这一格(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、
|
||||
* 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。
|
||||
*
|
||||
* `autoPlay` **从旧那一格继承**而不是重置成真:用户可能在请求落地之前就离开了这一屏
|
||||
* ([ChatUiState.dropPendingTtsAutoPlay] 把这一位置了假),结果晚到之后若又把它抬回真,
|
||||
* 切回来时就会突然响一段刚才点的朗读 —— 那正是要拦的那件事。
|
||||
*
|
||||
* 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的
|
||||
* 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。
|
||||
*/
|
||||
fun ChatUiState.applyTtsOutcome(
|
||||
messageId: String,
|
||||
clipId: String,
|
||||
result: Result<File>,
|
||||
): ChatUiState {
|
||||
val current = ttsClip
|
||||
if (current == null || current.clipId != clipId) return this
|
||||
val autoPlay = current.autoPlay
|
||||
return result.fold(
|
||||
onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false, autoPlay)) },
|
||||
onFailure = { error ->
|
||||
val failure = ttsFailureOf(error)
|
||||
copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable, autoPlay))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一屏要走了:把**还没到手**的那一次自动起播作废(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
* 起播闸门活在渲染层那个 `LaunchedEffect` 里(见 `ChatScreen`),而它只在**这一屏在的时候**
|
||||
* 才会被跑一次。用户点了朗读之后立刻切走(换会话 / 进详情页),合成还在飞;结果落地的瞬间这一屏
|
||||
* 不在,闸门没被消费过;等他切回来,那个 effect 一挂上就以「文件到了 + `autoPlay` 还是真」为由
|
||||
* 起播 —— 突然响一段**已经过去**的朗读,不是他此刻的动作。
|
||||
*
|
||||
* ## 为什么在「离开」这一刻判,而不是给 clip 记一个发起时刻再按超龄丢
|
||||
* 时间不是这件事的判据:合成一段长文本本来就可能花上十几秒,按秒数丢会把**人就在看着屏幕**
|
||||
* 的那一次也一起丢掉(点了却不出声)。「这一屏还在不在」才是真正的分界 —— 离开时把没消费的
|
||||
* 自动起播划掉,正好只拦掉「他不在」的那些。
|
||||
*
|
||||
* ## 只动「还没到手」的那一格
|
||||
* 文件已经在手(`file != null`)说明闸门要么已经跑过(`autoPlay` 已被 [consumeTtsAutoPlay]
|
||||
* 置假)、要么就是正在起播的这一刻:这两种情况一律原样返回,绝不打断一次真的播放。
|
||||
*/
|
||||
fun ChatUiState.dropPendingTtsAutoPlay(): ChatUiState {
|
||||
val clip = ttsClip ?: return this
|
||||
if (!clip.autoPlay || clip.file != null) return this
|
||||
return copy(ttsClip = clip.copy(autoPlay = false))
|
||||
}
|
||||
|
||||
sealed interface ChatDisplayItem {
|
||||
val key: String
|
||||
|
||||
@@ -732,6 +903,45 @@ data class ChatUiState(
|
||||
* `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
|
||||
*/
|
||||
val sessionGone: Boolean = false,
|
||||
/**
|
||||
* `voice_output` 到达后的那一份「她的语音回复」(契约 v0.96 §3.15);null = 没有要显示的那一条。
|
||||
*
|
||||
* 它**不是一条消息**,所以不在 [messages] / [pending] 里:这一帧只定向回发起 `voice_input` 的
|
||||
* 那条连接、断线不补投(契约明写),回合的正文本来也会经流式 / `message_persisted` 落进历史。
|
||||
* 这里存的是「这一次语音回合的音频 + 她说的话」这一次性产物,渲染在输入栏上方一张卡片上,
|
||||
* 用户可以随时关掉([ChatViewModel.dismissVoiceReply])。
|
||||
*
|
||||
* 音频先落成本地文件再进这一格([VoiceReply.file]):把 base64 塞进状态会让**每一次**
|
||||
* 状态 `copy` 都在几 MB 的字符串上做相等比较(流式期间每秒几十次)。
|
||||
*/
|
||||
val voiceReply: VoiceReply? = null,
|
||||
/**
|
||||
* 一段**录好、但没发出去**的语音(`voice_input` 没能上行);null = 没有等着重发的。
|
||||
*
|
||||
* 只有小字段(键 / 时长 / 字节数),audio 本体留在 VM 的私有字段里——理由同上。
|
||||
* 界面上它是输入栏上方一条带「重发 / 丢弃」的卡片:重发**复用同一个 `clientMsgId`**
|
||||
* (契约:同一个非空值服务端只承认第一条),所以重发不会让同一句话落成两条。
|
||||
*/
|
||||
val voiceDraft: VoiceDraftInfo? = null,
|
||||
/**
|
||||
* 语音输入被**本端**挡下时的如实告知(没录到 / 太短 / 太大,见 `domain.chat.voiceClipRejection`);
|
||||
* null = 没有要说的。
|
||||
*
|
||||
* 与 [error] 分开:这些都不是「服务端说不行」,而是本端的准入闸门,且用户要做的事很具体
|
||||
* (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。
|
||||
*/
|
||||
val voiceNotice: String? = null,
|
||||
/**
|
||||
* 「把这一条念出来」这一次的产物(W75,`POST /api/v1/audio/speech`);null = 用户还没点过朗读。
|
||||
*
|
||||
* **只有一格**:用户同一时刻点的是**一条**消息(见 [TtsClip])。它同时承载三件事 ——
|
||||
* 请求在飞([TtsClip.pending])、字节已经落到本机([TtsClip.file])、上一次为什么没成
|
||||
* ([TtsClip.failure])。音频本体是**文件路径**而不是字节,理由同 [voiceReply]:每次状态
|
||||
* `copy` 都在几 MB 的数组上做相等比较是不可接受的。
|
||||
*
|
||||
* 「正在响」不在这一格里 —— 那归唯一的播放通道(`ui.chat.VoicePlayer`),键是 [TtsClip.clipId]。
|
||||
*/
|
||||
val ttsClip: TtsClip? = null,
|
||||
) {
|
||||
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
|
||||
val items: List<ChatDisplayItem>
|
||||
@@ -841,13 +1051,26 @@ class ChatViewModel(
|
||||
it.draftDocuments.isNotEmpty() ||
|
||||
it.pending.isNotEmpty() ||
|
||||
it.queuedForConnection.isNotEmpty() ||
|
||||
it.shareConfirm != null
|
||||
it.shareConfirm != null ||
|
||||
// 一段录好但没发出去的语音:它只在本端内存里(没有任何服务端副本),
|
||||
// 交掉这一格 = 让用户重说一遍——与「还没发出去的字」同一条理由。
|
||||
it.voiceDraft != null
|
||||
}
|
||||
|
||||
private var socket: ChatSocket? = null
|
||||
private var repository: MessageRepository? = null
|
||||
private var oldestLoadedAt: Long? = null
|
||||
|
||||
/**
|
||||
* 一段**录好、但没发出去**的语音的字节(`voice_input` 没能上行)。
|
||||
*
|
||||
* 刻意是 VM 的字段而不是 [ChatUiState] 的一格:状态会被反复 `copy` 并逐字段比较(流式期间
|
||||
* 每秒几十次),几 MB 的 `ByteArray` 放在那里等于每次比较都扫一遍;而它的生命周期与 VM
|
||||
* 完全相同(转屏不丢、切走会话仍在**这一格**里,见 `ChatSessionSlots`)。
|
||||
* 状态里只留小字段 [ChatUiState.voiceDraft](键 / 时长 / 字节数)供渲染。
|
||||
*/
|
||||
private var voiceRetry: VoiceRetryClip? = null
|
||||
|
||||
/**
|
||||
* **每一路**一个流式正文解析器(键与 `ChatUiState.streams` 同源)。
|
||||
*
|
||||
@@ -893,6 +1116,19 @@ class ChatViewModel(
|
||||
*/
|
||||
private var gapCursor: Long? = null
|
||||
|
||||
/**
|
||||
* 本 VM 是否**曾经**观测到过 [WsState.Connected] —— 「这一次 Connected 是**第一次接上**还是
|
||||
* **接回来**」的唯一凭据(判据见 [isReconnect],它决定要不要 [settledAfterReconnect])。
|
||||
*
|
||||
* 为什么用「一位只置不翻的记忆」而不是「上一跳是不是 `Reconnecting`」:`ws.state` 是
|
||||
* **StateFlow**,中间那一跳会被**合流吞掉**(收集器只看到 `Connected → Connected`),
|
||||
* 而那一刻它仍然是一次**真实的重建**。一个只置不翻的位不受合流影响。
|
||||
*
|
||||
* 为什么在 VM 里而不是在 `ChatSocket` 上:问的是「**本 VM** 有没有见过连上」——连接是进程内
|
||||
* 共享的一条,别的会话见过不算(本 VM 可能是离线期间才建起来的,见 [isReconnect] 第 3 条)。
|
||||
*/
|
||||
private var everConnected = false
|
||||
|
||||
/**
|
||||
* 本会话的**首屏**([loadFirstPage] 那一次)走到哪儿了;判据见 [reconnectPull]。
|
||||
*
|
||||
@@ -1098,9 +1334,22 @@ class ChatViewModel(
|
||||
*
|
||||
* 补拉**必须先判「本端已知它没了」**([sessionKnownGone]):那个会话的 VM 在会话被删之后
|
||||
* 仍然活着,不判就会在每一次重连都发一次注定 `404 session_not_found` 的往返(S7)。
|
||||
*
|
||||
* ⚠️ 本方法还承担一件**与补拉无关**的事:**连接重建那一刻把「进行中」全部收口**
|
||||
* ([isReconnect] + [settledAfterReconnect])。理由与触发时机的取舍写在 `ReconnectReset.kt`
|
||||
* 顶上:后端重启之后那一轮在服务端**已经不存在了**,本端等不到任何收口帧,不自己收就会永久
|
||||
* 停在灰态 /「思考中…」/「正在思考」那一格,而且那条永远 `active` 的流式路会让「跟随底部」
|
||||
* 永久失效。它**只**改「进行中的外观」:不碰消息、不碰待发、不碰草稿、不重拉。
|
||||
*/
|
||||
private fun onConnectionChanged(connection: WsState) {
|
||||
val previous = _state.value.connection
|
||||
// 收口刻意放在下面那道「同值早退」**之前**:状态流会合流,`Connected → Reconnecting →
|
||||
// Connected` 中间那一跳**可能被吞掉**,那时收集器只看到 `Connected → Connected`,
|
||||
// 而它仍然是一次**真实的重建**。它幂等(无变化时 [settledAfterReconnect] 返回同一个实例),
|
||||
// 所以多跑一次没有代价。
|
||||
if (isReconnect(previous, connection, everConnected)) _state.update { it.settledAfterReconnect() }
|
||||
if (connection == WsState.Connected) everConnected = true
|
||||
|
||||
_state.update { it.copy(connection = connection) }
|
||||
if (connection == previous) return
|
||||
|
||||
@@ -1377,12 +1626,311 @@ class ChatViewModel(
|
||||
emitPlannedMessages(
|
||||
plan = imageSendPlan(text, images, documents),
|
||||
clearDraft = true,
|
||||
applySendPreference = true,
|
||||
fromShare = false,
|
||||
localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音输入(契约 v0.96 §3.15 的 ①「用户按住麦克风说话」):一段**已录好的 PCM** → `voice_input`。
|
||||
*
|
||||
* ## 准入判据只有一处([VoiceClip.plan],纯函数)
|
||||
* 空 / 太短 / 超限三档都在这里被挡下并**明确说一句**([ChatUiState.voiceNotice],到期自己收走):
|
||||
* 一段被静默吞掉的录音在用户眼里就是「按了没反应」。封 WAV 也在那里面(44 字节 RIFF 头),
|
||||
* 本函数只做「发」这一件事。
|
||||
*
|
||||
* ## 乐观气泡与文本那条同源
|
||||
* 上屏走的是**同一个** `pending` 列表([PendingMessage],只是 `text` 空、带上
|
||||
* [PendingMessage.voiceDurationMs]),失败回滚走的是**同一个** `rollbackPending`——没有另造
|
||||
* 一套「语音待发」的状态机。用户说完话气泡立刻出现,转写的正文之后由服务端的 user 消息
|
||||
* 落库回来(按 `clientMsgId` 收编,判据在 [consumePendingFor])。
|
||||
*
|
||||
* ## 为什么没有补发队列
|
||||
* 文本那条会进 [ChatUiState.queuedForConnection] 等连接;语音不进——audio 是几 MB 的内存,
|
||||
* 钉在队列里等一个可能永远不来的连接不划算,而且「这段还发不发」应当由用户决定。
|
||||
* 所以发不出去就把这一段**留着**([ChatUiState.voiceDraft]),由用户按「重发」
|
||||
* ([retryVoice],**复用同一个 `clientMsgId`**)或「丢弃」([discardVoice])。
|
||||
*
|
||||
* @param pcm `AudioRecord` 收到的 16-bit / 16 kHz / 单声道 PCM(未封头)。
|
||||
*/
|
||||
fun sendVoice(pcm: ByteArray) {
|
||||
viewModelScope.launch {
|
||||
// 封 WAV + base64 编码都是几 MB 的活(PCM 复制 + 4/3 膨胀),放默认调度器上做,
|
||||
// 别占主线程——用户按下「发送」之后这一帧就该没有可感的停顿。
|
||||
val plan = withContext(Dispatchers.Default) { VoiceClip.plan(pcm) }
|
||||
when (plan) {
|
||||
is VoiceClipPlan.Ready -> deliverVoice(plan.wav, plan.durationMs, retry = null)
|
||||
// Ready 之外的每一档都有一句人话(判定与文案都在 domain,能穷举单测)。
|
||||
else -> voiceClipRejection(plan)?.let(::showVoiceNotice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重发**刚才没发出去的那一段**(复用它的 `clientMsgId`,契约要求)。
|
||||
*
|
||||
* 没有待重发的就什么都不做(按钮本不该出现;这里是第二道闸)。字节取的是 VM 里留着的那一份
|
||||
* WAV(**不重新封装**),所以重发的是**同一个字节序列**——服务端按幂等键判重之后留下的那份
|
||||
* 音频与用户眼前这段永远一致。
|
||||
*/
|
||||
fun retryVoice() {
|
||||
val clip = voiceRetry ?: return
|
||||
viewModelScope.launch { deliverVoice(clip.wav, clip.durationMs, retry = clip) }
|
||||
}
|
||||
|
||||
/** 丢掉那段待重发的语音(用户点了「丢弃」)。 */
|
||||
fun discardVoice() {
|
||||
voiceRetry = null
|
||||
_state.update { it.copy(voiceDraft = null) }
|
||||
}
|
||||
|
||||
/** 关掉「她的语音回复」那张卡片(播放器由渲染层在同一个动作里停掉)。 */
|
||||
fun dismissVoiceReply() {
|
||||
val file = _state.value.voiceReply?.file
|
||||
_state.update { it.copy(voiceReply = null) }
|
||||
if (file != null) {
|
||||
viewModelScope.launch { withContext(Dispatchers.IO) { file.delete() } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一条语音回复的**自动播放**已经用掉了(渲染层在真的起了播放之后调)。
|
||||
*
|
||||
* 置位之后即使组合被重建(转屏 / 切走再回来)也不会再自动响一遍——见 [VoiceReply.autoPlay]。
|
||||
* 幂等:已经置假时 `copy` 出的是同一个值,不会多一次有意义的变更。
|
||||
*/
|
||||
fun consumeVoiceReplyAutoPlay() {
|
||||
_state.update { state ->
|
||||
val reply = state.voiceReply
|
||||
if (reply == null || !reply.autoPlay) state else state.copy(voiceReply = reply.copy(autoPlay = false))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点了某条消息上的「朗读」(W75,`POST /api/v1/audio/speech`)。
|
||||
*
|
||||
* ## 念什么
|
||||
* 只念**她**的消息(`assistant`):`user` 那一侧点朗读没有意义,而且服务端的发音人由
|
||||
* **会话的生效人格**决定 —— 念主人自己说的话会用她的声音,那是错的。判据就是角色这一条,
|
||||
* 其余(要念哪段字)全交给纯函数 [ttsTextOf]:它剥掉抬头与旁白,只留正文;一个字都不剩
|
||||
* (纯附件 / 纯旁白)时**直接不发请求** —— 服务端对空 `text` 回 `400`,本端没有理由先跑一趟。
|
||||
*
|
||||
* ## 防重入
|
||||
* 同一条消息的请求**在飞的时候再点一次**直接忽略:`_state.ttsClip` 已经是那条消息的
|
||||
* `pending` 态。用户点**另一条**则照常发起(旧那条被整格替换,它的音频文件留在缓存里,
|
||||
* 下次点它命中、不发请求 —— 见 `data.voice.SpeechClient`)。
|
||||
*
|
||||
* 播放那半边**不在这里**:这里只把「字节到手」写进 [ChatUiState.ttsClip],起播由渲染层那个
|
||||
* `LaunchedEffect` 交给唯一的播放通道(`ui.chat.VoicePlayer`)。分开是必须的 —— VM 不该碰
|
||||
* `MediaPlayer`,而播放通道要能在「换了一条语音副本」时独立停下,不必回头改这一格。
|
||||
*
|
||||
* ## 失败
|
||||
* 分类是纯函数 [ttsFailureOf]:`tts_persona_unmapped` / `tts_no_character` 这两档是**配置**缺失
|
||||
* (重试永远不会成功,文案要说清缺什么),其余一律柔和提示。**不重试**:这条路上没有任何
|
||||
* 自动重试 —— 用户再点一次就是那次重试,而配置缺失那一档连这句话都不该说。
|
||||
*
|
||||
* 失败**不弹窗、不走 [ChatUiState.error] 横幅**:那是「这一次请求失败了」的全局通道,而朗读
|
||||
* 是一次附带的小动作,把它抬成横幅会盖住真正的发送失败。文案挂在**这条消息**下面(见
|
||||
* `MessageBubble`)。
|
||||
*/
|
||||
fun speak(message: HistoryMessageDto) {
|
||||
if (message.role == "user") return
|
||||
val text = ttsTextOf(message) ?: return
|
||||
val clipId = ttsClipIdOf(message.id)
|
||||
// 判据与落位都在纯函数里([beginTtsSpeak] / [applyTtsOutcome]):同一条正在飞 ⇒ 这里
|
||||
// 拿回 null,直接返回。用 compareAndSet 而不是 update,是因为「放行与否」和「写进哪一
|
||||
// 格」必须是同一次读写的结论——分开写会让并发的两次点击都以为自己放行了。
|
||||
while (true) {
|
||||
val current = _state.value
|
||||
val begun = current.beginTtsSpeak(message.id, clipId) ?: return
|
||||
if (_state.compareAndSet(current, begun)) break
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = speechClient.speak(text, sessionId)
|
||||
_state.update { it.applyTtsOutcome(message.id, clipId, result) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一次朗读的**自动起播**已经用掉了(渲染层在真的起了播放之后调)。
|
||||
*
|
||||
* 与 [consumeVoiceReplyAutoPlay] 同一条纪律、同一个理由:请求发出去之后用户可能已经切到别的
|
||||
* 会话 / 别的页面,切回来时不该突然响一段刚才点的朗读(见 [TtsClip.autoPlay])。
|
||||
*/
|
||||
fun consumeTtsAutoPlay() {
|
||||
_state.update { state ->
|
||||
val clip = state.ttsClip
|
||||
if (clip == null || !clip.autoPlay) state else state.copy(ttsClip = clip.copy(autoPlay = false))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一屏走了:把**还没到手**的那一次自动起播作废(判据与取舍全在纯函数
|
||||
* [dropPendingTtsAutoPlay] 里)。
|
||||
*
|
||||
* 由渲染层的 `DisposableEffect.onDispose` 调:起播闸门只在这一屏活着的时候跑得到,人走了
|
||||
* 而结果后到,切回来时就不该突然响一段旧朗读。
|
||||
*/
|
||||
fun dropPendingTtsAutoPlay() {
|
||||
_state.update { it.dropPendingTtsAutoPlay() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造
|
||||
* (与 `AppContainer` 那些按需 getter 同一手法)。
|
||||
*
|
||||
* `baseUrlProvider` / `tokenProvider` 都传**函数**而不是值:换服务器 / 令牌续期之后,
|
||||
* 下一次朗读自动用新的(与 `mediaApi` 同源同纪律)。凭据只走 owner Bearer ——
|
||||
* 主人 2026-10-26 拍板不设 API Key 输入框(见 `data.voice.SpeechClient` 的类 KDoc)。
|
||||
*/
|
||||
private val speechClient: SpeechClient by lazy {
|
||||
SpeechClient(
|
||||
baseUrlProvider = { container.authManager.currentBaseUrl },
|
||||
tokenProvider = container.authManager::currentToken,
|
||||
unauthorizedPolicy = container.authManager.unauthorizedPolicy,
|
||||
cacheDir = container.appContext.cacheDir,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。
|
||||
*
|
||||
* 键由纯函数 [voiceClientMsgIdFor] 决定:**重发复用 [retry] 那个键**,新录音现铸一个。
|
||||
* 判定「发出去没有」用的是 `sendVoiceInput` 的返回值 + **发出那一刻**的连接状态
|
||||
* (与文本那条 [sendThroughLadder] 同一手法):连接就绪却仍写不出去,与「还没连上」
|
||||
* 是两句不同的话,用户要做的事也不同。
|
||||
*/
|
||||
private suspend fun deliverVoice(wav: ByteArray, durationMs: Int, retry: VoiceRetryClip?) {
|
||||
val draft = retry?.let { VoiceDraftInfo(it.clientMsgId, it.durationMs, it.totalBytes) }
|
||||
val clientMsgId = voiceClientMsgIdFor(draft) { UUID.randomUUID().toString() }
|
||||
val audio = withContext(Dispatchers.Default) { Base64.encodeToString(wav, Base64.NO_WRAP) }
|
||||
val totalBytes = wav.size
|
||||
|
||||
// 乐观气泡:与文本消息**同一个** pending 列表(正文空、带语音时长)。
|
||||
_state.update {
|
||||
it.copy(
|
||||
pending = it.pending + PendingMessage(
|
||||
localId = clientMsgId,
|
||||
text = "",
|
||||
voiceDurationMs = durationMs,
|
||||
),
|
||||
error = null,
|
||||
voiceDraft = null,
|
||||
voiceNotice = null,
|
||||
)
|
||||
}
|
||||
|
||||
val ws = socket
|
||||
val sent = ws?.sendVoiceInput(sessionId, audio, VoiceClip.FORMAT, DEVICE_ID, clientMsgId) ?: false
|
||||
if (sent) {
|
||||
voiceRetry = null
|
||||
return
|
||||
}
|
||||
|
||||
val connection = ws?.state?.value ?: WsState.Idle
|
||||
val reason = if (connection == WsState.Connected) {
|
||||
VOICE_SEND_FAILED_MESSAGE
|
||||
} else {
|
||||
VOICE_NOT_CONNECTED_MESSAGE
|
||||
}
|
||||
// 回滚走的是**同一个** rollbackPending(摘掉刚才那枚气泡、把原因挂进 error 通道),
|
||||
// 但不走 [rollback]:语音没有可以退回输入栏的正文 / 图 / 附件,那段字节留在
|
||||
// [voiceRetry] 里等用户决定(重发或丢弃)。
|
||||
voiceRetry = VoiceRetryClip(clientMsgId, wav, durationMs, totalBytes)
|
||||
_state.update {
|
||||
it.rollbackPending(clientMsgId, reason).copy(
|
||||
voiceDraft = VoiceDraftInfo(clientMsgId, durationMs, totalBytes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `voice_output` 落地(契约 v0.96 §3.15):**她的语音回复**(音频 + 她说的话)。
|
||||
*
|
||||
* ## 判据全在 domain(纯函数)
|
||||
* [isSilentVoiceProvider] / [voiceOutputHasAudio] 决定「这一轮有没有真音频」,
|
||||
* [voiceReplyText] 决定显示哪一句(`reply` = 她说的,`text` = 转写)。`silent` 档
|
||||
* **不画播放控件**,只显示文字 + 一句说明(见 `VoiceReplyRow`)。
|
||||
*
|
||||
* ## 音频先落成本地文件
|
||||
* base64 解码 + 写缓存都在 IO 上做(几 MB 的活不能占主线程),解码 / 落盘失败就退化成
|
||||
* 「只有文字 + 那句『本机没能准备好』」——**绝不**把本机的失败说成「她只回了文字」
|
||||
* (那是替服务端下一个它没下的结论)。
|
||||
*
|
||||
* ## 只定向回原连接、断线不补投
|
||||
* 本方法**不做任何补救**(不拉历史、不重发 `voice_input`):契约明写这一帧不补投,
|
||||
* 回合的正文与转写本来就会经流式 / `message_persisted` 落到消息列表里。
|
||||
*/
|
||||
private fun onVoiceOutput(event: WsEvent.VoiceOutput) {
|
||||
val provider = event.provider
|
||||
val hasAudio = voiceOutputHasAudio(event.audio, provider)
|
||||
val id = "voice-reply-${UUID.randomUUID()}"
|
||||
val previous = _state.value.voiceReply?.file
|
||||
_state.update {
|
||||
it.copy(
|
||||
voiceReply = VoiceReply(
|
||||
id = id,
|
||||
reply = voiceReplyText(event.reply, event.text),
|
||||
transcript = event.text?.takeIf { text -> text.isNotBlank() },
|
||||
format = event.format,
|
||||
provider = provider,
|
||||
hasAudio = hasAudio,
|
||||
file = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
// 这一轮没有音频 ⇒ 到这里就结束了(正文已经在上面那一格里)。
|
||||
val audio = event.audio?.takeIf { hasAudio } ?: return
|
||||
viewModelScope.launch {
|
||||
val file = withContext(Dispatchers.IO) {
|
||||
previous?.delete()
|
||||
storeVoiceReply(audio, event.format)
|
||||
}
|
||||
_state.update { state ->
|
||||
// 只有「这一格还是我刚放进去的那一条」才补文件:迟到的解码不许覆盖更新的那一条。
|
||||
if (state.voiceReply?.id == id) {
|
||||
state.copy(voiceReply = state.voiceReply.copy(file = file))
|
||||
} else {
|
||||
file?.delete()
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `voice_output` 的 base64 音频落成 `cacheDir/media/` 下一个文件(**IO 线程调用**)。
|
||||
*
|
||||
* 目录与写入纪律直接复用 [VoiceStore] / [DocumentCache]:同一个 `media/` 根(与接收侧的
|
||||
* 语音副本同一层,与 `voice.url` 的 `/api/v1/media/` 同源)、同样的「先写 `.part` 再改名」。
|
||||
* 名字是现铸的随机名(`reply-<uuid>.<ext>`)而不是内容寻址:这一份是**一次性**的实时产物,
|
||||
* 没有第二个人会来按名字取它。
|
||||
*
|
||||
* @return 可直接交给 `MediaPlayer` 的文件;任何一步失败回 `null`(调用方据此退化成文字)。
|
||||
*/
|
||||
private fun storeVoiceReply(base64: String, format: String?): File? = runCatching {
|
||||
val bytes = Base64.decode(base64, Base64.NO_WRAP)
|
||||
if (bytes.isEmpty()) return null
|
||||
// 扩展名只影响可读性:`MediaPlayer` 按文件头嗅探,不认扩展名。不认识的格式一律 `.bin`。
|
||||
val extension = format?.trim()?.lowercase()?.takeIf { it in VOICE_REPLY_FORMATS } ?: "bin"
|
||||
val file = File(
|
||||
VoiceStore.directory(container.appContext.cacheDir),
|
||||
"reply-${UUID.randomUUID()}.$extension",
|
||||
)
|
||||
DocumentCache.write(file, bytes)
|
||||
file
|
||||
}.getOrNull()
|
||||
|
||||
/** 记一条语音输入的如实告知,到期自动收走(与 [showAttachmentNotice] 同一形态)。 */
|
||||
private fun showVoiceNotice(text: String) {
|
||||
_state.update { it.copy(voiceNotice = text) }
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(VOICE_NOTICE_MILLIS)
|
||||
_state.update { if (it.voiceNotice == text) it.copy(voiceNotice = null) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
|
||||
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
|
||||
@@ -1406,7 +1954,7 @@ class ChatViewModel(
|
||||
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
|
||||
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
|
||||
*
|
||||
* ## 分享不声明(`applySendPreference` 恒为 false)
|
||||
* ## 分享不声明(`fromShare = true` ⇒ [ShareRules.declaresSendPreference] 恒为 false)
|
||||
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递,
|
||||
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
|
||||
* 两种声明在这里**都**是错的:
|
||||
@@ -1467,7 +2015,7 @@ class ChatViewModel(
|
||||
emitPlannedMessages(
|
||||
plan = imageSendPlan(text, share.images, files),
|
||||
clearDraft = false,
|
||||
applySendPreference = false,
|
||||
fromShare = true,
|
||||
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
|
||||
)
|
||||
}
|
||||
@@ -1491,12 +2039,19 @@ class ChatViewModel(
|
||||
*
|
||||
* 新认领的那一份描述从空开始([ShareConfirmDraft.caption] 的初值):它是用户要附加的话、**不是**
|
||||
* 这次分享进来的正文——预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
|
||||
*
|
||||
* ## 认领那一刻还要如实说一句「正文是从标题回落来的」
|
||||
* 回落来的正文与真正文长得一模一样(见 [shareTextOriginNotice]):面板里那段字看着就是对方发来的
|
||||
* 正文本体,而它其实只是对方给的标题。这件事必须在用户点「发送」**之前**说——所以它挂在**认领**
|
||||
* 这一步上(面板正要长出来的那一刻),而不是挂在发送路径的告知里。通道是 [showShareNotice]
|
||||
* 那条柔和告知(不是错误横幅):没有出错,只是这一次拿到的东西与用户以为的不一样。
|
||||
*/
|
||||
fun claimSharedContent() {
|
||||
val holding = _state.value.shareConfirm
|
||||
val claimed = shareConfirmClaim(holding) { ShareIntake.take() }
|
||||
if (claimed != null && claimed !== holding) {
|
||||
_state.update { it.copy(shareConfirm = claimed) }
|
||||
shareTextOriginNotice(claimed.share)?.let(::showShareNotice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1624,16 +2179,20 @@ class ChatViewModel(
|
||||
* 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则
|
||||
* 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。
|
||||
*
|
||||
* 判据**只有一处**:[ShareRules.declaresSendPreference](纯函数,穷举整批的测试在
|
||||
* `ShareSendDeclarationTest`)。这里只把两条入口的事实([fromShare])与位置([index])喂给它,
|
||||
* 不在本函数里再写一遍 `&& index == 0`——那种写法正是 W76 之前两处各半个判据的由来。
|
||||
*
|
||||
* @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送,
|
||||
* 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。
|
||||
* @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的
|
||||
* 那条传 false(分享不是用户按的发送键,从不声明)。
|
||||
* @param fromShare 这一批是不是分享进来的:分享落地那条传 true([sendShared]),输入栏那条传
|
||||
* false([send])。它是 [ShareRules.declaresSendPreference] 的入参之一——分享从不声明。
|
||||
* @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。
|
||||
*/
|
||||
private suspend fun emitPlannedMessages(
|
||||
plan: ImageSendPlan,
|
||||
clearDraft: Boolean,
|
||||
applySendPreference: Boolean,
|
||||
fromShare: Boolean,
|
||||
localIdPrefix: String,
|
||||
) {
|
||||
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages)
|
||||
@@ -1644,7 +2203,7 @@ class ChatViewModel(
|
||||
imageUri = message.imageUri,
|
||||
documents = message.documents,
|
||||
clearDraft = clearDraft && index == 0,
|
||||
applySendPreference = applySendPreference && index == 0,
|
||||
applySendPreference = ShareRules.declaresSendPreference(fromShare, index),
|
||||
localIdPrefix = localIdPrefix,
|
||||
)
|
||||
}
|
||||
@@ -1656,9 +2215,9 @@ class ChatViewModel(
|
||||
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
||||
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
||||
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
|
||||
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false:
|
||||
* [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些
|
||||
* ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。
|
||||
* `interrupt` 与 `parallel`)。它是**已经算好的结果**,由 [emitPlannedMessages] 按
|
||||
* [ShareRules.declaresSendPreference] 逐条判出来(唯一调用方就是它)——本函数不再自己推导,
|
||||
* 也没有默认值:判据只能有一个出处,给个 `= true` 的默认值等于把口径又抄了一遍。
|
||||
* @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认),
|
||||
* [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在
|
||||
* 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动**
|
||||
@@ -1671,7 +2230,7 @@ class ChatViewModel(
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
clearDraft: Boolean,
|
||||
applySendPreference: Boolean = true,
|
||||
applySendPreference: Boolean,
|
||||
localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX,
|
||||
) {
|
||||
// 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。
|
||||
@@ -1751,10 +2310,10 @@ class ChatViewModel(
|
||||
// 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。
|
||||
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
|
||||
//
|
||||
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。
|
||||
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍
|
||||
// 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true
|
||||
// (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。
|
||||
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,且是这一批的第一条)。
|
||||
// 这个值由 [emitPlannedMessages] 按 [ShareRules.declaresSendPreference] 逐条算好后传进来——
|
||||
// 分享那条入口([ChatViewModel.sendShared])因此一次都不声明:它是**批量**投递,两种声明在那儿
|
||||
// 都是错的(理由与取舍见 [sendShared] 的「分享不声明」一段)。
|
||||
val mode = currentInterruptMode()
|
||||
val busy = _state.value.busyMode != null
|
||||
if (applySendPreference) interruptRunningTurn(mode, busy)
|
||||
@@ -2175,7 +2734,7 @@ class ChatViewModel(
|
||||
* 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发
|
||||
* `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支
|
||||
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时**
|
||||
* 才调(分享那条批量路径传 false,见 [sendShared])。
|
||||
* 才调(那个值出自 [ShareRules.declaresSendPreference],分享那条批量路径恒为 false,见 [sendShared])。
|
||||
*
|
||||
* [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**:
|
||||
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
|
||||
@@ -2729,7 +3288,14 @@ class ChatViewModel(
|
||||
is WsEvent.ToolExecutionUpdate ->
|
||||
onToolExecution(event.turnId, event.id, event.tool, event.phase, event.delta)
|
||||
|
||||
is WsEvent.ToolExecutionEnd -> onToolExecution(event.turnId, event.id, event.tool, event.phase)
|
||||
is WsEvent.ToolExecutionEnd ->
|
||||
onToolExecution(
|
||||
event.turnId,
|
||||
event.id,
|
||||
event.tool,
|
||||
event.phase,
|
||||
resultSummary = event.resultSummary,
|
||||
)
|
||||
|
||||
is WsEvent.StreamEnd -> {
|
||||
val bucket = bucketToClose(event.turnId)
|
||||
@@ -2786,6 +3352,12 @@ class ChatViewModel(
|
||||
|
||||
is WsEvent.MessageDeleted -> applyMessageDeleted(event.id)
|
||||
|
||||
// 语音应答(契约 v0.96 §3.15):会话归属由解析层从 `data` 提到帧上(帧壳没有
|
||||
// sessionId),所以上面那道 `isForSession` 闸对它**真的生效**——别的会话的语音
|
||||
// 不会在本会话响起来。未知归属(坏帧)按同一道闸的既有口径放行(「无法判定」≠
|
||||
// 「不属于本会话」)。判据与取舍见 [onVoiceOutput]。
|
||||
is WsEvent.VoiceOutput -> onVoiceOutput(event)
|
||||
|
||||
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
|
||||
_state.update { it.copy(busyMode = event.mode).withActiveTurns(event.activeTurns) }
|
||||
}
|
||||
@@ -2996,7 +3568,7 @@ class ChatViewModel(
|
||||
|
||||
/**
|
||||
* 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见
|
||||
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出。落不到就整帧忽略。
|
||||
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出 / 结果摘要。落不到就整帧忽略。
|
||||
*
|
||||
* 落哪张卡由**两件事**决定:先按 `turnId` 选**这一路**(两路并行时各自的工具批次互不可见,
|
||||
* 不变量 I3′),再在那一路上按 `id` 关联。帧壳没给 `turnId` 时落回本会话当前那一路
|
||||
@@ -3008,10 +3580,13 @@ class ChatViewModel(
|
||||
tool: String,
|
||||
phase: String,
|
||||
delta: String? = null,
|
||||
resultSummary: String? = null,
|
||||
) {
|
||||
val bucket = openBucket(frameTurnId)
|
||||
updateStreaming(bucket) { streaming ->
|
||||
streaming.copy(tools = applyToolExecution(streaming.tools, id, tool, phase, delta))
|
||||
streaming.copy(
|
||||
tools = applyToolExecution(streaming.tools, id, tool, phase, delta, resultSummary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3262,6 +3837,46 @@ private const val SHARE_TEXT_NOT_STAGED = "这段内容没能存成文件,已
|
||||
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
|
||||
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
|
||||
|
||||
/** 语音输入被本端挡下时那句告知停留的时长(同其它几条「动作刚发生」的告知)。 */
|
||||
private const val VOICE_NOTICE_MILLIS = 8 * 1000L
|
||||
|
||||
/**
|
||||
* 语音**还没连上**、因此一个字节都没发出去时的如实告知(走 `error` 通道,与「发送失败」同档)。
|
||||
*
|
||||
* 说清三件事:没连上(所以确实没发出去)、这段没丢(留在 `voiceDraft` 那一行)、下一步按「重发」。
|
||||
* 刻意不写「发送失败」:失败的是连接,而这一段一次都没上过路——这与文本那条
|
||||
* [SEND_QUEUED_NOTICE] 的措辞纪律同源,只是语音的字节留在本端等**用户**决定(不进自动补发队列)。
|
||||
*/
|
||||
private const val VOICE_NOT_CONNECTED_MESSAGE = "还没连上,这段语音没发出去;连上后点「重发」再试一次"
|
||||
|
||||
/**
|
||||
* 连接就绪却仍写不出去(socket 对象已空 / 序列化抛异常)时的如实告知。
|
||||
*
|
||||
* 与 [VOICE_NOT_CONNECTED_MESSAGE] 分开:这一档说的不是连接,而是这一次写帧真的失败了,
|
||||
* 所以不提「连上后」——那时候它已经连着。
|
||||
*/
|
||||
private const val VOICE_SEND_FAILED_MESSAGE = "这段语音没发出去,点「重发」再试一次"
|
||||
|
||||
/**
|
||||
* `voice_output` 的音频落盘时**允许照抄**的扩展名(契约白名单 `wav|mp3|ogg`,服务端目前恒 `wav`)。
|
||||
* 白名单之外的取值一律用 `.bin`:扩展名只用来让文件看得懂,**不**参与解码判定。
|
||||
*/
|
||||
private val VOICE_REPLY_FORMATS: Set<String> = setOf("wav", "mp3", "ogg")
|
||||
|
||||
/**
|
||||
* 一段「录好、还没发出去」的语音的**完整**内容(只在 VM 私有字段里,不进界面状态)。
|
||||
*
|
||||
* [wav] 是已经封好 RIFF 头的那份字节:重发时原样再编码一次 base64,**不重新封装**——
|
||||
* 重发的必须是同一段字节,否则「同一个 `clientMsgId`」在服务端判重之后留下的那份音频
|
||||
* 与用户眼前这段就对不上了。
|
||||
*/
|
||||
private data class VoiceRetryClip(
|
||||
val clientMsgId: String,
|
||||
val wav: ByteArray,
|
||||
val durationMs: Int,
|
||||
val totalBytes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* 「并行没排上」告知在界面上停留的时长(同上:描述刚发生的那一次发送)。
|
||||
*
|
||||
|
||||
@@ -22,7 +22,9 @@ import androidx.compose.ui.window.Dialog
|
||||
import top.yeij.cyrene.data.repository.CachedDocument
|
||||
import top.yeij.cyrene.domain.chat.DocumentPreview
|
||||
import top.yeij.cyrene.domain.chat.DocumentPreviewPlan
|
||||
import top.yeij.cyrene.domain.chat.DocumentSource
|
||||
import top.yeij.cyrene.domain.chat.DocumentTarget
|
||||
import top.yeij.cyrene.domain.chat.PreviewMetadata
|
||||
import top.yeij.cyrene.domain.chat.PreviewSlice
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
@@ -85,6 +87,15 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false
|
||||
* [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
|
||||
* 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
|
||||
*
|
||||
* ## 正文下方那一行元数据
|
||||
* 正文(非文本时是那句人话)**下方**还有一行灰色小字,写的是「这份文件是什么」的格式事实:
|
||||
* 文本给行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记,非文本给服务端嗅探所得的 mime。
|
||||
* 组装全在纯函数 [PreviewMetadata] 里(**「为什么某一项不显示」也写在那里**),这里只取证据、画一行。
|
||||
*
|
||||
* 它与上方那枚芯片刻意**不重复**:体积与服务端状态芯片上已经写着(同一个数字、同一个词,再写
|
||||
* 一遍只是噪音);字符数虽然也在芯片上,但芯片那个「8,000 字」没说清是谁的口径——本行补上
|
||||
* 「(服务端注入口径)」,这才不算一句会把用户带偏的话。
|
||||
*
|
||||
* @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
|
||||
* @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。
|
||||
* @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
|
||||
@@ -135,22 +146,25 @@ fun DocumentPreviewDialog(
|
||||
TextButton("重试", onClick = onRetry)
|
||||
}
|
||||
|
||||
is DocumentPreviewState.Ready -> when (plan) {
|
||||
DocumentPreviewPlan.Text -> {
|
||||
state.slice.notice?.let { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
is DocumentPreviewState.Ready -> {
|
||||
when (plan) {
|
||||
DocumentPreviewPlan.Text -> {
|
||||
state.slice.notice?.let { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
PreviewBody(state.slice)
|
||||
}
|
||||
PreviewBody(state.slice)
|
||||
}
|
||||
|
||||
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
|
||||
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
|
||||
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
|
||||
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
|
||||
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
|
||||
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
|
||||
}
|
||||
PreviewMetadataLine(state = state, plan = plan, target = target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +213,50 @@ fun DocumentPreviewDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文**下方**那一行灰色小字:关于这份文件的格式元数据([PreviewMetadata] 的产物)。
|
||||
*
|
||||
* 组装全在纯函数里(逐档穷举单测),这里只做两件事:**取证据**、**画一行**。
|
||||
* 取证据的两处来源都不许在 UI 层临场发挥:
|
||||
* - 服务端那两件事实(注入字数 / 注入是否被截断)从[芯片模型][DocumentChipModel]里取——
|
||||
* 它就是 DTO 那几字段的唯一换算落点([DocumentChips.of]);
|
||||
* - 嗅探所得的 mime 只从 [DocumentSource.Server.mime] 取。本地待发附件**没有**这个结论
|
||||
* (那时还没上传过),所以它这一项缺席——**不拿扩展名编一个**(`DocumentTarget.openType()`
|
||||
* 对本地来源回的是 `application/octet-stream` 这个兜底值,拿它当「类型」会是本端编的话)。
|
||||
*
|
||||
* 拼出来是空串时**整行不画**(一项证据都没有就没有这一行),绝不留一个空行或一排「未知」。
|
||||
*/
|
||||
@Composable
|
||||
private fun PreviewMetadataLine(
|
||||
state: DocumentPreviewState.Ready,
|
||||
plan: DocumentPreviewPlan,
|
||||
target: DocumentTarget,
|
||||
) {
|
||||
val items = remember(state.slice, plan, target) {
|
||||
when (plan) {
|
||||
// 文本:行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记。
|
||||
// `truncatedLabel` 非空 ⟺ DTO 的 `truncated == true`(见 DocumentChipModel)。
|
||||
DocumentPreviewPlan.Text -> PreviewMetadata.textItems(
|
||||
slice = state.slice,
|
||||
serverCharsLabel = target.chips.charsLabel,
|
||||
contextTruncated = target.chips.truncatedLabel != null,
|
||||
)
|
||||
|
||||
// 非文本:只有服务端嗅探所得的 mime(体积与状态已在上面那枚芯片里)。
|
||||
is DocumentPreviewPlan.NotText ->
|
||||
PreviewMetadata.fileItems((target.source as? DocumentSource.Server)?.mime)
|
||||
}
|
||||
}
|
||||
val line = PreviewMetadata.join(items)
|
||||
if (line.isEmpty()) return
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = line,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
|
||||
/** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */
|
||||
@Composable
|
||||
private fun PreviewBody(slice: PreviewSlice) {
|
||||
|
||||
@@ -54,6 +54,7 @@ import top.yeij.cyrene.domain.chat.ImageOpenTypes
|
||||
import top.yeij.cyrene.domain.chat.ImageSize
|
||||
import top.yeij.cyrene.domain.chat.ImageSource
|
||||
import top.yeij.cyrene.domain.chat.ImageZoom
|
||||
import top.yeij.cyrene.domain.chat.PreviewMetadata
|
||||
import top.yeij.cyrene.domain.chat.ZoomPan
|
||||
import top.yeij.cyrene.domain.chat.imageLoadFailureMessage
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
@@ -145,6 +146,28 @@ fun ImagePreviewDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// 预览内容下方那一行元数据要用的事实:像素与 EXIF 拍摄日期都得先读字节,所以放到 IO 上算。
|
||||
// 键是**那一份缓存**:换一张图(或重试拿回新的字节)才重算,重组不会。
|
||||
val facts by produceState(
|
||||
initialValue = PreviewImageFacts(),
|
||||
key1 = (state as? ImagePreviewState.Ready)?.cached,
|
||||
) {
|
||||
val cached = (state as? ImagePreviewState.Ready)?.cached ?: return@produceState
|
||||
value = withContext(Dispatchers.IO) { readPreviewImageFacts(cached.bytes) }
|
||||
}
|
||||
val ready = state as? ImagePreviewState.Ready
|
||||
val metadata = PreviewMetadata.join(
|
||||
PreviewMetadata.imageItems(
|
||||
// 「图片通配」不是格式:嗅探与平台声明都判不出来时这一项就缺席,
|
||||
// **不拿 `image/*` 冒充一个答案**。
|
||||
mime = ready?.openType?.takeIf { it != ImageOpenTypes.ANY_IMAGE },
|
||||
width = facts.width,
|
||||
height = facts.height,
|
||||
sizeBytes = ready?.cached?.bytes?.size?.toLong() ?: PreviewMetadata.UNKNOWN_BYTES,
|
||||
capturedAt = facts.capturedAt,
|
||||
),
|
||||
)
|
||||
|
||||
// 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节**
|
||||
// (不是重新编码过的位图,JPEG 存下来还是 JPEG)。
|
||||
val createDocument = rememberLauncherForActivityResult(
|
||||
@@ -237,7 +260,8 @@ fun ImagePreviewDialog(
|
||||
}
|
||||
|
||||
ExitPane(
|
||||
ready = state as? ImagePreviewState.Ready,
|
||||
ready = ready,
|
||||
metadata = metadata,
|
||||
notice = notice,
|
||||
note = nameNoteFor(source),
|
||||
onOpen = { openElsewhere() },
|
||||
@@ -344,12 +368,16 @@ private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
|
||||
/**
|
||||
* 底部那一列出口(与附件弹层同一形态)。
|
||||
*
|
||||
* 最上面那一行是**预览内容下方**的格式元数据([PreviewMetadata] 的产物,可能是空串——
|
||||
* 那就整行不画,绝不留一个空行或一排「未知」);再下面是 [notice](刚才那个动作的结果)。
|
||||
*
|
||||
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
|
||||
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ExitPane(
|
||||
ready: ImagePreviewState.Ready?,
|
||||
metadata: String,
|
||||
notice: DocumentPreviewNotice?,
|
||||
note: String,
|
||||
onOpen: () -> Unit,
|
||||
@@ -360,6 +388,14 @@ private fun ExitPane(
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 12.dp),
|
||||
) {
|
||||
if (metadata.isNotEmpty()) {
|
||||
Text(
|
||||
text = metadata,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
notice?.let { line ->
|
||||
Text(
|
||||
text = line.message,
|
||||
|
||||
@@ -24,10 +24,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
@@ -37,9 +47,23 @@ import top.yeij.cyrene.domain.chat.DocumentChipModel
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.ImageHostRebase
|
||||
import top.yeij.cyrene.domain.chat.TTS_LOADING_DESCRIPTION
|
||||
import top.yeij.cyrene.domain.chat.TTS_SPEAK_DESCRIPTION
|
||||
import top.yeij.cyrene.domain.chat.TTS_STOP_DESCRIPTION
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.hasVoice
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.isLoadingFor
|
||||
import top.yeij.cyrene.domain.chat.isVoiceMessage
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
import top.yeij.cyrene.domain.chat.ttsTextOf
|
||||
import top.yeij.cyrene.domain.chat.voiceFallbackNote
|
||||
import top.yeij.cyrene.domain.chat.voiceNote
|
||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
import top.yeij.cyrene.domain.parse.InlineRun
|
||||
import top.yeij.cyrene.domain.parse.InlineStyle
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
@@ -48,7 +72,13 @@ import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.extended.More
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pause
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/** 流式进行中的光标。 */
|
||||
private const val CURSOR = "▍"
|
||||
@@ -62,6 +92,11 @@ private val MESSAGE_ACTION_ICON_SIZE = 18.dp
|
||||
/** 「⋯」与来源角标之间的间距。 */
|
||||
private val MESSAGE_ACTION_GAP = 4.dp
|
||||
|
||||
/**
|
||||
* 气泡外侧那枚时间与气泡之间的空隙:时间贴气泡,但不贴成一体(也不必去够屏幕边)。
|
||||
*/
|
||||
private val MESSAGE_TIME_GAP = 6.dp
|
||||
|
||||
/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
|
||||
private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
|
||||
|
||||
@@ -82,6 +117,15 @@ private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传
|
||||
*
|
||||
* 判据刻意**窄**,只有 `user` 消息才可能成立:
|
||||
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
|
||||
* - **`voice` 键在就一律不成立**([hasVoice]):这一条问的是「这条里**有没有东西可看**」,不是
|
||||
* 「有没有语音形态」,所以两档都要挡住:
|
||||
* - `status = empty` / `transcribed`(语音形态画得出来):转写文本可能为空,但那条消息**不是
|
||||
* 什么都没有** —— 它有一个语音形态(`VoiceMessageRow` 会把它画出来)。少了这一档,一条
|
||||
* 「微信语音、没认出发音、也没有音频副本」的消息会被扣上「内容没有被传过来」这顶帽子,
|
||||
* 而那句话是**假的**;
|
||||
* - `status = failed`(入站转写失败 / 出站合成降级):**没有**语音形态,但有一条正文
|
||||
* (服务端那行诚实提示 / 降级后的原文本)和那行降级小标([voiceFallbackNote] 的产物)——
|
||||
* 同样不是「什么都没有」;
|
||||
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
|
||||
* 顺手改会波及所有助手气泡,不在本次范围内。
|
||||
*
|
||||
@@ -94,7 +138,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
message.content.isBlank() &&
|
||||
message.image.isNullOrBlank() &&
|
||||
message.documents.isEmpty() &&
|
||||
message.thinking.isNullOrBlank()
|
||||
message.thinking.isNullOrBlank() &&
|
||||
// 判据只有一处(domain.chat.hasVoice):绝不在这里另写一个 `voice != null`。
|
||||
// ⚠️ 用 hasVoice 而不是 isVoiceMessage:failed 行(转写失败 / 合成降级)也**不是**什么都没有
|
||||
// —— 它有正文(服务端那行诚实提示 / 降级后的原文本)与那行降级小标(voiceFallbackNote)。
|
||||
!hasVoice(message)
|
||||
|
||||
/**
|
||||
* 一条落库消息渲染成一个或多个气泡:
|
||||
@@ -103,11 +151,34 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* - 其它 mode 原样整条渲染,不解析标签;
|
||||
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
|
||||
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
|
||||
* - `voice` 键在时(🎤 判据 = **这个键存在且 `status != "failed"`**,契约 §4.4 + 后端 M57-M59
|
||||
* 预告,见 `isVoiceMessage`)在正文之外多画一枚语音形态([VoiceMessageRow]:播放 / 暂停 + 时长,
|
||||
* 有副本时才有播放控件);正文照常渲染——转写文本 / 服务端那行诚实提示都在里面,一个字都不改;
|
||||
* `status = "failed"` 那一档(入站转写失败 / 出站合成降级)**没有**语音形态可画,改为在正文之外
|
||||
* 多画一行灰色小标([VoiceFallbackNote],文案由 `voiceFallbackNote` 给:固定前半句 +
|
||||
* 服务端 `fallbackReason` 原样);
|
||||
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
|
||||
* [ImageHostRebase](见 [imageBaseUrl]);
|
||||
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
|
||||
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
|
||||
*
|
||||
* ## 外侧那枚时间(W69)
|
||||
* 每条**落库**消息在气泡外侧再画一枚灰色小字(主人:用户的气泡靠右 ⇒ 时间贴气泡左边;她的气泡
|
||||
* 靠左 ⇒ 时间贴气泡右边):文案由纯函数 [messageTimeLabel] 给(今天 `14:30` / `昨天 09:05` /
|
||||
* `3月7日 09:05` / 跨年 `2025年12月31日`),布局与宽度取舍见 [BubbleWithTime]。
|
||||
* 它是与气泡下方那行角标(来源 / 六态 / 「⋯」)**并列的另一块**:那块说的是「这条从哪来、能对它
|
||||
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
|
||||
*
|
||||
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
|
||||
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音那一块
|
||||
* ——语音形态或 `failed` 行的降级小标,`voice` 键在就必画其一)
|
||||
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
|
||||
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
|
||||
* (W72 补的就是它)。
|
||||
*
|
||||
* 只有落库消息有它:流式气泡([StreamingBubble])与待发气泡([PendingBubble],[PendingMessage]
|
||||
* 本端就没有时间戳这个概念)走的都是「不显示」那一档。
|
||||
*
|
||||
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
|
||||
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
|
||||
*
|
||||
@@ -136,6 +207,15 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* 回复侧在 [StreamingBubble] 上挂一次,用户就能把它们配起来。
|
||||
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
|
||||
* @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。
|
||||
* @param voicePlayback 那条**播放通道**此刻的状态(`ui.chat.VoicePlayer` 的 `state`)。只有指出
|
||||
* 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认
|
||||
* [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。
|
||||
* @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。
|
||||
* @param ttsClip 这一条(或**别的一条**)的朗读状态;只有 `messageId` 对得上时才影响本条。
|
||||
* 见 [TtsClip]:`null` / 对不上 / 本条没点过朗读 ⇒ 与加这一块之前逐像素相同。
|
||||
* @param ttsPlayback 那条**播放通道**此刻的状态。朗读与语音副本共用它,所以朗读的键是
|
||||
* `ttsClipIdOf(message.id)` 而不是 `message.id` —— 两枚图标的「正在响」互不串台。
|
||||
* @param onSpeak 用户点了「朗读」:交给上层发请求(本组件不自己发)。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
@@ -143,10 +223,15 @@ fun MessageBubble(
|
||||
imageBaseUrl: String = "",
|
||||
phase: TurnPhase = TurnPhase.Sent,
|
||||
legBadge: String? = null,
|
||||
voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle,
|
||||
ttsClip: TtsClip? = null,
|
||||
ttsPlayback: VoicePlaybackState = VoicePlaybackState.Idle,
|
||||
onDocumentClick: (DocumentRefDto) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
onEdit: (HistoryMessageDto) -> Unit = {},
|
||||
onDelete: (HistoryMessageDto) -> Unit = {},
|
||||
onVoiceToggle: (HistoryMessageDto) -> Unit = {},
|
||||
onSpeak: (HistoryMessageDto) -> Unit = {},
|
||||
) {
|
||||
val isUser = message.role == "user"
|
||||
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
|
||||
@@ -168,6 +253,34 @@ fun MessageBubble(
|
||||
// 所以 ③ 排队中**不会**被渲染成灰态——规范 §1.3 特意点名不要合并这两态。
|
||||
val gray = phase == TurnPhase.Answering
|
||||
|
||||
// 气泡**外侧**那枚时间(W69)。它在组合期按当下时刻算一次,**不 remember**:文案是
|
||||
// (时间戳, 现在, 时区) 的函数,而「现在」只在重组时才有机会更新——冻结它就等于让跨天之后
|
||||
// 还挂着昨天的口径。代价是「页面开着不动、跨度又恰好跨过零点」时它不会自己变,
|
||||
// 不为这一件事加一个 ticker(见 [messageTimeLabel])。
|
||||
val outsideTime = messageTimeLabel(
|
||||
createdAt = message.createdAt,
|
||||
nowMillis = System.currentTimeMillis(),
|
||||
)
|
||||
// 时间挂在哪一块:判据全在纯函数里([messageTimeCarrier],可单测)。原来是「最后一个有正文的
|
||||
// 气泡」(而不是「列表最后一项」——解析出的末段偶尔只有旁白,那一项不画正文框,挂上去时间就
|
||||
// 没有落脚点),于是**纯附件消息**(有 documents 或 image、`content` 为空 ⇒ 一个正文气泡都没有)
|
||||
// 那一行根本没被画出来。
|
||||
val timeCarrier = messageTimeCarrier(message, bubbles)
|
||||
|
||||
// —— 朗读(W75)——
|
||||
// 「这条消息有没有可念的正文」:判据与 VM 发请求用的是**同一个纯函数**([ttsTextOf]),
|
||||
// 所以「画了入口」与「发得出去」永远不会分叉。这里喂的是**已经解析好的** `bubbles`:
|
||||
// 它们就是屏幕上这些气泡,比让纯函数再解析一遍更贴近「用户看到的那段字」。
|
||||
// 只给她的消息(`assistant`)——主人的话不该用她的声音念回来,VM 里还有第二道闸。
|
||||
val speakable = !isUser && remember(bubbles) { ttsTextOf(bubbles) != null }
|
||||
// 本条自己的那份朗读状态:`messageId` 对不上的(用户点了别的一条)一律当没点过。
|
||||
val ownClip = ttsClip?.takeIf { it.messageId == message.id }
|
||||
val ownClipId = remember(message.id) { ttsClipIdOf(message.id) }
|
||||
val speakActive = ttsPlayback.isActiveFor(ownClipId)
|
||||
// 「正在准备」有两个来源:VM 那边请求还在飞(网络 / 合成,绝大多数时间在这里),以及播放通道
|
||||
// 正在取字节(本端落好的文件是直接给的,这一档只是一闪而过)。
|
||||
val speakPending = ownClip?.pending == true || ttsPlayback.isLoadingFor(ownClipId)
|
||||
|
||||
MessageGroup(
|
||||
isUser = isUser,
|
||||
footer = bubbleFooter(
|
||||
@@ -177,19 +290,80 @@ fun MessageBubble(
|
||||
shared = isSharedMessage(message),
|
||||
),
|
||||
onOpenMenu = openMenu,
|
||||
onSpeak = if (speakable) { { onSpeak(message) } } else null,
|
||||
speakActive = speakActive,
|
||||
speakPending = speakPending,
|
||||
ttsFailure = ownClip?.failure,
|
||||
) {
|
||||
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
|
||||
DocumentChipRow(
|
||||
chips = message.documents.map(DocumentChips::of),
|
||||
alignEnd = isUser,
|
||||
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
|
||||
// 气泡都没有时**,它们中的一个就是时间的落脚点。不是那一个时 `time = null`,
|
||||
// [BubbleWithTime] 连 `Box` 都不建 ⇒ 与 W69 之前逐像素相同。
|
||||
if (message.documents.isNotEmpty()) {
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Attachments },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
DocumentChipRow(
|
||||
chips = message.documents.map(DocumentChips::of),
|
||||
modifier = side,
|
||||
alignEnd = isUser,
|
||||
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
}
|
||||
}
|
||||
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
|
||||
?.let { ChatImage(it, onImageClick) }
|
||||
?.let { image ->
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Image },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
ChatImage(model = image, onClick = onImageClick, modifier = side)
|
||||
}
|
||||
}
|
||||
message.voice?.let { voice ->
|
||||
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
|
||||
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
|
||||
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
|
||||
if (isVoiceMessage(message)) {
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
VoiceMessageRow(
|
||||
voice = voice,
|
||||
messageId = message.id,
|
||||
playback = voicePlayback,
|
||||
note = voiceNote(voice.status, message.content),
|
||||
alignEnd = isUser,
|
||||
onToggle = { onVoiceToggle(message) },
|
||||
modifier = side,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// status == "failed":这条**没有可播的声音**(入站转写失败 / 出站合成降级)。
|
||||
// 正文照常按文字渲染(下面那条路),这里只多画一行灰色小标 —— **不**画语音形态:
|
||||
// 画出来就是一个必然播不了的控件(见 isVoiceMessage 的 KDoc)。
|
||||
// 没有这一行,这一条与普通文字消息逐像素相同,看的人无从知道它本来是一段声音。
|
||||
voiceFallbackNote(voice)?.let { note ->
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
VoiceFallbackNote(text = note, alignEnd = isUser, modifier = side)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isContentlessUserMessage(message)) {
|
||||
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
|
||||
ContentlessNote(onLongPress = openMenu)
|
||||
BubbleWithTime(time = outsideTime, isUser = isUser) { side ->
|
||||
ContentlessNote(onLongPress = openMenu, modifier = side)
|
||||
}
|
||||
} else {
|
||||
// 时间挂在**最后一个有正文的气泡**外侧。`null` = 这条的落点在附件块上(或这条一个字都不画),
|
||||
// 此时正文气泡一个都不带时间——时间挂在这条消息上一次,不跟着每个气泡重复。
|
||||
val carrierIndex = (timeCarrier as? MessageTimeCarrier.TextBubble)?.index
|
||||
bubbles.forEachIndexed { index, bubble ->
|
||||
Bubble(
|
||||
bubble = bubble,
|
||||
@@ -198,6 +372,7 @@ fun MessageBubble(
|
||||
showCursor = gray,
|
||||
onLongPress = openMenu,
|
||||
gray = gray,
|
||||
sideTime = outsideTime.takeIf { index == carrierIndex },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -300,6 +475,147 @@ fun bubbleFooter(
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条消息**外侧**那枚灰色时间的文案(纯函数,可单测);`null` = 这条一个字都不画。
|
||||
*
|
||||
* 四档,全部按**本地日历日**分(不是「相差不到 24 小时」那种算法:`23:59` 与次日 `00:01` 只差
|
||||
* 两分钟,却已经跨天,这一档必须是「昨天」):
|
||||
* - **今天**(与 `nowMillis` 落在同一个本地日期)→ `14:30`;
|
||||
* - **昨天** → `昨天 09:05`;
|
||||
* - **今年内更早** → `3月7日 09:05`(月日不补零,时分补零);
|
||||
* - **跨年** → `2025年12月31日`。**刻意不带时分**:一年前的消息精确到分没有意义,年月日已经
|
||||
* 把「多久远」说清了,多四个字符反而更难扫。
|
||||
*
|
||||
* ## 未来时间:按「今天」那一档显示(不造「明天」这个词)
|
||||
* 服务端时钟略快 / 本机时钟略慢时会出现几秒到几分钟「之后」的时间戳,它没有任何要表达的语义
|
||||
* (这条就是刚刚到的),所以只显示 `HH:mm`——那正是这条消息发生的那一刻。判据因此写成
|
||||
* `!day.isBefore(today)` 而不是 `day == today`:顺手也把「服务端时钟整整偏了一天 / 一年」这种
|
||||
* 坏时钟吃掉——宁可只显示一个时刻,也不在用户眼皮底下编出一个他从没见过的「明天」或假日期。
|
||||
*
|
||||
* ## 非法 / 缺失(`<= 0`):整条不画
|
||||
* 这个字段是**毫秒 Unix 时间戳**;`0` / 负数只可能来自「这个键根本没传过来」或解析事故,不是
|
||||
* 一个真实时刻。画成 `1970年1月1日` 或 `--:--` 都是在替服务端编一个它没说过的答案;而一条没有
|
||||
* 时间的消息并不缺什么(正文、来源角标、动作入口都还在,见 [MessageBubble])。
|
||||
*
|
||||
* ## 为什么在 UI 层算,而不是让 VM 把文案算好塞进 item
|
||||
* 它只依赖(这条的时间戳,现在,时区)——前两个 UI 层本地就有。为了一个「现在」去动 item 模型与
|
||||
* 列表的 key 不划算,而且那样还会把文案**冻住**:同一秒里算出来的字符串被 diff 当成同一个 item,
|
||||
* 跨天之后那一屏就永远停在旧口径。
|
||||
*
|
||||
* `zone` 可注入(默认本机时区),`nowMillis` 由调用方喂:这条映射自己不读表,所以可单测、可复现
|
||||
* (与 [searchHitTime] 同一条写法)。
|
||||
*/
|
||||
fun messageTimeLabel(
|
||||
createdAt: Long,
|
||||
nowMillis: Long,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): String? {
|
||||
if (createdAt <= 0L) return null
|
||||
val at = Instant.ofEpochMilli(createdAt).atZone(zone)
|
||||
val day = at.toLocalDate()
|
||||
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
|
||||
return when {
|
||||
// 今天**与未来**同一档(见 KDoc:未来只可能是时钟漂移,不造「明天」这个词)。
|
||||
!day.isBefore(today) -> at.format(MessageClockFormat)
|
||||
day == today.minusDays(1) -> "$YESTERDAY_PREFIX ${at.format(MessageClockFormat)}"
|
||||
day.year == today.year -> at.format(MessageDayFormat)
|
||||
else -> at.format(MessageDateOnlyFormat)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「今天」那一档,同时也是其余各档拼时分的零件:24 小时制、补零。
|
||||
*
|
||||
* `Locale.ROOT`:刻意不跟随设备 `locale`——这几档文案的形状是固定的(「3月7日」不会因为系统语言
|
||||
* 变成别的写法),不给本地化留口子。
|
||||
*/
|
||||
private val MessageClockFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
|
||||
|
||||
/**
|
||||
* 「今年内更早」那一档:`3月7日 09:05`(月日**不**补零,与 [messageTimeLabel] 的例一致)。
|
||||
*
|
||||
* 两处模式字母都要盯住:**`d`** 才是「月里的第几天」,大写 **`D`** 是「一年里的第几天」
|
||||
* (`M'月'D'日'` 会把 8 月 14 日排成「8月226日」——W69 的单测就是在这一步拦下的)。
|
||||
*/
|
||||
private val MessageDayFormat = DateTimeFormatter.ofPattern("M'月'd'日' HH:mm", Locale.ROOT)
|
||||
|
||||
/** 「跨年」那一档:`2025年12月31日`(刻意不带时分)。 */
|
||||
private val MessageDateOnlyFormat = DateTimeFormatter.ofPattern("yyyy'年'M'月'd'日'", Locale.ROOT)
|
||||
|
||||
/** 「昨天」那两个字:只与时分连用(`昨天 09:05`),不会单独成句。 */
|
||||
private const val YESTERDAY_PREFIX = "昨天"
|
||||
|
||||
/**
|
||||
* 这条消息的时间**落在哪一块**上([messageTimeCarrier] 的返回值)。
|
||||
*
|
||||
* 之所以要给它一个名字,是因为「时间挂在正文气泡上」与「时间挂在附件块上」在 [MessageBubble] 里
|
||||
* 是**两处不同的调用点**(前者由 [Bubble] 经 `sideTime` 代画,后者各自包一层 [BubbleWithTime]),
|
||||
* 而某条消息只能落一个地方。判据本身是纯函数,所以「纯附件消息也有时间」这件事能被单测直接钉住,
|
||||
* 不必去跑一个 Compose 测试。
|
||||
*/
|
||||
sealed interface MessageTimeCarrier {
|
||||
/** 正文气泡:`bubbles` 里的下标([MessageBubble] 解析出来的第几个气泡)。 */
|
||||
data class TextBubble(val index: Int) : MessageTimeCarrier
|
||||
|
||||
/** 附件芯片排(`documents` 非空才会画)。 */
|
||||
data object Attachments : MessageTimeCarrier
|
||||
|
||||
/** 图片(`image` 非空才会画)。 */
|
||||
data object Image : MessageTimeCarrier
|
||||
|
||||
/** 语音那一块(`voice` 键在才会画:语音形态,或 `failed` 行的降级小标)。 */
|
||||
data object Voice : MessageTimeCarrier
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条消息外侧那枚时间该挂在哪一块上(纯函数,可单测);`null` = 什么都不挂。
|
||||
*
|
||||
* ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西
|
||||
*
|
||||
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音那一块 → 正文气泡。
|
||||
* 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。
|
||||
*
|
||||
* 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
|
||||
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
|
||||
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
|
||||
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音那一块 → 图片 → 附件芯片排。这一档就是
|
||||
* **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡
|
||||
* `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来**
|
||||
* (主人 2026-10-07 验收时发现的缺口)。
|
||||
* 「语音那一块」按 `voice` 键判([hasVoice])而**不**按 [isVoiceMessage]:`failed` 行不画语音
|
||||
* 形态,但会画那行降级小标([VoiceFallbackNote])——它也占着一块,也照样能挂时间。
|
||||
* 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。
|
||||
*
|
||||
* ## 谁**不**走这一档
|
||||
* - **真的空气泡**([isContentlessUserMessage] 成立):上面三块按判据一定都不在,所以这里返回
|
||||
* `null`;但那种消息**仍然有时间**——[MessageBubble] 把 [ContentlessNote] 自己包了一层
|
||||
* [BubbleWithTime]。两处刻意分开:那块的落脚点由 `ContentlessNote` 那条路自己交代,不必让这个
|
||||
* 函数认识它(它只答「附件块和正文气泡里挂哪个」)。
|
||||
* - **思维链卡片**:它永远画在最上面,不可能是「最后一块」(有别的块时被别的块盖过,没别的块时
|
||||
* 这条消息的真问题也不是时间)。不把它算进来 ⇒ 只有思维链的助手消息与 W69 之前逐字相同。
|
||||
* - **流式 / 待发气泡**:它们根本不调这个函数(见 [BubbleWithTime] 的 KDoc)。
|
||||
*
|
||||
* 与角色无关:她侧(`assistant`)也会带附件与图,同一条规则照用。贴哪一侧由 [BubbleWithTime]
|
||||
* 按 `isUser` 定,不在这里判——这一层只回答「挂在哪一块」,不回答「挂在它的哪一边」。
|
||||
*
|
||||
* @param bubbles [MessageBubble] 已经解析好的气泡列表(本函数不去 `parse` 一遍:那一份判据在
|
||||
* `isChat` 上,重复算一次会多出「两份解析结果不一致」的可能)。
|
||||
*/
|
||||
fun messageTimeCarrier(
|
||||
message: HistoryMessageDto,
|
||||
bubbles: List<ParsedBubble>,
|
||||
): MessageTimeCarrier? {
|
||||
val lastBody = bubbles.indexOfLast { it.body.isNotEmpty() }
|
||||
if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody)
|
||||
// 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。
|
||||
return when {
|
||||
hasVoice(message) -> MessageTimeCarrier.Voice
|
||||
!message.image.isNullOrBlank() -> MessageTimeCarrier.Image
|
||||
message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观发送中的用户消息(还没落库)。
|
||||
*
|
||||
@@ -334,6 +650,9 @@ fun PendingBubble(
|
||||
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
|
||||
// 语音:正文是空的(本端不转写,用户说的话要等服务端 STT 之后才落库),
|
||||
// 所以这一条消息至少要有「这是语音、多长」这一枚可见凭据——与接收侧的语音形态同一个外观。
|
||||
item.voiceDurationMs?.let { durationMs -> VoiceClipLabel(durationMs) }
|
||||
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
|
||||
}
|
||||
}
|
||||
@@ -497,6 +816,10 @@ private fun MessageGroup(
|
||||
isUser: Boolean,
|
||||
footer: String? = null,
|
||||
onOpenMenu: (() -> Unit)? = null,
|
||||
onSpeak: (() -> Unit)? = null,
|
||||
speakActive: Boolean = false,
|
||||
speakPending: Boolean = false,
|
||||
ttsFailure: String? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -509,7 +832,7 @@ private fun MessageGroup(
|
||||
content()
|
||||
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
|
||||
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
|
||||
if (footer != null || onOpenMenu != null) {
|
||||
if (footer != null || onOpenMenu != null || onSpeak != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
|
||||
@@ -522,6 +845,31 @@ private fun MessageGroup(
|
||||
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
|
||||
)
|
||||
}
|
||||
// 「朗读」紧挨「⋯」:两枚都是这条消息的动作,分开放会让人以为是两个话题。
|
||||
// 与「⋯」同一个尺寸、同一条 `Icon + clickable` 纪律(理由见下面那段注释)。
|
||||
if (onSpeak != null) {
|
||||
Icon(
|
||||
imageVector = if (speakActive) MiuixIcons.Pause else MiuixIcons.Play,
|
||||
contentDescription = when {
|
||||
speakActive -> TTS_STOP_DESCRIPTION
|
||||
speakPending -> TTS_LOADING_DESCRIPTION
|
||||
else -> TTS_SPEAK_DESCRIPTION
|
||||
},
|
||||
// 正在响 ⇒ 主色(与语音形态那枚图标同一口径);其余是角标行那档灰。
|
||||
tint = if (speakActive) {
|
||||
MiuixTheme.colorScheme.primary
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
// 正在准备时**点不动**:这时候再发一次请求没有任何意义,用户想要的是
|
||||
// 「别再等了」——而那条路(取消在飞的合成)本端没有做,所以不给一个
|
||||
// 点了没反应的入口(见报告「未做」)。
|
||||
modifier = Modifier
|
||||
.size(MESSAGE_ACTION_ICON_SIZE)
|
||||
.clip(CircleShape)
|
||||
.clickable(enabled = !speakPending, onClick = onSpeak),
|
||||
)
|
||||
}
|
||||
if (onOpenMenu != null) {
|
||||
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
|
||||
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
|
||||
@@ -538,9 +886,104 @@ private fun MessageGroup(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 朗读没成功时那一句。挂在角标行**下面**、自成一行:塞进那一行会把来源角标挤成两截,
|
||||
// 而这句话是给用户看的一整句,不该跟角标抢横向空间。
|
||||
// 颜色用 `error` 而不是灰:它是一条**如实告知**(不是装饰),但也不弹窗、不打断——
|
||||
// 用户的眼睛本来就在他刚点的那条消息上。
|
||||
if (ttsFailure != null) {
|
||||
Text(
|
||||
text = ttsFailure,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡本体 + 它**外侧**那一枚灰色时间(主人 W69):**用户**的气泡靠右 ⇒ 时间贴气泡**左边**;
|
||||
* **她**的气泡靠左 ⇒ 时间贴气泡**右边**。
|
||||
*
|
||||
* ## 时间那一条的宽度是「量」出来的,不参与宽度分配
|
||||
* 气泡可用宽度有限([BubbleBody] 自己封了 `300.dp` 的上限),而时间与气泡**同一行**,必然分掉
|
||||
* 一点横向空间。这里**不让两者去抢 weight**,而是先用 [rememberTextMeasurer] 把这一行字按
|
||||
* **它将要用的那个 [MiuixTheme.textStyles.footnote2]** 量一遍,拿到它排出来的**精确宽度**,
|
||||
* 再用 `padding` 把这条宽度从气泡的可用宽度里**先扣掉**:
|
||||
* - 时间量得准(同一份 `TextStyle`、同一份字体缩放与字体回退,量出来的就是排出来的那一串字——
|
||||
* `Text` 内部只是在它自己那份 `style` 上 merge 显式参数,不参数字段一律不动),所以它**既不会
|
||||
* 溢出屏幕、也不会被压成两行**;
|
||||
* - 气泡吃**剩下的全部**宽度(`padding` 是在约束里扣的,气泡自己的 `widthIn(max = 300.dp)` 照旧
|
||||
* 生效 ⇒ 实际可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`),所以长消息照旧用满能用的宽度。
|
||||
* 代价写在明处:**有时间的消息,气泡比加这一块之前最多窄一条时间的宽度**(这是「时间与气泡同行」
|
||||
* 的必然结果,也是主人要的那件事)。只有「极窄屏 + 超大字号」这一条极端组合下气泡才会明显变窄
|
||||
* ——那时我们选择**如实把日期显示完整**:截断「2025年12月31日」比窄一点的气泡更亏。
|
||||
*
|
||||
* 为什么不用「把时间排在子节点前面、让它先占住宽度」这种零测量的写法:`Row` 给非 weight 子节点的
|
||||
* 宽度是**按子节点顺序**递减的,所以「气泡在前」的那一侧(她那一侧)必然先把宽度吃光、把时间挤成
|
||||
* 一条竖线;而 Compose 的 `Row` 只有「顺序摆放」一种排布,做不到「先量、后摆」。自写 `Layout` 当然
|
||||
* 可以,但要自己接管窄屏下的钳制,而且那份宽度就是同一笔账——量一次是这里最小的一步。
|
||||
*
|
||||
* ## 摆在哪(三条都是「不许动气泡」的具体落实)
|
||||
* - 时间在**气泡外边**:`padding` 在 `clip` / `background` 之外,所以它不是气泡的一部分——底色、
|
||||
* 圆角、[SelectionContainer] 的选字范围、长按的点击区全都与加它之前逐像素 / 逐行为相同;
|
||||
* - 时间与气泡**底对齐**:贴着气泡最后一行(气泡很高时它也不会跑到中间去);
|
||||
* - 与气泡之间有 [MESSAGE_TIME_GAP] 的空隙,免得那串灰字和气泡贴成一体。
|
||||
*
|
||||
* 整块(时间 + 气泡)仍由 [MessageGroup] 那一列按角色对齐(主人靠右、她靠左),所以**气泡自己那
|
||||
* 一侧**离屏幕的边距与没有时间时**逐像素相同**——时间填的正是原本空着的那条区域。
|
||||
*
|
||||
* @param time `null` = 这条消息不显示时间(时间戳缺失 / 非法,见 [messageTimeLabel]):此时**连
|
||||
* `Box` 都不建**,整个气泡与加这一块之前逐像素相同——流式气泡与待发气泡走的正是这一档
|
||||
* (后者本端还没有任何时间戳)。
|
||||
*/
|
||||
@Composable
|
||||
private fun BubbleWithTime(
|
||||
time: String?,
|
||||
isUser: Boolean,
|
||||
bubble: @Composable (Modifier) -> Unit,
|
||||
) {
|
||||
if (time == null) {
|
||||
bubble(Modifier)
|
||||
return
|
||||
}
|
||||
val measurer = rememberTextMeasurer()
|
||||
val style = MiuixTheme.textStyles.footnote2
|
||||
val density = LocalDensity.current
|
||||
val strip = remember(measurer, density, time, style) {
|
||||
with(density) {
|
||||
measurer.measure(text = time, style = style, maxLines = 1, softWrap = false).size.width.toDp()
|
||||
} + MESSAGE_TIME_GAP
|
||||
}
|
||||
Box {
|
||||
bubble(
|
||||
Modifier
|
||||
.then(if (isUser) Modifier.padding(start = strip) else Modifier.padding(end = strip))
|
||||
// 气泡贴回它自己那一侧:`strip` 已经吃掉了可用的余量,这里的对齐在正常宽度下是
|
||||
// 恒等的(气泡就是最宽的那个孩子),留着是为了「万一条比气泡还宽」时也仍在原位。
|
||||
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart),
|
||||
)
|
||||
Text(
|
||||
text = time,
|
||||
style = style,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
// 宽度已经量过、条也留够了,正常情况下一行必放得下;这两个参数是防「量完到排完之间
|
||||
// 密度变了」这类不该发生的事——真发生也只是这串字自己被截,不会去挤气泡。
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier.align(if (isUser) Alignment.BottomStart else Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条消息在气泡层渲染成一个 / 多个气泡。
|
||||
*
|
||||
* [sideTime] 只由**携带时间的那个气泡**收到([MessageTimeCarrier.TextBubble] 指的那一个),
|
||||
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。`body` 为空的气泡
|
||||
* (比如纯附件消息解析出来的那个占位气泡)一个像素都不画,**连 [BubbleWithTime] 都不进**:
|
||||
* 这也是纯附件消息会整条没有时间的原因(判据见 [messageTimeCarrier])。
|
||||
*/
|
||||
@Composable
|
||||
private fun Bubble(
|
||||
bubble: ParsedBubble,
|
||||
@@ -549,10 +992,24 @@ private fun Bubble(
|
||||
showCursor: Boolean = false,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
gray: Boolean = false,
|
||||
sideTime: String? = null,
|
||||
) {
|
||||
AnnotationLine(bubble.head)
|
||||
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
|
||||
if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray)
|
||||
val withCursor = showCursor && isLast && bubble.body.isNotEmpty()
|
||||
val body = if (withCursor) bubble.body + CURSOR else bubble.body
|
||||
if (body.isNotEmpty()) {
|
||||
BubbleWithTime(time = sideTime, isUser = isUser) { side ->
|
||||
BubbleBody(
|
||||
text = body,
|
||||
runs = bubble.runs,
|
||||
isUser = isUser,
|
||||
onLongPress = onLongPress,
|
||||
gray = gray,
|
||||
trailingCursor = withCursor,
|
||||
modifier = side,
|
||||
)
|
||||
}
|
||||
}
|
||||
AnnotationLine(bubble.tail)
|
||||
}
|
||||
|
||||
@@ -582,7 +1039,14 @@ private fun LegBadge(text: String, onClick: (() -> Unit)?) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
|
||||
/**
|
||||
* 旁白/动作:灰斜体小字,依附在气泡上/下。
|
||||
*
|
||||
* 这一行**不做行内解析**(正文那四类标签在这里照原样显示)。理由是它读的是 `parts` 里的原文,
|
||||
* 而分享预览那行读的是同一批原文(`ShareConfirmPanel.ShareConfirmAnnotation`)——那边是红线、
|
||||
* 本次一个字都不能动。只在这边渲染,就等于让「预览里看到的 == 发出去之后看到的」在旁白上又裂一次,
|
||||
* 而 W65 刚把这道缝补上。要连带做,得两处一起改并单独拍板(实测 `<action>` 里带 `<b>` 的 0 命中)。
|
||||
*/
|
||||
@Composable
|
||||
private fun AnnotationLine(parts: List<ContentPart>) {
|
||||
if (!LocalAppPrefs.current.showNarration) return
|
||||
@@ -610,11 +1074,14 @@ private fun AnnotationLine(parts: List<ContentPart>) {
|
||||
* ## 长按仍然开动作菜单
|
||||
* 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。
|
||||
* 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。
|
||||
*
|
||||
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`:
|
||||
* 和 [BubbleBody] 一样,它是**气泡外边**的留白,不参与这张灰底方块的绘制与点击区。
|
||||
*/
|
||||
@Composable
|
||||
private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
|
||||
private fun ContentlessNote(onLongPress: (() -> Unit)? = null, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.clip(BubbleShape)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
@@ -648,16 +1115,35 @@ private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
|
||||
* 这一态的语义是「这条已经在被处理,但还没有答案」,它既不是用户说的话(不该是主人的青色),
|
||||
* 也不是她的回答(不该是助手的正常底色)。正文一个字都不改,只是**褪色**,让人一眼看出
|
||||
* 「没丢,只是在等」。
|
||||
*
|
||||
* [runs] 非空时**只画 [runs]**(拼起来就是 `text` 去掉末尾光标那一截,见 [inlineBody]):
|
||||
* 同一段正文不在这里再拼一遍,免得拼出两个版本的字。[runs] 为空 = 这段正文没有可渲染的行内标签,
|
||||
* 走原来的 `Text(text)` 那条路(**绝大多数消息都是这一档,一个像素都不变**)。
|
||||
*
|
||||
* [trailingCursor] 说的是「`text` 末尾那枚光标」。光标必须**在片段之外**补上去:它不是正文,
|
||||
* 不该继承她最后那一段的粗体/等宽底色。
|
||||
*
|
||||
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
|
||||
* 它排在这条链的**最前面**,所以那一层留白在 `clip` / `background` / `combinedClickable` 之外:
|
||||
* 气泡自己的样子、选字范围、长按的点击区都还是原来的那一块。
|
||||
*/
|
||||
@Composable
|
||||
private fun BubbleBody(
|
||||
text: String,
|
||||
runs: List<InlineRun>,
|
||||
isUser: Boolean,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
gray: Boolean = false,
|
||||
trailingCursor: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val contentColor = when {
|
||||
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
isUser -> MiuixTheme.colorScheme.onPrimary
|
||||
else -> MiuixTheme.colorScheme.onSurfaceContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.widthIn(max = 300.dp)
|
||||
// clip + background 与原来那句 `background(color, shape)` 画出来完全一样,
|
||||
// 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。
|
||||
@@ -685,19 +1171,74 @@ private fun BubbleBody(
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = when {
|
||||
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
isUser -> MiuixTheme.colorScheme.onPrimary
|
||||
else -> MiuixTheme.colorScheme.onSurfaceContainer
|
||||
},
|
||||
)
|
||||
if (runs.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = contentColor,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = inlineBody(
|
||||
runs = runs,
|
||||
codeBackground = if (isUser) {
|
||||
MiuixTheme.colorScheme.onPrimary.copy(alpha = INLINE_CODE_ON_USER_BACKGROUND)
|
||||
} else {
|
||||
MiuixTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
trailingCursor = trailingCursor,
|
||||
),
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `<code>` 那点底色在**主人那侧**(青色气泡)的透明度:`onPrimary` 压一层浅底。
|
||||
* 昔涟那侧直接用 `surfaceContainerHigh`——它本来就是比气泡底(`surfaceContainer`)高一档的填充色,
|
||||
* 同一套配色里现成的一档,不另造颜色。
|
||||
*/
|
||||
private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f
|
||||
|
||||
/**
|
||||
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 下划线 / 等宽底色。
|
||||
*
|
||||
* **为什么不拼一串 `Text` 节点**:一条气泡本来就是**一整段可选中的文本**([SelectionContainer]
|
||||
* 的行为一个字都不用改),折行位置也由同一个排版器一次定完——拆成多个节点会让可选性与折行
|
||||
* 各自为政。`<br>` 已经不在这一层(它是气泡分隔符,切在解析层),所以这里不再有「插一个换行」
|
||||
* 这种活。
|
||||
*
|
||||
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体/装饰),所以非 `<code>` 的片段与原来逐像素相同。
|
||||
*
|
||||
* `u` 落到 `TextDecoration.Underline`(六项白名单里的那一项,见 `InlineMarkup.kt` 的文件头);
|
||||
* `s` 不在白名单里,所以这里**没有**删除线分支——写 `<s>甲</s>` 时那四个字符是按字面显示的正文,
|
||||
* 走的是 `runs` 为空的那条路(`BubbleBody` 里的裸 `Text`)。
|
||||
*/
|
||||
private fun inlineBody(
|
||||
runs: List<InlineRun>,
|
||||
codeBackground: Color,
|
||||
trailingCursor: Boolean,
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
for (run in runs) {
|
||||
val code = InlineStyle.Code in run.styles
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontWeight = if (InlineStyle.Bold in run.styles) FontWeight.Bold else null,
|
||||
fontStyle = if (InlineStyle.Italic in run.styles) FontStyle.Italic else null,
|
||||
textDecoration = if (InlineStyle.Underline in run.styles) TextDecoration.Underline else null,
|
||||
fontFamily = if (code) FontFamily.Monospace else null,
|
||||
background = if (code) codeBackground else Color.Unspecified,
|
||||
),
|
||||
) {
|
||||
append(run.text)
|
||||
}
|
||||
}
|
||||
if (trailingCursor) append(CURSOR)
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡里的图片(缩略)。
|
||||
*
|
||||
@@ -707,13 +1248,17 @@ private fun BubbleBody(
|
||||
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
|
||||
*
|
||||
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
|
||||
*
|
||||
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
|
||||
* 它排在这条链的**最前面**:那一层留白在 `widthIn` / `clip` / `clickable` 之外,所以图片自己的
|
||||
* 尺寸上限、圆角与点击区都与加它之前逐像素 / 逐行为相同(与 [BubbleBody] 同一条纪律)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ChatImage(model: String, onClick: (String) -> Unit) {
|
||||
private fun ChatImage(model: String, onClick: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = "查看图片",
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.widthIn(max = 240.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable { onClick(model) },
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.ExifInterface
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
/**
|
||||
* 从本端持有的那份字节里**现读**出来的图片事实:像素与拍摄日期。读不到的就是 `null`。
|
||||
*
|
||||
* ## 为什么这一层不在 `domain/` 里
|
||||
* [PreviewMetadata][top.yeij.cyrene.domain.chat.PreviewMetadata] 是纯函数(JVM 单测里逐档穷举),
|
||||
* 而这里调的两个都是 Android 平台类(`android.graphics.BitmapFactory` / `android.media.ExifInterface`),
|
||||
* 在 JVM 单测里是**抛异常的 stub**。所以切口落在这里:读平台 → 得到两个可能为 `null` 的数 →
|
||||
* 交给纯函数组装成那一行。判断「显示什么」的逻辑一个字都不在平台类里,测试因此能覆盖全部档位。
|
||||
*
|
||||
* ## 分辨率为什么用 `inJustDecodeBounds`
|
||||
* 为了一行小字把 20 MiB 的大图解成位图是不可接受的(内存 + 主线程耗时)。`inJustDecodeBounds`
|
||||
* 只读文件头,`outWidth` / `outHeight` 就是答案,`decodeByteArray` 返回 `null` 是有意的。
|
||||
*
|
||||
* ## EXIF 用的是**系统 API**,不引新依赖
|
||||
* `android.media.ExifInterface`(`InputStream` 构造自 API 24 起可用,本工程 `minSdk = 33`)。
|
||||
* 它认不出的容器(PNG / GIF / WebP / BMP 这些没有标准 EXIF 的格式)只会让 `getAttribute` 回
|
||||
* `null`——**那正是「拿不到就整项不缺席」要的答案**,不是错误。⚠️ 它**只读 JPEG 与 RAW**,
|
||||
* 所以「所有图片都有拍摄日期」是错的预期,能报出来的基本只有相机/相册里的 JPEG。
|
||||
*
|
||||
* ## 日期为什么不在这里格式化
|
||||
* EXIF 的 `DateTimeOriginal` 是**没有时区**的墙钟字符串(`yyyy:MM:dd HH:mm:ss`),本层**原样**
|
||||
* 交给 [PreviewMetadata.formatExifDateTime][top.yeij.cyrene.domain.chat.PreviewMetadata.formatExifDateTime]
|
||||
* 去严格校验与格式化——那一步是纯函数,能逐档单测,也能把「不做时区换算」这条取舍写在明面上。
|
||||
*/
|
||||
data class PreviewImageFacts(
|
||||
val width: Int? = null,
|
||||
val height: Int? = null,
|
||||
/** EXIF `DateTimeOriginal` 的原始字符串;缺席即 `null`。 */
|
||||
val capturedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 读出这两个事实。**任何一步失败都只是少一项**(`runCatching`):这一行是锦上添花,
|
||||
* 它自己绝不许成为「图片打不开」的原因。
|
||||
*
|
||||
* 只从**已经在内存里**的那份字节读([top.yeij.cyrene.data.repository.CachedDocument.bytes],
|
||||
* 预览与两条出口用的都是它),不再碰盘、不再联网。
|
||||
*/
|
||||
fun readPreviewImageFacts(bytes: ByteArray): PreviewImageFacts {
|
||||
val bounds = readBounds(bytes)
|
||||
return PreviewImageFacts(
|
||||
width = bounds?.first,
|
||||
height = bounds?.second,
|
||||
capturedAt = readCapturedAt(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件头里的像素:任一侧 ≤ 0(解不出来 / 不是图片)就是 `null`。
|
||||
*
|
||||
* 与 `ZoomableImage` 里 Coil 报上来的那个尺寸是两回事:那个是**解码后**的位图尺寸,要等图片
|
||||
* 真的解出来才有;这里是**打开弹层时立刻**就有(只看文件头)。两者一致是常态,不一致时以这里
|
||||
* 为准——它描述的是文件本身。
|
||||
*/
|
||||
private fun readBounds(bytes: ByteArray): Pair<Int, Int>? = runCatching {
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
val width = options.outWidth
|
||||
val height = options.outHeight
|
||||
if (width > 0 && height > 0) width to height else null
|
||||
}.getOrNull()
|
||||
|
||||
/** EXIF 的 `DateTimeOriginal`;没有这个标签 / 不是 EXIF / 读不动,一律 `null`。 */
|
||||
private fun readCapturedAt(bytes: ByteArray): String? = runCatching {
|
||||
ByteArrayInputStream(bytes).use { stream ->
|
||||
ExifInterface(stream).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
|
||||
}
|
||||
}.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
@@ -0,0 +1,134 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.allClosed
|
||||
|
||||
/**
|
||||
* **连接重建**(不是第一次接上)时把本端的「进行中」一次性收口。
|
||||
*
|
||||
* ## 它修的是什么(主人 2026-10-06 报的现场)
|
||||
* 后端卡在半路(回合在跑、或工具在跑)时被**重启**:那一轮在服务端**已经不存在了**,
|
||||
* 所以本端**永远不会**再收到它的任何收口帧(`turn.end` / `stream_end` / 助手落库行)。
|
||||
* 而本端「进行中」的每一条闸都只认**帧**:
|
||||
*
|
||||
* - 灰态(④「已读,正在回答…」)的唯一闸是 [top.yeij.cyrene.domain.chat.TurnBinding.closed],
|
||||
* 它只由 `turn.end` / 同 `turnId` 的助手落库行驱动(`ChatViewModel.onEvent`);
|
||||
* - 「思考中…」/「执行 X…」的唯一闸是流式路自己的 `thinkingActive` / `active` / 工具卡的 `phase`
|
||||
* (收口只发生在 `stream_end` / 助手落库行);
|
||||
* - 底栏那句「正在思考…」的唯一闸是 `session_busy` / `session_idle` 报的 [ChatUiState.busyMode]。
|
||||
*
|
||||
* 三条路都断在「没有帧」上 ⇒ **没有任何一条会自己收口**,界面于是永久停在那一格:气泡一直灰着、
|
||||
* 卡片一直转、底栏一直说她在思考。三条里今天唯一被兜住的是 `session_idle` / `session_interrupted`
|
||||
* 那两帧(`allClosed()` 的两个既有调用点)——而**后端重启时它们永远不会到**(说这话的进程已经没了)。
|
||||
*
|
||||
* 还有一处**不显眼但很要命**的连带:那条永远 `active` 的流式路永远是消息列表的**末项**
|
||||
* ([ChatUiState.items] 的最后一项,`reverseLayout` 下就是**屏幕最下方**),而 `ChatScreen` 的
|
||||
* 「跟随底部」判的正是 `items.lastOrNull()?.key`(`ChatScreen.kt:508`)——末项不再变化 ⇒
|
||||
* **此后新到的消息不再把列表滚到底**,于是「最新一条」永远停在那一张转着的卡上。用户看到的就是
|
||||
* 「拉不到新消息」(消息其实进得来,是屏幕不再跟着它走)。
|
||||
*
|
||||
* 收口之后这张卡变成**惰性**的(不再转、不再声称在思考):它仍留在列表里(正文一个字都不删,
|
||||
* 见下面「不许碰的东西」),直到它自己那一路的落库行到达、或**下一轮** `message.start` 把它当作
|
||||
* 「已结束的路」丢掉(`TurnStreams.beginLeg`)——那时末项恢复推进,跟随底部也就回来了。
|
||||
* 这条残留如实登记:收口**不**替这张卡删内容,所以它自己要等一个后续事件才消失。
|
||||
*
|
||||
* ## 触发时机:只做「连接重建」((a)),**不做静默超时**((b))
|
||||
* 判据落在 [isReconnect] 上,也就是 `WsState` **非 Connected → Connected** 的那一跳。三条理由:
|
||||
* 1. **确定性**:后端重启必然重建连接,而重建连接**必然**让 `WsState` 走一遍 `Reconnecting →
|
||||
* Connected`(`ChatSocket.runConnectionLoop`)。这条判据是**事件驱动**的,不需要时间常量;
|
||||
* 2. **可验证**:假后端 `POST /__control/ws-close`(或直接重启进程)就能复现,设备上判据是
|
||||
* 「一条已在飞的回合 + 一次真踢」,不依赖「等多久」这种主观量;
|
||||
* 3. **与既有纪律一致**:本项目自 W38 起就定死「状态驱动,不按时间轮」(见
|
||||
* `ChatViewModel.retryFirstPage` 的 KDoc),(b) 会在同一张表上引入第二个口径。
|
||||
*
|
||||
* (b)(某一路 N 秒没有任何帧就认为它结束了)**刻意没做**,代价与风险如实记在这里,留主人拍板:
|
||||
* - 它要多一个**时间常量**,而那个数只能拍:太小会把「正常但慢」的回合(长思维链、长工具执行、
|
||||
* 服务端排队)误杀成已结束,太大则回到「卡住很久」的老样子;
|
||||
* - 它能在**连接没断**时兜底(后端进程活着但编排器死锁、或帧被中间设备悄悄丢掉),而 (a) 兜不住
|
||||
* 这一档;但这一档今天**没有现场证据**(主人报的是「重启后端」,那是 (a)),按「先修有证据的」
|
||||
* 不动它;
|
||||
* - 两者叠加不会互相破坏(都是幂等收口),真要做时**先做 (a)、再把它当第二级**即可。
|
||||
*
|
||||
* ## 「第一次连接」不算重建
|
||||
* 冷启动的 `Idle → Connecting → Connected` 必须**一个字节都不收口**,两条路都会踩到:
|
||||
* - **刚进会话的 VM**:它订阅时连接可能**已经是** Connected,于是它看到的第一次 Connected 与
|
||||
* `history` / `turn_begin` / `steer_ack` 那一批首帧**几乎同时**到达(帧走 `socket.events`、
|
||||
* 状态走 `socket.state`,两条流各自投递)。这时候本端手里可能**已经有**一条正当地灰着的气泡
|
||||
* (另一台设备起的那一轮),把它当成重建收口就是**在用户刚打开会话的瞬间**凭空把那一轮说成已答;
|
||||
* - **冷启动**:`Idle → Connecting → Connected` 是每一个会话的第一跳,与「后端重启过」毫无关系。
|
||||
*
|
||||
* 所以判据必须是「**曾经**到过 Connected 之后又回到 Connected」,而不是「这一次是不是 Connected」。
|
||||
*/
|
||||
internal fun isReconnect(previous: WsState, current: WsState, everConnected: Boolean): Boolean {
|
||||
// 不是「接上了」,谈不上重建(Connecting / Reconnecting / Offline / Idle 都不收口)。
|
||||
if (current != WsState.Connected) return false
|
||||
// 本 VM 见过 Connected ⇒ 这一次接上必然是**接回来**。这一条是主判据,而且**不受状态流合流影响**
|
||||
// (理由与「为什么是一位记忆而不是看上一跳」见 `ChatViewModel.everConnected` 的 KDoc)。
|
||||
if (everConnected) return true
|
||||
// 没记过账时的第二证据:上一跳是**重连退避**(`ChatSocket.runConnectionLoop` 只在断开之后写
|
||||
// `Reconnecting`;首次握手走的是 `Connecting`)。这一档只在一处是真的:**本 VM 是在离线期间
|
||||
// 建起来的**(进次级会话时连接就是断的)⇒ 它自己的第一次 Connected 确实发生在一次重建之后。
|
||||
// 它手里那时**什么都没有**(没有 turn / 没有流式路 / 没有 busyMode),所以 [settledAfterReconnect]
|
||||
// 对它是恒等变换——判在这里不会误伤任何东西,反过来说也不必为它加第二条口径。
|
||||
return previous == WsState.Reconnecting
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接重建时把本端「进行中」的五个面一次性收口(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* 收口的是**服务端那一轮留下来的本端投影**,一件一件说清它为什么该收:
|
||||
* - [ChatUiState.busyMode]:「正在思考…」是**服务端报的**事实(`session_busy`)。旧连接没了,
|
||||
* 这个事实就**不可知**了;继续显示它等于替服务端保证一件已经问不到的事;
|
||||
* - [ChatUiState.activeTurns]:同上(「并行 N 路」);归 `0` = 「不知道」,正是
|
||||
* [ChatUiState.activeTurns] 文档里那个「不知道」的取值;
|
||||
* - [ChatUiState.queueDepth]:「已排队 N 条」也是服务端队列的事实,与 `session_idle` / 被打断
|
||||
* 那两处的处置**逐字一致**(那两处也是 `busyMode = null, queueDepth = 0` + 全部收口);
|
||||
* - [ChatUiState.turns] ⇒ `allClosed()`:未收口的绑定全部标 `closed`(气泡 ④ → ⑤);
|
||||
* - [ChatUiState.streams] ⇒ `allFinished()`:每一路停止生长(思维链不再「思考中」、工具卡不再
|
||||
* 「执行 X…」),**正文一个字都不删**。
|
||||
*
|
||||
* ## 收口之后**不许**碰的东西(可降级;也是本函数的红线)
|
||||
* [ChatUiState.messages] / [ChatUiState.pending] / [ChatUiState.queuedKeys] /
|
||||
* [ChatUiState.queuedForConnection] / 草稿与附件 / [ChatUiState.error] / [ChatUiState.sessionGone]
|
||||
* **一律原样**:收口只改「进行中的外观」,**不删消息、不整表重拉、不给用户多加一条横幅**。
|
||||
* 绑定表本身也**不删**(只标 `closed`)——并行路角标(`legBadgeForMessage`)靠它,删了等于事后
|
||||
* 篡改历史。于是写死一条纪律:**收口是可降级的**,最坏的结果只是「少灰一会儿」。
|
||||
*
|
||||
* ## 「闪动」:收口会不会被随后到达的帧翻回进行中(如实回答)
|
||||
* - 灰态**不会**:[top.yeij.cyrene.domain.chat.TurnBinding.closed] 是**单调**的既成事实
|
||||
* (`withTurnBegin` 合并时也**保持原值**,见那里第 2 条),所以「已答」之后不会退回「回答中」。
|
||||
* - 流式卡片**会**:那一帧正文/思维链真的又来了,`withLeg` 就会把它重新变成 `active` /
|
||||
* `thinkingActive` —— 这是**如实**的(它确实又在生成了),而且是**自愈**的:正文不会因为
|
||||
* 本端收过一次口而丢字(收口不删内容,后续 delta 照旧追加到同一路)。
|
||||
* - `busyMode` **会**:服务端下一次 `session_busy` 一到就恢复。
|
||||
*
|
||||
* 也就是说:**代价落在「服务端那一轮其实还活着」(只是连接抖动过)这一档**——本端会提前一点点
|
||||
* 把气泡显示成已答。这个代价是刻意选的,因为它比另一个方向小得多:今天的行为(永远灰着、
|
||||
* 卡片永远转、底栏永远说她在思考、跟随底部永远失效)是**永久坏掉**,且用户唯一的出路是退出重进。
|
||||
*
|
||||
* ## 一条如实登记的行为变化
|
||||
* 清掉 [ChatUiState.busyMode] 会**连带**改变下一次发送的声明:`ChatViewModel.emitAndDeliver` 用
|
||||
* `busyMode != null` 算 `interruptScopeForSend` / `parallelForSend`(`ChatViewModel.kt:1793`)。
|
||||
* 于是「她忙时发消息」偏好选「打断」的那一档,在重建后的**第一次**发送不再先发 `interrupt`。
|
||||
* 如实说:连接重建之后本端**确实不知道**那一轮还在不在,而不打断的后果(这一条被服务端排在后面
|
||||
* 或并行处理)比对着一个已经不存在的回合发 `interrupt` 小得多。服务端下一次报 `session_busy`
|
||||
* 就恢复原样。
|
||||
*/
|
||||
internal fun ChatUiState.settledAfterReconnect(): ChatUiState {
|
||||
val closedTurns = turns.allClosed()
|
||||
val finishedStreams = streams.allFinished()
|
||||
// 没有任何「进行中」可收时返回**同一个实例**:每次「接回来」都会调它一次,而无变化时制造新
|
||||
// 实例会让 StateFlow 白白发一轮(也让测试里的 `assertSame` 失去意义)。
|
||||
if (closedTurns === turns && finishedStreams === streams &&
|
||||
busyMode == null && activeTurns == 0 && queueDepth == 0
|
||||
) {
|
||||
return this
|
||||
}
|
||||
return copy(
|
||||
busyMode = null,
|
||||
activeTurns = 0,
|
||||
queueDepth = 0,
|
||||
turns = closedTurns,
|
||||
streams = finishedStreams,
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,12 +24,18 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
import top.yeij.cyrene.domain.share.SharedDocument
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
@@ -253,6 +260,17 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
|
||||
* 输入框与按钮原地不动。上限按状态取([sharePreviewMaxHeight]):收起态
|
||||
* [SHARE_PREVIEW_MAX_HEIGHT]、用户展开后 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
|
||||
*
|
||||
* ## 正文走**与气泡同一套解析**(W65),所以这一窗里排的是「气泡块」而不是一段原样的字
|
||||
* 正文不是原样铺进去的:它先过 [top.yeij.cyrene.domain.parse.parse]——与 `MessageBubble` 里那条
|
||||
* 完全同一个函数、同一个口径([sharePreviewBlocks] 只是给结果加了一道兜底)。于是在这里看到的就是
|
||||
* 发出去之后气泡里的样子:每个 `<p>…</p>`(无标签时按空行分段)是**一个独立的块**,
|
||||
* `<action>` / `<narration>`(以及星号那几种写法)不当正文、而是**灰斜体小字**的旁白块,标签本身
|
||||
* 一个字符都不显示。
|
||||
*
|
||||
* 这一层修掉的是 W61 审查发现的一处「所见非所发」:预览此前把正文 `trim` 之后原样显示、**不过解析
|
||||
* 器**,而发送后的气泡过解析器——分享一段 `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩
|
||||
* 「正文」**。用户按发送之前看到的不是他将要发出的东西。
|
||||
*
|
||||
* ## 正文给三行,但三行之外是「滚」不是「省略号」
|
||||
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
|
||||
* 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短
|
||||
@@ -265,10 +283,10 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
|
||||
*
|
||||
* ## 三行读不下时,再给一次「展开」
|
||||
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行**
|
||||
* (W56 拍板:滚动与展开**两个都要**)。所以正文排下来超过三行时(判据是
|
||||
* [sharePreviewTextOverflowsWindow],正好三行不给)卡片右上一枚「展开」把这一窗放到
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。没超过三行时这一枚**不出现**——
|
||||
* 短正文上摆一枚点了没反应的「展开」比没有更糟。
|
||||
* (W56 拍板:滚动与展开**两个都要**)。所以这一窗**装不下**里面那几块时(判据是
|
||||
* [sharePreviewTextOverflowsWindow]:量出来的内容自然高度比窗口高,正好放满不给)卡片右上一枚
|
||||
* 「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。装得下时这一枚
|
||||
* **不出现**——短正文上摆一枚点了没反应的「展开」比没有更糟。
|
||||
*
|
||||
* 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限,
|
||||
* 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。
|
||||
@@ -316,8 +334,8 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
|
||||
}
|
||||
if (preview.hasText) {
|
||||
// 「超没超过三行」只有排完才知道(折行位置由字号与系统的大字体缩放决定),所以它由正文
|
||||
// 本体量出来之后回调上来,标签这一行凭它决定要不要给出展开控件。
|
||||
// 「装不装得下」只有排完才知道(折行位置由字号与系统的大字体缩放决定,块数与旁白行数更是
|
||||
// 解析出来的),所以它由正文那一窗量出来之后回调上来,标签这一行凭它决定要不要给出控件。
|
||||
var bodyOverflows by remember(preview.text) { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -385,20 +403,70 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 相邻两个气泡块之间的间距(见 [ShareConfirmBody])。块与块靠这一层间距分开,不靠气泡底色。 */
|
||||
private val SHARE_PREVIEW_BLOCK_GAP = 6.dp
|
||||
|
||||
/** 一个块里旁白行与正文行之间的间距(气泡里那两行也挨得很近,与 `AnnotationLine` 的 2dp 同源)。 */
|
||||
private val SHARE_PREVIEW_LINE_GAP = 2.dp
|
||||
|
||||
/**
|
||||
* 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
|
||||
* 用户点了「展开」之后这一窗撤掉自己的上限,整段正文交给外层预览区那一层滚(W56)。
|
||||
* 正文那一窗:**高度恒为三行、再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
|
||||
* 用户点了「展开」之后这一窗撤掉自己的上限,全部内容交给外层预览区那一层滚(W56)。
|
||||
*
|
||||
* 里面排的**不是一段原样的字**,而是 [sharePreviewBlocks] 给出的那几个「气泡块」(W65 起走与
|
||||
* `MessageBubble` 同一个 [parse])。每个块自己排旁白行与正文行,块与块之间留
|
||||
* [SHARE_PREVIEW_BLOCK_GAP]。
|
||||
*
|
||||
* ## 为什么量行高,而不是写一个 dp 常量
|
||||
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
|
||||
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
|
||||
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
|
||||
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
|
||||
* 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。
|
||||
* 所以这里改为**量**:任一排版结果都能给出「一行多高」(`getLineBottom(0) - getLineTop(0)`),
|
||||
* 窗口高度 = 它 × [SHARE_PREVIEW_TEXT_LINES]([sharePreviewWindowHeight])。
|
||||
*
|
||||
* 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
|
||||
* 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
|
||||
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,高度不受限,两种状态下排出来的行位置一样)。
|
||||
* **为什么不再取「第三行的底边」**(那是 W65 之前的量法):那时这一窗里只有一个 `Text`,
|
||||
* `getLineBottom(2)` 就是那一个文本的三行底。现在里面是**多个块**,每块还有自己的旁白行与间距——
|
||||
* 在任何一个块上取「第三行」都不再是这个窗口的第三行。行高则是唯一一个跨块也成立、且与块数无关的
|
||||
* 量:窗口恒为「三行字高」,块与旁白占掉多少就从这三行里吃掉多少(这正是它的语义)。
|
||||
*
|
||||
* ## 「装不下」是**量**出来的,不是**数**出来的(W65 换掉的判据)
|
||||
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > 3`)。多块之后行数不再是一个数得出来的量:
|
||||
* 旁白行与块间距都占高度,却不属于任何一块的「行数」;字号缩放与折行位置也全落在行数之外。按行数算,
|
||||
* 两块各三行加起来是 6、窗口只装得下 3 行——这条能对,但「一块三行 + 一条旁白 + 一层间距」这种更常见
|
||||
* 的形状就数不准了,而这恰是解析之后最常见的形状。
|
||||
*
|
||||
* 所以现在比的是**高度**:内容排下来的自然高度 vs 这一窗的高度([sharePreviewTextOverflowsWindow])。
|
||||
* 两个量都是排完才知道的:
|
||||
* - 窗口高由上面的行高乘三给出;
|
||||
* - 自然高由 [androidx.compose.ui.layout.onSizeChanged] 读到——它挂在 `verticalScroll` **里面**,
|
||||
* 而 `verticalScroll` 给子项的是无界高度,所以读到的是内容的**自然高度**(不受这一窗上限影响的那
|
||||
* 一个,收起态与展开态读到的是同一个数)。
|
||||
*
|
||||
* 「自然高度 > 窗口高度」就是「窗里还有东西没露出来」,正是那枚「展开」承诺的事;旁白、块间距、折行、
|
||||
* 系统大字体缩放全被算在同一把尺子上——它可自证的地方在于:两边都是这一帧真实排出来的像素,没有一处
|
||||
* 是我们估的行数或猜的 dp。
|
||||
*
|
||||
* ⚠️ 这个比较在**组合期**算完、由 [SideEffect] 报上去,**不**在排版回调里算:两个量都来自排版回调,
|
||||
* 而在回调里读到的是**上一帧**组合里的值(`contentHeight` 与 `cap` 都不在本帧的组合里),在那里直接比
|
||||
* 会永远差一帧——首帧报一次「不超窗」之后就再也不会报,长正文上那枚「展开」永远不出现。组合期读到的
|
||||
* 一定是最新的一帧,代价只是一次幂等的重复上报([SideEffect] 每次组合都跑,值没变就是空写)。
|
||||
*
|
||||
* ## ⚠️ 这一层绝不能拿到 [Dp.Infinity](W58 修的崩溃)
|
||||
* `heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` 一旦在**无界高度**下被测量,
|
||||
* Compose 的 `checkScrollableContainerConstraints` 会**直接抛 `IllegalStateException` 把 App 打崩**
|
||||
* (真机复现:分享进来、面板刚打开就闪退,栈是 `ScrollNode.measure` ← `SizeNode.measure` ← `DialogLayout`)。
|
||||
*
|
||||
* 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它给出的**恒是一个有限值**;
|
||||
* 而「这一层干脆不设上限」只发生在展开态——那时它连 `verticalScroll` 一起去掉(见下一节),
|
||||
* 于是「无界高度 + 滚动」这个组合在两种状态下都不成立:
|
||||
* - **首帧**(还没量到)用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 有限,且正好是收起态外层预览区的上限;
|
||||
* - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层
|
||||
* 有限的约束在管事(不是「没有约束」)。
|
||||
*
|
||||
* 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,块的内容、宽度
|
||||
* 与样式都没变,行高与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
|
||||
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行高一样)。
|
||||
*
|
||||
* ## 展开态为什么干脆不留这一层上限
|
||||
* 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
|
||||
@@ -407,16 +475,19 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。
|
||||
* 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。
|
||||
*
|
||||
* ## 首帧的那次回流:留着,且确认它看不见(W56 复核)
|
||||
* 上限是**首帧之后**才量到的,所以长正文会先按没有上限排一次,下一帧才收到三行。上一批留了个悬念:
|
||||
* 真机上会不会看见一次跳动。复核的结论是**看不见**,两条理由:
|
||||
* ## 首帧的那次回流:留着,且确认它看不见
|
||||
* 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次,
|
||||
* 下一帧才收到三行。真机上**看不见**跳动,三条理由:
|
||||
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
|
||||
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
|
||||
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
|
||||
* 2. 加展开态**不会把它变明显**:展开态本来就没有这一层上限(见上),也就没有「量完再回落」这一步;
|
||||
* 而展开 / 收起是用户主动的一次布局变化,那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的
|
||||
* 是展开控件本身——它要到第二帧才出现(行数是排完才知道的),但这一枚也顶不动东西:标签行里那
|
||||
* 两句话用的是同一个 `footnote2`,行高不变。
|
||||
* 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化,
|
||||
* 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现
|
||||
* (自然高度是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,
|
||||
* 行高不变。
|
||||
* 3. 首帧那一次比出来的是「自然高度 vs 150dp 兜底」而不是「vs 三行」:那一帧的结论**可能是错的**
|
||||
* (一段四五行的正文在 150dp 下看着「装得下」),但它在下一帧就被纠正,而这一枚控件本来就要到
|
||||
* 第二帧才可能摆上——所以这条错判永远看不见。
|
||||
*
|
||||
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
|
||||
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
|
||||
@@ -425,8 +496,13 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
* ## 两层滚动怎么分工
|
||||
* 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
|
||||
* 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
|
||||
* 就走嵌套滚动,不用额外接线。展开态下这一窗没有可滚的余量(`heightIn` 给了 [Dp.Infinity]),
|
||||
* 手势整个交给外层。
|
||||
* 就走嵌套滚动,不用额外接线。
|
||||
*
|
||||
* ⚠️ **展开态必须把这一层整个去掉(上限 + 滚动一起),只留外层那一层滚。** 只去掉上限是不够的 ——
|
||||
* 外层 [ShareConfirmPreviews] 自己就是一个 `verticalScroll`,它给内容的是**无界高度**;这一层若
|
||||
* 留着 `verticalScroll`,那个 scroll 就会在无界高度下被测量 ⇒ 抛同一个
|
||||
* `IllegalStateException`(W58 真机在「点展开」的那一下崩了第二次,就是这么来的)。
|
||||
* 展开后读不读得到尾,只取决于外层那一层。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmBody(
|
||||
@@ -435,33 +511,124 @@ private fun ShareConfirmBody(
|
||||
onOverflowChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var cap by remember(text) { mutableStateOf(Dp.Infinity) }
|
||||
val blocks = remember(text) { sharePreviewBlocks(text) }
|
||||
// `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。
|
||||
var cap by remember(text) { mutableStateOf<Dp?>(null) }
|
||||
// 内容排下来的**自然高度**(见 KDoc:量在 `verticalScroll` 里面,拿到的是不受上限影响的那一个)。
|
||||
var contentHeight by remember(text) { mutableStateOf(0) }
|
||||
// 无条件 `remember`,避免在分支里建状态;滚动位置由这一窗自己的生命周期管。
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
val window = if (expanded) {
|
||||
// 展开态:上限与滚动**一起**去掉(见 KDoc)。内容自然撑开,交给外层预览区那一层滚。
|
||||
Modifier
|
||||
} else {
|
||||
Modifier
|
||||
.heightIn(max = shareConfirmWindowCap(cap))
|
||||
.verticalScroll(scroll)
|
||||
}
|
||||
|
||||
// 一行有多高:正文行与旁白行同为 `footnote1`,所以第一个排完的文本就能给(不限是哪一块的哪一行)。
|
||||
// 量到一次就定死——`cap` 只在这一个分支里被写、且只从 `null` 变成一个值,不会来回跳。
|
||||
val measureLine: (TextLayoutResult) -> Unit = { layout ->
|
||||
if (cap == null && layout.lineCount > 0) {
|
||||
val line = layout.getLineBottom(0) - layout.getLineTop(0)
|
||||
// 0 或负数(还没排稳)不记:150dp 的兜底比一扇零高的窗好(同 [shareConfirmWindowCap] 的取向)。
|
||||
if (line > 0) cap = sharePreviewWindowHeight(with(density) { line.toDp() })
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// 展开态给 [Dp.Infinity] = 这一层没有上限;这正是**首帧那个状态**(`cap` 一开始就是它),
|
||||
// 所以展开态不多一条代码路径,也不会比首帧更糟。
|
||||
.heightIn(max = if (expanded) Dp.Infinity else cap)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.then(window)
|
||||
.onSizeChanged { contentHeight = it.height },
|
||||
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_BLOCK_GAP),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
onTextLayout = { layout ->
|
||||
if (cap == Dp.Infinity && layout.lineCount > 0) {
|
||||
// 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。
|
||||
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
|
||||
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
|
||||
}
|
||||
// 行数与上限无关(这一窗没有 `maxLines`、测量高度不受限,排一次行数就定了),所以
|
||||
// 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。
|
||||
onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount))
|
||||
},
|
||||
)
|
||||
blocks.forEach { block -> ShareConfirmBlock(block, measureLine) }
|
||||
}
|
||||
|
||||
// 见 KDoc:在组合期算完、由 SideEffect 报上去(两个量在排版回调里读到的是上一帧的值)。
|
||||
val overflows = sharePreviewTextOverflowsWindow(
|
||||
contentHeightPx = contentHeight,
|
||||
windowHeightPx = with(density) { shareConfirmWindowCap(cap).roundToPx() },
|
||||
)
|
||||
SideEffect { onOverflowChange(overflows) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览里的**一个气泡块**:上依附的旁白 + 正文 + 下依附的旁白。
|
||||
*
|
||||
* 这个顺序就是 `MessageBubble` 里那个 `Bubble` 的顺序(`head` → 正文 → `tail`),用的也是同一个
|
||||
* [parse] 给出的同一批块——「预览里长这样」与「发出去之后长那样」因此是**同一套判据**的两次渲染。
|
||||
*
|
||||
* 块与块之间只留一层间距(外层 [ShareConfirmBody] 的 `spacedBy`),**不画气泡底色**:这一栏是面板里
|
||||
* 的一张卡片(`surfaceContainer` 底),而气泡底色在聊天里表示「谁说的」——用户那条是 `primary` 色、
|
||||
* 她那侧是灰。在确认面板里复刻一层假气泡底色会让人以为这里已经是聊天记录;而且那层内边距要从三行窗
|
||||
* 里再吃掉近一行,正是 W54D 之后最不该动的那点可见正文。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmBlock(block: ParsedBubble, onTextLayout: (TextLayoutResult) -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_LINE_GAP),
|
||||
) {
|
||||
ShareConfirmAnnotation(block.head, onTextLayout)
|
||||
if (block.body.isNotEmpty()) {
|
||||
Text(
|
||||
text = block.body,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
}
|
||||
ShareConfirmAnnotation(block.tail, onTextLayout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览里的旁白行:**灰斜体小字**——与气泡里那行(`MessageBubble` 的 `AnnotationLine`)同一个形态、
|
||||
* 同一种拼法(多个片段用一个空格连起来),字全是 [parse] 给的那几个 `ContentPart` 的原文。
|
||||
*
|
||||
* ⚠️ 这里**不**读 `LocalAppPrefs.showNarration`(气泡那行会读)。那个偏好管的是**聊天记录里**要不要
|
||||
* 显示旁白,而这块面板回答的是「她将收到的是什么」:按那个偏好把旁白藏起来,就等于让用户在按下发送
|
||||
* 之前看不到这次要发出去的一部分内容——而 W61 审查发现的那处「预览里是标签、气泡里只剩正文」,错的
|
||||
* 正是同一件事(看到的不是要发的)。要改成与气泡一样随时可以,但那是主人拍板的事,不在这里顺手做。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmAnnotation(parts: List<ContentPart>, onTextLayout: (TextLayoutResult) -> Unit) {
|
||||
val text = parts.joinToString(" ") { it.text }.trim()
|
||||
if (text.isEmpty()) return
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **收起态**那一窗的高度上限(**纯函数**,可单测)。
|
||||
*
|
||||
* 量到过就用量的,还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 兜底必须是**有限值**,因为
|
||||
* `heightIn` 与 `verticalScroll` 就在同一串 modifier 上。
|
||||
*
|
||||
* 展开态**不走这里**:那一态连 `verticalScroll` 一起去掉(只留外层预览区那一层滚),没有「上限」
|
||||
* 这个维度可给 —— 见 [ShareConfirmBody] 的 KDoc。
|
||||
*
|
||||
* ⚠️ 返回值**永远是一个有限值** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而
|
||||
* `verticalScroll` 在无界高度下被测量时 Compose 会直接抛 `IllegalStateException` 把 App 打崩
|
||||
* (W58 真机崩过两次:一次是初值用了 `Dp.Infinity`,一次是展开态只去掉了上限)。这条不变量有单测钉着。
|
||||
*
|
||||
* 所以非有限的那几个取值([Dp.Infinity] 与 [Dp.Unspecified],后者是 `NaN`)**与「还没量到」同等对待**,
|
||||
* 一律回落 [SHARE_PREVIEW_MAX_HEIGHT]:这条 KDoc 写的是一条**判据**,而不该只是对调用方的请求——本函数
|
||||
* 是这一层唯一的出口,它自己要能保证「给出去的上限总是有限」。今天唯一的调用方传的是 `null` 或
|
||||
* [sharePreviewWindowHeight] 的结果(正数 × 三,恒有限),所以这一道守卫不改变任何现状;它挡的是
|
||||
* **下一次**有人顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。
|
||||
*/
|
||||
internal fun shareConfirmWindowCap(measured: Dp?): Dp =
|
||||
measured?.takeIf { it.value.isFinite() } ?: SHARE_PREVIEW_MAX_HEIGHT
|
||||
|
||||
/** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
||||
internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
||||
|
||||
@@ -486,8 +653,7 @@ internal val SHARE_PREVIEW_EXPANDED_MAX_HEIGHT = 240.dp
|
||||
/**
|
||||
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
|
||||
*
|
||||
* 它同时是「要不要给出展开控件」的那条线([sharePreviewTextOverflowsWindow]):正好这么多行就没有
|
||||
* 东西可展开。
|
||||
* 它同时是窗口高度那笔账里唯一的数:窗口高 = **量到的行高** × 这个数([sharePreviewWindowHeight])。
|
||||
*
|
||||
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
|
||||
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
|
||||
@@ -504,18 +670,67 @@ internal const val SHARE_PREVIEW_EXPAND_LABEL = "展开"
|
||||
internal const val SHARE_PREVIEW_COLLAPSE_LABEL = "收起"
|
||||
|
||||
/**
|
||||
* 正文**排下来**超过三行窗口了吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
|
||||
* 三行窗口有多高(**纯函数**,可单测):量到的一行高度 × [SHARE_PREVIEW_TEXT_LINES]。
|
||||
*
|
||||
* 判据是**行数**,不是 `hasVisualOverflow`:正文这一窗的裁剪落在它外面那层 `Column` 的
|
||||
* `heightIn(max = cap)` 上,而这一窗自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的
|
||||
* 就是无上限约束),所以 `Text` 自己**永远**报「没有溢出」——拿它当判据会在最长的那种正文上漏掉
|
||||
* 控件。行数则与上限无关:只要排下来比窗口多一行,第四行就一定在窗口之外,展开就有东西可看。
|
||||
*
|
||||
* 边界是**严格大于**:正好三行时整段都在窗里(`getLineBottom(2)` 就是这一窗的底),此时给一枚
|
||||
* 「展开」是点了没有反应的假控件。
|
||||
* 行高只能是**量**出来的(见 [ShareConfirmBody] 的「为什么量行高,而不是写一个 dp 常量」),所以这里
|
||||
* 只做乘法、不掺任何 dp 常量:大字体档下量到的行高更大,窗口就跟着更高,「三行」始终是三行。把这个数
|
||||
* 留在唯一一处,改它只改 [SHARE_PREVIEW_TEXT_LINES]。
|
||||
*/
|
||||
internal fun sharePreviewTextOverflowsWindow(lineCount: Int): Boolean =
|
||||
lineCount > SHARE_PREVIEW_TEXT_LINES
|
||||
internal fun sharePreviewWindowHeight(lineHeight: Dp): Dp =
|
||||
lineHeight * SHARE_PREVIEW_TEXT_LINES
|
||||
|
||||
/**
|
||||
* 预览正文区该渲染成哪几块(**纯函数**,可单测)——**走的就是气泡那一套解析**(W65 起)。
|
||||
*
|
||||
* 用的是 `MessageBubble` 里那同**一个** [parse],连兜底都是同一条:解析结果为空(或全是被丢掉的空块)
|
||||
* 时回落到「整段原样显示」的那一块,与那边 `parsed.ifEmpty { listOf(plainBubble(content)) }` 逐字同义。
|
||||
* 所以「预览里看不到那一串标签」不是本函数自己剥出来的,而是 [parse] 的既有语义(落单标签一律剥掉、
|
||||
* 完整块切气泡、旁白另成一类)——这里一个字符都不改写、也不另写一套「去标签」。
|
||||
*
|
||||
* **兜底不是可选项**:`parse` 对某些输入会回空列表(纯空白是既有的一档,落单标签被剥光是另一档,
|
||||
* 见 `ShareTextPipelineTest` 里的 `<p><p>` 那几条),而面板的 [ShareConfirmPreview.hasText] 只管
|
||||
* 「正文非空白」,管不到解析结果。少了这条兜底,用户就会在一块**空白**前面按发送——而正文这一栏是他按
|
||||
* 发送之前唯一能核对的东西。
|
||||
*
|
||||
* 正文在这里**不再 trim**:两侧空白早在 [shareConfirmPreview] 那一站去过一次(`share.text.trim()`,
|
||||
* 与 `sendShared` 开头同一个口径),这里再来一次只会多一个说法不一的出处。
|
||||
*/
|
||||
internal fun sharePreviewBlocks(text: String): List<ParsedBubble> =
|
||||
parse(text).filterNot { it.isEmpty }.ifEmpty {
|
||||
listOf(ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList()))
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一窗**装不下**里面的内容吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
|
||||
*
|
||||
* ## 判据是**高度**,不是行数(W65 换掉的)
|
||||
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > SHARE_PREVIEW_TEXT_LINES`)。正文改走解析
|
||||
* 之后这一窗里是**多个块**,每块还各自带旁白行与块间距,于是行数不再是一个覆盖得住窗口的量:
|
||||
*
|
||||
* - 旁白行与块间距都占高度,却谁也不属于哪一块的「行数」;
|
||||
* - 折行位置、字号与系统大字体缩放都落在行数之外:「两个块各占两行、中间一层间距」这种最常见的形状,
|
||||
* 按行数只能说「4 > 3」,说不出它到底差多少,也说不出一块内部的折行吃掉了第二块的位置;
|
||||
* - 反过来也漏:「一块两行 + 一条两行的旁白」数出来是 4 行,但它真正超出的高度取决于那两层间距与
|
||||
* 旁白自己的行高——行数这个量从头到尾没有把它们算进去。
|
||||
*
|
||||
* 所以比的是**两边真实排出来的像素**:内容自然高度(由挂在 `verticalScroll` 里面的
|
||||
* [androidx.compose.ui.layout.onSizeChanged] 读到,见 [ShareConfirmBody])vs 这一窗的高度
|
||||
* ([sharePreviewWindowHeight] 那个口径:量到的行高 × 三)。「自然高度 > 窗口高度」**就是**「窗里还有
|
||||
* 东西没露出来」,正是那枚「展开」承诺的事。它可自证的地方在于:两个量都是这一帧真实排出来的像素,
|
||||
* 没有任何一处是我们估的行数或猜的 dp。
|
||||
*
|
||||
* 也不是 `hasVisualOverflow`:裁剪落在这一窗外面那层 `Column` 的 `heightIn(max = cap)` 上,而每个
|
||||
* `Text` 自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的就是无上限约束),所以它们永远报
|
||||
* 「没有溢出」——拿它当判据会把控件在最长的那种正文上漏掉。
|
||||
*
|
||||
* ## 边界与首帧
|
||||
* 严格大于:**正好一样高**就是整段都在窗里(窗口底下一条不多),此时给一枚「展开」是点了没有反应的
|
||||
* 假控件。
|
||||
*
|
||||
* 任何一边还没量到(`0`)都回 `false`:首帧摆上一枚点了没有反应的「展开」比晚一帧出现更糟。
|
||||
*/
|
||||
internal fun sharePreviewTextOverflowsWindow(contentHeightPx: Int, windowHeightPx: Int): Boolean =
|
||||
contentHeightPx > 0 && windowHeightPx > 0 && contentHeightPx > windowHeightPx
|
||||
|
||||
/**
|
||||
* 预览区当前该给多高(**纯函数**,可单测):收起态 [SHARE_PREVIEW_MAX_HEIGHT]、展开态
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.domain.share.ShareTextSource
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
|
||||
/**
|
||||
* 「这段正文是从对方给的**分享标题**回落来的」——面板上那句如实的小字(**纯函数**,可单测)。
|
||||
*
|
||||
* ## 它补的是哪一处
|
||||
* 取正文的回落链([top.yeij.cyrene.domain.share.ShareRules.extract]:`EXTRA_TEXT` →
|
||||
* `EXTRA_SUBJECT` → `EXTRA_TITLE`)让「对方只给了标题」的那次分享**不再静默**——用户可以确认、可以
|
||||
* 取消。但回落来的那一段与真正文**长得一模一样**:预览里只有一句话时,用户会以为对方发来的就是这句
|
||||
* 正文,其实本端只拿到了标题。所以这里给出一句**只出现在界面上**的说明。
|
||||
*
|
||||
* ## 红线:它绝不进正文
|
||||
* 这句话是**本端渲染层**的信息,一个字都不许拼进将要发送的那段正文(`shareConfirmBody` 拼的是分享
|
||||
* 正文 + 用户描述,与这里无关):把来源标记拼进正文 = 让用户正文冒充系统指令,是项目红线
|
||||
* (见 `ShareRules` 的「为什么正文一个字都不加」)。所以这条消息的内容**永远只是正文本身**,
|
||||
* 预览里那段字与发出去的那条消息逐字相同。
|
||||
*
|
||||
* ## 为什么 `Subject` 与 `Title` 共用一句
|
||||
* 两档在**数据**上分得很清([ShareTextSource],排查时要看得见对方把内容塞进了哪个字段),但对用户说
|
||||
* 的是同一件事:标题与主题在他眼里没有区别。各写一句只会多出一处迟早会走样的措辞。
|
||||
*
|
||||
* ## 不说「正文」的那一档为什么回 `null`
|
||||
* [ShareTextSource.Text] 是规范路径(正文本来就是正文):一切照旧,**一个字都不说**——正常落地时
|
||||
* 说话才是噪声(与 `shareNoticeFor` 的静默边界同一条纪律)。
|
||||
*
|
||||
* @return 该说的那句话;没有要说的(正文来自 `EXTRA_TEXT`)回 `null`。
|
||||
*/
|
||||
fun shareTextOriginNotice(share: SharedContent): String? = when (share.textSource) {
|
||||
ShareTextSource.Text -> null
|
||||
ShareTextSource.Subject, ShareTextSource.Title -> SHARE_TEXT_FROM_TITLE_NOTICE
|
||||
}
|
||||
|
||||
/**
|
||||
* 那句话说出口的样子。
|
||||
*
|
||||
* 两件事要在同一句里说清:**它是什么**(对方给的分享标题)与**缺了什么**(对方没有提供正文)——
|
||||
* 只说前一半,用户仍会以为这句话就是对方发来的正文;只说后一半,他不知道预览里那行字是从哪冒出来的。
|
||||
* 刻意**不说**「失败」「错误」:这一次分享的内容一个字节都没丢,只是它本来就只有标题。
|
||||
*/
|
||||
const val SHARE_TEXT_FROM_TITLE_NOTICE = "这段文字来自对方的分享标题(对方没有提供正文)"
|
||||
@@ -88,10 +88,13 @@ fun ToolCard(
|
||||
FoldCardLabel("参数")
|
||||
FoldCardBody(tool.arguments)
|
||||
}
|
||||
if (tool.output.isNotBlank()) {
|
||||
// 结果区是否出现**完全**由 [toolResultTextOf] 决定:`null` 就整块不画(连标签和
|
||||
// 上方那道 Spacer 都不画),于是「摘要缺席」时这一屏与后端补这个键之前**逐字节相同**。
|
||||
val result = toolResultTextOf(tool)
|
||||
if (result != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
FoldCardLabel("结果")
|
||||
FoldCardBody(tool.output)
|
||||
FoldCardBody(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,49 @@ fun List<StreamingLeg>.finishingLeg(
|
||||
fun List<StreamingLeg>.withoutLeg(bucket: TurnBucket): List<StreamingLeg> =
|
||||
if (none { it.bucket == bucket }) this else filterNot { it.bucket == bucket }
|
||||
|
||||
/**
|
||||
* **连接重建时收口每一路**(纯函数,[top.yeij.cyrene.ui.chat.settledAfterReconnect] 用它)。
|
||||
*
|
||||
* 与 [finishingLeg] 的关系:同一件事(这一路不再生长、思维链与工具卡不再声称自己在跑),
|
||||
* 区别只在**触发点**——[finishingLeg] 是「收到了这一路的 `stream_end`」,这里没有那一帧,
|
||||
* 是「连接重建了,而服务端那一轮在旧连接上,永远不会再有后续帧」(判据见
|
||||
* [top.yeij.cyrene.ui.chat.isReconnect],理由见它那一份 KDoc)。
|
||||
*
|
||||
* 两条纪律:
|
||||
* 1. **正文与思维链一个字都不删**——用户可能正读到一半,而这已经不是服务端能再给一次的东西;
|
||||
* 2. **没有任何可显示内容的那一路整条丢掉**:`active = true` 而什么都不画时,[StreamingBubble]
|
||||
* 会画一个只有光标的空气泡,收口它等于把它变成一个**一个像素都不画、却仍占一个列表项**
|
||||
* (也因此占着「最新一项」这个位置)的幽灵——那种路丢掉是本函数的**唯一**删除动作,
|
||||
* 而它删的是「本来就没有的东西」。
|
||||
*
|
||||
* 无变化时返回**同一个实例**(与 [finishingLeg] / `withoutLeg` 同一条纪律)。
|
||||
*/
|
||||
fun List<StreamingLeg>.allFinished(): List<StreamingLeg> {
|
||||
val next = mapNotNull { it.finishedLeg() }
|
||||
return if (next == this) this else next
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路**收口后**的样子;收口之后**什么都画不出来**时返回 `null`(整路丢掉,见 [allFinished])。
|
||||
*
|
||||
* 三件事与 [finishingLeg] 逐字同义:不再 `active`(光标停、兜底路径不再当它是新回合)、
|
||||
* 思维链不再 `thinkingActive`(标题从「思考中…」变回「思维链」),外加工具卡也一起收口
|
||||
* ([settledAfterReconnect],标题从「执行 X…」变回「工具调用 · X」)。
|
||||
*
|
||||
* ⚠️ `bubbles` / `thinking` / 工具卡的参数与输出**一个都没动**:这是「不再声称在跑」,
|
||||
* 不是「清空这一路」。
|
||||
*/
|
||||
private fun StreamingLeg.finishedLeg(): StreamingLeg? {
|
||||
val settled = state.copy(
|
||||
active = false,
|
||||
thinkingActive = false,
|
||||
tools = state.tools.settledAfterReconnect(),
|
||||
)
|
||||
val anythingToShow =
|
||||
settled.bubbles.isNotEmpty() || settled.thinking.isNotBlank() || settled.tools.isNotEmpty()
|
||||
return if (anythingToShow) copy(state = settled) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条**已落库**消息处在哪一态(六态判定,规范 §1.1)。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.failureOf
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.isLoadingFor
|
||||
import top.yeij.cyrene.domain.chat.voiceAudio
|
||||
import top.yeij.cyrene.domain.chat.voiceDurationLabel
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pause
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = 这条画的是语音形态,
|
||||
* 见 `isVoiceMessage`),不是内容。
|
||||
*/
|
||||
const val VOICE_LABEL: String = "语音"
|
||||
|
||||
/** 加载中那一句(与 [VOICE_LABEL] 同一行,替换掉时长那一格)。 */
|
||||
private const val VOICE_LOADING_LABEL = "加载中…"
|
||||
|
||||
/** 播放控件那枚图标的读屏名字 —— 同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
|
||||
const val VOICE_PLAY_DESCRIPTION: String = "播放语音"
|
||||
|
||||
/** 正在放 / 加载中时那枚图标的读屏名字(点它 = 停)。 */
|
||||
const val VOICE_STOP_DESCRIPTION: String = "停止播放语音"
|
||||
|
||||
/** 图标尺寸:与消息角标那枚「⋯」同一档,整条语音形态因此不比一条角标高多少。 */
|
||||
private val VOICE_ICON_SIZE = 18.dp
|
||||
|
||||
/**
|
||||
* 一条语音消息在**转写文本之外**的那个「语音」形态:`[播放/暂停] 语音 · 时长`。
|
||||
*
|
||||
* ## 画什么由三条契约判据决定(都在这一个函数里,不散落)
|
||||
* - **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**:本组件只有在这条消息要画语音形态时
|
||||
* 才会被调用(调用点在 [MessageBubble],判据是 `isVoiceMessage`),所以这里既不认识正文里有
|
||||
* 没有 `[语音]` 字样、也不认识 `status`——`failed` 那一态(没有可播的声音)根本不走这条路,
|
||||
* 它由 `VoiceFallbackNote` 单独画一行灰色小标;
|
||||
* - **无副本 ⇒ 不画播放控件**([voiceAudio] 回 `null`):四个键(`name`/`mime`/`size`/`url`)
|
||||
* 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示
|
||||
* (时长与副本**无关**);
|
||||
* - **缺席就不显示时长**([voiceDurationLabel] 回 `null`):绝不显示 `0:00` 冒充,也不拿
|
||||
* `size` 反算。
|
||||
*
|
||||
* ## 三处文案各自归谁
|
||||
* - **正文**(转写 / 服务端的诚实提示)由 [MessageBubble] 照常渲染,本组件一个字符都不改、
|
||||
* 也不隐藏它;
|
||||
* - [note](`status = empty` 且正文为空时那句「没有识别出文字」)由调用方用纯函数
|
||||
* `voiceNote` 算好传进来 —— 本组件不判 `status`;
|
||||
* - [playback] 里这条消息的失败提示([failureOf])**只在失败时出现一行**,颜色用
|
||||
* `colorScheme.error`:可见但不夸张(不弹窗、不占整行、不挡住正文)。
|
||||
*
|
||||
* ## 图标从哪来(查过 AAR,不是猜的)
|
||||
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚(`Close` / `Search` / `SearchCleanup` /
|
||||
* `Sidebar` / `Check` / `ArrowRight` / `ArrowUpDown`),**没有播放 / 暂停字形**;但 **extended**
|
||||
* 包里有真的 `Play` / `Pause`(`javap` 确认过 `PlayKt.getPlay(MiuixIcons)`),而本模块本来就在用
|
||||
* extended(`MessageBubble.kt` 的 `MiuixIcons.More`)。所以这里用真的字形,**不**自绘几何图形:
|
||||
* 自绘一个三角形在扫读上并不比官方图标便宜,还要自己承担「像不像播放键」的风险。
|
||||
* 官方文档写 `MiuixIcons.Xxx` 的用法对人不对包,以 AAR 为准 —— 这两枚就是 extended 包里的。
|
||||
*
|
||||
* ## 点哪儿
|
||||
* **整枚可点**(不只是那 18dp 的图标):读屏与手指都不必瞄准;图标只是「这里能按」的可见凭据
|
||||
* (与 [LegBadge] 「看得出可点」同一条思路)。
|
||||
*
|
||||
* @param note 正文之外要多说的一句(没有就传 `null`)。
|
||||
* @param playback 这条通道的当前状态(由调用方用 [rememberVoicePlayer] 那一个实例驱动)。
|
||||
* @param onToggle 用户点了这枚语音:交给上层真正去取字节 / 放 / 停(本组件不发请求)。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceMessageRow(
|
||||
voice: VoiceDto,
|
||||
messageId: String,
|
||||
playback: VoicePlaybackState,
|
||||
note: String? = null,
|
||||
alignEnd: Boolean = false,
|
||||
onToggle: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val audio = voiceAudio(voice)
|
||||
val active = playback.isActiveFor(messageId)
|
||||
val loading = playback.isLoadingFor(messageId)
|
||||
val failure = playback.failureOf(messageId)
|
||||
val duration = voiceDurationLabel(voice.durationMs)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.then(
|
||||
if (audio == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier
|
||||
.clickable(onClick = onToggle)
|
||||
// 可点的那一层(整枚)自己报「点它会发生什么」;里面的图标因此只是
|
||||
// 一笔装饰(`contentDescription = null`),读屏不会读到第二个焦点。
|
||||
.semantics {
|
||||
contentDescription = if (active) VOICE_STOP_DESCRIPTION else VOICE_PLAY_DESCRIPTION
|
||||
}
|
||||
},
|
||||
)
|
||||
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (audio != null) {
|
||||
Icon(
|
||||
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.size(VOICE_ICON_SIZE)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = VOICE_LABEL,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
when {
|
||||
// 「现在在干什么」与「点它会怎样」分开说:图标回答后者,这一格回答前者。
|
||||
// ⚠️ 只有**真的在取字节**时才说「加载中…」:`active` 还包含「正在响」,
|
||||
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」。
|
||||
loading -> Text(
|
||||
text = VOICE_LOADING_LABEL,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
|
||||
duration != null -> Text(
|
||||
text = duration,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
note?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
failure?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `status == "failed"` 那条消息**没有语音形态**,但要多说的一句话(`voice` 键在、没有可播的声音)。
|
||||
*
|
||||
* ## 为什么这一行必须存在
|
||||
* 这一态的消息正文照常按文字渲染(入站是服务端那行诚实提示,出站是合成降级后的原文本)。少了这一行,
|
||||
* 一条「她本想用语音、合成降级成文字」的消息与一条普通文字消息**逐像素相同**——看的人无从知道这条
|
||||
* 本来是一段声音,也看不出它与别的话有什么不一样。所以这枚小标不是装饰,它是这一态**唯一**的可见
|
||||
* 凭据([VoiceMessageRow] 在这里根本不画,见 `isVoiceMessage`)。
|
||||
*
|
||||
* ## 样式为什么抄 [VoiceMessageRow] 里 note 那一行
|
||||
* `footnote2` + `onSurfaceVariantSummary`:与语音形态旁边那几句说明(「没有识别出文字」/ 加载中 /
|
||||
* 时长)**同一档**。这三处说的都是「关于这段声音的一句脚注」,同一个外观才让人读成一类东西;
|
||||
* 换成正文那一档字级会被误读成消息内容的一部分,换成 `error` 又会把一次**降级**说成一次**失败**
|
||||
* ——降级本身是服务端做对的一件事(她的话一个字没丢,只是没变成声音)。
|
||||
*
|
||||
* ## 不判 `status`,也不拼文案
|
||||
* [text] 由调用方用纯函数 `voiceFallbackNote` 算好传进来(固定前半句 + 服务端 `fallbackReason` 原样)。
|
||||
* 本组件只负责画:判据与措辞都在 domain 层单测钉着,这里再判一次就多出「两处判据不一致」的可能
|
||||
* ——与 [VoiceMessageRow] 对 `note` 的处理同一条纪律。
|
||||
*
|
||||
* @param text 已经算好的那一整句(本组件不改一个字符)。
|
||||
* @param alignEnd 用户侧(气泡在右):整行贴右;否则贴左。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceFallbackNote(
|
||||
text: String,
|
||||
alignEnd: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.failureOf
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.isLoadingFor
|
||||
import top.yeij.cyrene.domain.chat.voiceElapsedLabel
|
||||
import top.yeij.cyrene.domain.chat.voiceReplyNote
|
||||
import top.yukonga.miuix.kmp.basic.Button
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.extended.Mic
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pause
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 输入栏那几个**语音**形态(录音中 / 待重发 / 她的语音回复),以及乐观气泡里那一枚「语音」。
|
||||
*
|
||||
* ## 为什么单独一个文件
|
||||
* 它们四个共用同一套尺寸与配色,而这个文件之外没人需要知道这些细节:`ChatScreen` 只负责接线,
|
||||
* `MessageBubble` 只调用最后一枚。放在一起,改一眼就能看全。
|
||||
*
|
||||
* ## 图标从哪来(查过 AAR,不是猜的)
|
||||
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚,**没有麦克风**;**extended** 包里有真的
|
||||
* `Mic` / `MicSlash`(`javap` 确认过 `MicKt.getMic(MiuixIcons)`),而本模块本来就在用 extended
|
||||
* (`VoiceBubble.kt` 的 `Play` / `Pause`、`ChatScreen.kt` 的 `File` / `Image`)。
|
||||
*
|
||||
* ## 读屏名字即设备验收的 `content-desc`
|
||||
* 四枚可点控件的 `contentDescription` 都抽成了常量([VOICE_RECORD_DESCRIPTION] 等):
|
||||
* `uiautomator dump` 靠它们定位,改文案等于改验收口径,两处一起改。
|
||||
*/
|
||||
|
||||
/** 「开始录音」那枚麦克风的读屏名字。 */
|
||||
const val VOICE_RECORD_DESCRIPTION: String = "按住说话(点按开始录音)"
|
||||
|
||||
/** 录音中那枚麦克风(点它 = 结束并发送)。 */
|
||||
const val VOICE_RECORD_STOP_DESCRIPTION: String = "结束录音并发送"
|
||||
|
||||
/** 录音中的「取消」。 */
|
||||
const val VOICE_RECORD_CANCEL_DESCRIPTION: String = "取消这段录音"
|
||||
|
||||
/** 待重发那一段的「重发」。 */
|
||||
const val VOICE_DRAFT_RETRY_DESCRIPTION: String = "重发这段语音"
|
||||
|
||||
/** 待重发那一段的「丢弃」。 */
|
||||
const val VOICE_DRAFT_DISCARD_DESCRIPTION: String = "丢弃这段语音"
|
||||
|
||||
/** 她的语音回复的播放 / 停止。 */
|
||||
const val VOICE_REPLY_PLAY_DESCRIPTION: String = "播放她的语音回复"
|
||||
const val VOICE_REPLY_STOP_DESCRIPTION: String = "停止播放她的语音回复"
|
||||
|
||||
/** 关掉她的语音回复那一张卡片。 */
|
||||
const val VOICE_REPLY_DISMISS_DESCRIPTION: String = "关闭这条语音回复"
|
||||
|
||||
/** 乐观气泡里那枚「语音」的字样(与接收侧的 [VOICE_LABEL] 同一个词,不另造)。 */
|
||||
const val VOICE_CLIP_LABEL: String = VOICE_LABEL
|
||||
|
||||
/**
|
||||
* 麦克风**没授权**时的如实告知。
|
||||
*
|
||||
* 说「到系统设置里允许」而不是再申请一次:本端只在第一次点麦克风时申请(平台规范),
|
||||
* 被拒之后重复申请只会被系统静默挡回(Android 不再弹框),那样用户点十次都毫无反应。
|
||||
*/
|
||||
const val VOICE_PERMISSION_DENIED_MESSAGE: String =
|
||||
"没有麦克风权限,到系统设置里允许后再试"
|
||||
|
||||
/**
|
||||
* 有权限、但麦克风**现在起不来**(被别的应用占用 / 设备不支持这组采样参数)。
|
||||
*
|
||||
* 与 [VOICE_PERMISSION_DENIED_MESSAGE] 分开:那一档用户要去设置页,这一档要等一会或换个应用;
|
||||
* 说成同一句就是把人引向一个解决不了问题的动作。
|
||||
*/
|
||||
const val VOICE_UNAVAILABLE_MESSAGE: String = "麦克风现在打不开,可能被别的应用占着"
|
||||
|
||||
/** 图标尺寸:与接收侧语音形态同一档。 */
|
||||
private val VOICE_ICON_SIZE = 18.dp
|
||||
|
||||
/** 卡片底色与圆角:与 `VoiceMessageRow` 那一枚同形(同一个产品里的语音长一个样)。 */
|
||||
private val VOICE_CARD_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
/**
|
||||
* 录音中那一行:`🎤 录音中 · 0:07 [取消] [发送]`。
|
||||
*
|
||||
* 时长由**已收到的字节数**反算(见 `VoiceRecorder`),所以屏幕上这个数字与发出去的音频长度
|
||||
* 严格一致。到 60 秒时 `VoiceRecorder` 会置「满了」标记、由调用方直接收尾——这里不做倒计时:
|
||||
* 一个还在数的计时器在「已经满了」那一刻是假话。
|
||||
*
|
||||
* 两枚按钮分开(不是「点头像就发」那种手势):录音是一条**有去无回**的动作(这段 bytes 一旦
|
||||
* 发出就落进服务端编排),取消必须和发送一样容易按到。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceRecordingRow(
|
||||
elapsedMs: Int,
|
||||
onCancel: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "录音中 · ${voiceElapsedLabel(elapsedMs)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_RECORD_CANCEL_DESCRIPTION },
|
||||
) {
|
||||
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
|
||||
}
|
||||
Button(onClick = onSend) { Text("发送") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一段**录好但没发出去**的语音:`🎤 语音 · 0:07 [重发] [丢弃]`。
|
||||
*
|
||||
* 它只在一件事之后出现:`voice_input` 没能上行(连接没就绪 / 写帧失败)。此时那段字节**只在
|
||||
* 内存里**,界面上必须给两条出路——重发(**复用同一个 `clientMsgId`**,服务端据此判重)或丢弃。
|
||||
* 不给「退回到输入栏」那种出路:语音没有可以退回到输入框的形态。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceDraftRow(
|
||||
draft: VoiceDraftInfo,
|
||||
onRetry: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(draft.durationMs)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
text = "重发",
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_RETRY_DESCRIPTION },
|
||||
)
|
||||
TextButton(
|
||||
text = "丢弃",
|
||||
onClick = onDiscard,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_DISCARD_DESCRIPTION },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 她的语音回复:`[▶] 她的语音回复 · 0:03 [×]` + 她说的话([VoiceReply.reply])。
|
||||
*
|
||||
* ## 只有文字那一档
|
||||
* `provider == "silent"`(或这一轮根本没有 audio)时**不画播放控件**,只画文字 + 一句
|
||||
* [voiceSilentNote](「这一轮她只回了文字,没有语音」)。契约的硬要求是「UI 别把它画成播放器」;
|
||||
* 补那句解释是为了让「她选择只用文字」与「播放器坏了」在屏幕上分得开。
|
||||
*
|
||||
* ## 可播放但播不了
|
||||
* 音频在、本机解码 / 落盘失败时**照样画**播放控件:点了会走到 [failureOf] 那一行如实说
|
||||
* 「播不了」——这比一个看起来什么都没有的控件诚实。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceReplyRow(
|
||||
reply: VoiceReply,
|
||||
playback: VoicePlaybackState,
|
||||
onTogglePlay: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val active = playback.isActiveFor(reply.id)
|
||||
val loading = playback.isLoadingFor(reply.id)
|
||||
val failure = playback.failureOf(reply.id)
|
||||
val text = reply.reply
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (reply.playable) {
|
||||
// 整枚可点(不只是 18dp 的图标),读屏名字说清「点它会怎样」。
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onTogglePlay)
|
||||
.semantics {
|
||||
contentDescription = if (active) {
|
||||
VOICE_REPLY_STOP_DESCRIPTION
|
||||
} else {
|
||||
VOICE_REPLY_PLAY_DESCRIPTION
|
||||
}
|
||||
}
|
||||
.padding(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "她的语音回复",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "她的语音回复",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
}
|
||||
// 「现在在干什么」与「点它会怎样」分开说(与接收侧语音形态同一条纪律):
|
||||
// 只有真的在取字节时才说「准备中…」,正在响时不冒充加载。
|
||||
if (loading) {
|
||||
Text(
|
||||
text = "准备中…",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.semantics { contentDescription = VOICE_REPLY_DISMISS_DESCRIPTION },
|
||||
) {
|
||||
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
text?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
modifier = Modifier.padding(start = 2.dp, end = 8.dp),
|
||||
)
|
||||
}
|
||||
// 「只有文字」与「本机没准备好」是两件事,各说各的那句(判据是纯函数)。
|
||||
voiceReplyNote(silent = reply.silent, hasAudioFile = reply.file != null)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
failure?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观气泡里那一枚「语音」:`🎤 语音 · 0:07`。
|
||||
*
|
||||
* 用户按住说话之后,气泡**立刻**出现(与文本消息的乐观路径同一个 `pending` 列表、同一个回滚),
|
||||
* 只是正文还没到——转写要等服务端。所以这里只画媒体类型与时长,**不画播放控件**:
|
||||
* 本地已经发出去的那段音频不在播放通道里(它已经在服务端了,回放应当走历史消息那条路)。
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceClipLabel(durationMs: Int, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(VOICE_CARD_SHAPE)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Mic,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(VOICE_ICON_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(durationMs)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.chat.VOICE_UNPLAYABLE_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.failed
|
||||
import top.yeij.cyrene.domain.chat.finished
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.prepared
|
||||
import top.yeij.cyrene.domain.chat.pressed
|
||||
import top.yeij.cyrene.domain.chat.silenced
|
||||
import top.yeij.cyrene.domain.chat.voiceAudio
|
||||
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 「同一时刻只有一条语音在响」的那条**播放通道**(接收侧:播放别人发来的语音)。
|
||||
*
|
||||
* ## 用什么放(不引任何新依赖)
|
||||
* 系统 **`android.media.MediaPlayer`**(平台 API,API 1 起就有)。选它而不是 ExoPlayer / Media3:
|
||||
* 项目纪律是加依赖要先问,而这里要的只是「放一段 ogg / wav / mp3 的本地文件」,平台的解码器够用。
|
||||
* 音源是**本地文件**([load] 的产物):字节由 `VoiceStore` 取一次、落在 `cacheDir/media/`,
|
||||
* 于是播放本身不联网、不随时间抖动,也**不必**把 Bearer 头塞进播放器(`MediaPlayer` 的头部参数
|
||||
* 不会走 401 续期那条路,那才是真的会出事的地方)。
|
||||
*
|
||||
* ## 生命周期:三处必须停下来并释放(`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路)
|
||||
* 1. **开始播另一条**:[toggle] 在任何新播放之前先 [stop](旧的那条停下并释放,含正在下载的
|
||||
* 那一次)——所以「一条没放完又点另一条」不会出现两条叠着响;
|
||||
* 2. **离开会话 / 换会话**:调用方用 [rememberVoicePlayer](它按 `sessionKey` 换实例并在
|
||||
* `onDispose` 里 [release]),换会话 = 换实例 = 旧的那条立刻停;
|
||||
* 3. **组件销毁**:同上,`onDispose` 是组合被丢弃时唯一可靠的时刻([release] 幂等,可重复调)。
|
||||
*
|
||||
* ⚠️ **本片没做的两件事(如实)**:
|
||||
* - **不申请音频焦点**(`AudioManager` 那套):需要 `Context` 与「焦点被抢走」的处理,属于
|
||||
* 「播放器该有的完整行为」,与本期「收得到、放得出来」不是一档。后果是主人自己在放音乐时
|
||||
* 语音会叠在上面;
|
||||
* - **App 退到后台不停播**(要接 `LocalLifecycleOwner` 需要 `lifecycle-runtime-compose`,
|
||||
* 本期不加依赖)。语音都很短,影响面是「切走那一两秒还在响」。
|
||||
*
|
||||
* ## 失败:可见、但不夸张
|
||||
* 两档失败都会进 [VoicePlaybackState.Failed](气泡据此把按钮变回「未播」并显示**一行**短提示):
|
||||
* 取字节失败(网络 / 404 / 地址拼不出来,文案来自 [voicePlaybackFailureMessage])与解码起播失败
|
||||
* ([VOICE_UNPLAYABLE_MESSAGE])。**绝不静默**——一条点了没反应的语音在用户眼里就是坏的。
|
||||
*
|
||||
* ## 不缓存字节(这一层)
|
||||
* 缓存是 [VoiceStore] 的事(按存储名落 `cacheDir/media/`,命中就不发请求)。本类只负责「拿到
|
||||
* 文件 → 放 → 放完/停 → 释放」,因此它不认识网络,也不认识缓存策略。
|
||||
*
|
||||
* @param scope 驱动的协程作用域(调用方给,通常是组合的 `rememberCoroutineScope`)。
|
||||
* @param load 取这条语音的**本地文件**(未命中缓存时内部会下载并落盘)。
|
||||
*/
|
||||
class VoicePlayer(
|
||||
private val scope: CoroutineScope,
|
||||
private val load: suspend (VoiceAudio) -> Result<File>,
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow<VoicePlaybackState>(VoicePlaybackState.Idle)
|
||||
|
||||
/** 这条通道此刻的状态(气泡与按钮都看它)。状态机本身是纯函数,见 `domain.chat.VoicePlayback`。 */
|
||||
val state: StateFlow<VoicePlaybackState> = _state.asStateFlow()
|
||||
|
||||
/** 正在取字节的那次协程;停下 / 换目标时一并取消(用户不该被一个慢下载困住)。 */
|
||||
private var loading: Job? = null
|
||||
/** 当前这一台 `MediaPlayer`(null = 没有)。回调里的身份比对都以它为准。 */
|
||||
private var player: MediaPlayer? = null
|
||||
|
||||
/**
|
||||
* 用户点了这条消息的播放 / 暂停。
|
||||
*
|
||||
* 判据全部走纯函数:拿不到 [VoiceAudio](**无副本** ⇒ 四个键没凑齐)就**什么都不做**
|
||||
* ——正常路径上那时气泡根本不画播放控件,这里是第二道闸(同一个判据,不在 UI 之外再猜一次)。
|
||||
*/
|
||||
fun toggle(message: HistoryMessageDto) {
|
||||
val audio = voiceAudio(message.voice) ?: return
|
||||
val id = message.id
|
||||
// 再点一下 = 停(加载中也算:那表示「别下了」)。
|
||||
if (_state.value.isActiveFor(id)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
stop()
|
||||
_state.value = _state.value.pressed(id)
|
||||
loading = scope.launch {
|
||||
load(audio).fold(
|
||||
onSuccess = { file ->
|
||||
// 这一帧还该不该放:用户可能已经点了停 / 点了别的(状态已不是 Loading(id))。
|
||||
if (_state.value.isActiveFor(id)) play(id, file)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.value = _state.value.failed(id, voicePlaybackFailureMessage(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 放一段**已经在手上**的音频(`voice_output` 那条路:base64 就在帧里,不需要下载)。
|
||||
*
|
||||
* 与 [toggle] 走**同一条通道**、同一套身份比对与释放纪律(同一台喇叭上永远只有一条语音在响),
|
||||
* 差别只有两个:音频不是「按消息取字节」而是由调用方现给([load]),而且键是
|
||||
* `voice_output` 那一帧自己铸的 [clipId](它不是历史消息,没有 `messageId` 可用)。
|
||||
*
|
||||
* 同样遵守「再点一下 = 停」:`clipId` 正在放 / 正在载入时,这一下是**停**。
|
||||
*
|
||||
* @param load 取这份本地文件(写缓存 / 解码都在里面,调用方应切到 IO)。
|
||||
*/
|
||||
fun toggleClip(clipId: String, load: suspend () -> Result<File>) {
|
||||
if (_state.value.isActiveFor(clipId)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
stop()
|
||||
_state.value = _state.value.pressed(clipId)
|
||||
loading = scope.launch {
|
||||
load().fold(
|
||||
onSuccess = { file ->
|
||||
if (_state.value.isActiveFor(clipId)) play(clipId, file)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.value = _state.value.failed(clipId, voicePlaybackFailureMessage(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
|
||||
fun stop() {
|
||||
loading?.cancel()
|
||||
loading = null
|
||||
releasePlayer()
|
||||
// ⚠️ `silenced` 而不是「只清 Playing」:失败那句话也要跟着走——换了目标 / 离开页面之后,
|
||||
// 屏幕上不该还挂着上一条的失败。
|
||||
_state.value = _state.value.silenced
|
||||
}
|
||||
|
||||
/** 组件销毁 / 离开会话时调(等价于 [stop],单列一个名字是为了让调用点的意图看得见)。 */
|
||||
fun release() = stop()
|
||||
|
||||
private fun play(messageId: String, file: File) {
|
||||
val mp = MediaPlayer()
|
||||
player = mp
|
||||
mp.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
mp.setOnPreparedListener { prepared ->
|
||||
// 身份比对:已经换掉 / 已经 release 的那个实例,回调一律不改状态。
|
||||
if (player !== prepared) return@setOnPreparedListener
|
||||
prepared.start()
|
||||
_state.value = _state.value.prepared(messageId)
|
||||
}
|
||||
mp.setOnCompletionListener { done ->
|
||||
if (player !== done) return@setOnCompletionListener
|
||||
releasePlayer()
|
||||
_state.value = _state.value.finished(messageId)
|
||||
}
|
||||
mp.setOnErrorListener { broken, _, _ ->
|
||||
if (player !== broken) return@setOnErrorListener true
|
||||
releasePlayer()
|
||||
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
|
||||
// true = 这次错误已经处理(不让它再走 onCompletion,也不弹系统默认处理)。
|
||||
true
|
||||
}
|
||||
try {
|
||||
mp.setDataSource(file.absolutePath)
|
||||
// prepareAsync 而不是 prepare:同步那一版会占着主线程等解码器,异步的失败同样会走到
|
||||
// onError(format 不支持 / 文件损坏 / 已被清空都是这一档)。
|
||||
mp.prepareAsync()
|
||||
} catch (_: Exception) {
|
||||
// setDataSource / prepareAsync 在状态不对、文件读不动时**同步抛**:如实报「播不了」。
|
||||
releasePlayer()
|
||||
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前那台播放器拆干净:先摘掉全部监听(免得 `stop()` / `release()` 触发的回调再改状态),
|
||||
* 再 `stop()`(未 prepare 完时它会抛 `IllegalStateException`,所以包在 `runCatching` 里),
|
||||
* 最后 `release()`。
|
||||
*/
|
||||
private fun releasePlayer() {
|
||||
val current = player ?: return
|
||||
player = null
|
||||
current.setOnPreparedListener(null)
|
||||
current.setOnCompletionListener(null)
|
||||
current.setOnErrorListener(null)
|
||||
runCatching { current.stop() }
|
||||
runCatching { current.release() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为**当前这段组合**(通常是一个会话的聊天页)记住一条播放通道,并在它被丢弃时释放。
|
||||
*
|
||||
* 两条纪律写在这里,因为它们是本类唯一的使用方式:
|
||||
* - **[sessionKey] 变了就换一条通道**:换会话 = 换通道 = 上一条立刻停(`remember` 换实例 →
|
||||
* `DisposableEffect` 的旧 effect 走 `onDispose` → [VoicePlayer.release]);
|
||||
* - **[load] 变化不重建通道**:它由 `rememberUpdatedState` 顶着(每次重组拿到最新那个 lambda),
|
||||
* 否则重组期间的一次实例更替会把正在放的那条停掉。
|
||||
*/
|
||||
@Composable
|
||||
fun rememberVoicePlayer(
|
||||
sessionKey: Any?,
|
||||
load: suspend (VoiceAudio) -> Result<File>,
|
||||
): VoicePlayer {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentLoad by rememberUpdatedState(load)
|
||||
val player = remember(sessionKey) { VoicePlayer(scope) { audio -> currentLoad(audio) } }
|
||||
DisposableEffect(player) {
|
||||
onDispose { player.release() }
|
||||
}
|
||||
return player
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `voice_output` 在界面上的那一份「她的语音回复」(只管**这一条实时应答**,不进历史)。
|
||||
*
|
||||
* ## 为什么它不在消息列表里
|
||||
* 契约 v0.96 §3.15:这一帧**只定向回发起 `voice_input` 的那条连接**、**连接断了不补投**,
|
||||
* 而且回合的正文本来就会经流式 / `message_persisted` 落进消息列表。所以它不是一条消息,
|
||||
* 是「这一次语音回合的音频 + 她说的话」这一次性产物:放在输入栏上方一条卡片里,
|
||||
* 用户随时可以关掉([ChatViewModel.dismissVoiceReply]),它不参与排序、不占历史。
|
||||
*
|
||||
* ## [hasAudio] 与 [file] 是两件事
|
||||
* - [hasAudio]:**服务端**这一轮给了真音频(`provider != "silent"` 且 `audio` 非空白)。
|
||||
* 为假 ⇒ 界面上**一个播放控件都不画**(画了就是点下去必然失败的按钮)。
|
||||
* - [file]:音频在本机的那份文件。`hasAudio` 为真而它是 `null` = 本机解码 / 落盘失败
|
||||
* ——这一档**照样画**播放控件(点了会如实报「播不了」),**绝不**把本机的故障说成
|
||||
* 「她这一轮只回了文字」(见 `domain.chat.voiceReplyNote`)。
|
||||
*
|
||||
* @property id 播放通道的键:每条 `voice_output` 现铸一个(它不是历史消息,没有 `messageId`)。
|
||||
* @property reply 她说的那一句(音频念的就是它),见 `domain.chat.voiceReplyText`。
|
||||
* @property transcript 转写(载荷里的 `text`):那段音频被识别成什么;只作兜底与诊断。
|
||||
* @property format 服务端给的音频格式;本端**不照抄它去解码**(`MediaPlayer` 按文件头嗅探)。
|
||||
*/
|
||||
data class VoiceReply(
|
||||
val id: String,
|
||||
val reply: String?,
|
||||
val transcript: String?,
|
||||
val format: String?,
|
||||
val provider: String?,
|
||||
val hasAudio: Boolean,
|
||||
val file: File?,
|
||||
/**
|
||||
* 这一条还该不该**自动播放**(默认 true,播过就置假,见 [ChatViewModel.consumeVoiceReplyAutoPlay])。
|
||||
*
|
||||
* 为什么要这一位:这一帧**只在到达时**有意义(断线不补投),而到达的那一刻用户可能正在别的
|
||||
* 会话 / 别的页面里。若只按「音频落好了就放」来触发,等他切回来时会**突然响一段旧语音**
|
||||
* ——那不是他刚做的事。所以自动播放只认**第一次**落好的那一下,之后靠卡片上的播放键。
|
||||
*/
|
||||
val autoPlay: Boolean = true,
|
||||
) {
|
||||
|
||||
/** 能不能画「播放」那枚控件:这一轮**有**音频,而且它已经落到本机。 */
|
||||
val playable: Boolean get() = hasAudio && file != null
|
||||
|
||||
/** 这一轮是不是「没有音频」(= `provider == "silent"` 或 `audio` 缺席)。 */
|
||||
val silent: Boolean get() = !hasAudio
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import java.io.File
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
|
||||
/**
|
||||
* 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。
|
||||
*
|
||||
* ## 为什么它住在 VM 的状态里,而不是气泡自己的 `remember`
|
||||
* 一次朗读要经过一段**跨组合重建**的等待(发请求、等字节、落盘):转屏 / 滚出屏幕再滚回来
|
||||
* 都不该让已经落好的那段音频消失,也不该让「正在准备」这件事凭空重置。所以请求发起、失败分类、
|
||||
* 落盘结果都在 `ChatViewModel.speak` 里,气泡只是这份状态的视图。
|
||||
*
|
||||
* ## 一次只有一个:`ttsClip` 是**单格**
|
||||
* 与 [VoiceReply] 同一取舍:同一时刻用户点的是**一条**消息,多留几条既占内存又没人看。
|
||||
* 用户点了另一条 ⇒ 这一格被新的那条整个替换(旧那条的音频文件留在缓存里,下次点它命中不发请求)。
|
||||
*
|
||||
* ## 播放不在这一格里
|
||||
* 「正在响 / 已经停了」由**唯一的播放通道**(`ui.chat.VoicePlayer` 的 `state`)说,键是
|
||||
* [clipId](`tts:<messageId>`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、
|
||||
* 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道
|
||||
* `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事
|
||||
* 本来就不是一件事。
|
||||
*
|
||||
* @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。
|
||||
* @property clipId 播放通道上的键(`tts:<messageId>`):与语音副本的键(= 它的 `messageId`)
|
||||
* 分得开,于是「念这一条」与「放这条消息的语音副本」天然互斥(点一个停另一个)。
|
||||
* @property file 合成音频在本机的那份 WAV;`null` = 还没到手(请求在飞,或已经失败了)。
|
||||
* @property failure 上一次没成功时给用户看的那句话(`domain.chat.TtsFailure.message`);
|
||||
* `null` = 没失败过 / 还没结果。
|
||||
* @property retryable 再点一次**可能**有用吗(`domain.chat.TtsFailure.retryable`)。为假时图标
|
||||
* 仍然可点 —— 但那是「重来一次」而不是「重试」,文案上不提重试(见 `TtsFailure.Unconfigured`)。
|
||||
* @property autoPlay 这一次结果还该不该**自动起播**(默认 true,起播过就置假,见
|
||||
* `ChatViewModel.consumeTtsAutoPlay`)。
|
||||
*
|
||||
* 为什么要这一位:请求发出去之后用户可能已经切到别的会话 / 别的页面。若只按「文件到了就放」
|
||||
* 来触发,等他切回来时会**突然响一段刚才点的朗读** —— 那不是他此刻的动作。所以自动起播只认
|
||||
* **第一次**落好的那一下,之后靠那枚图标。
|
||||
*/
|
||||
data class TtsClip(
|
||||
val messageId: String,
|
||||
val clipId: String,
|
||||
val file: File?,
|
||||
val failure: String?,
|
||||
val retryable: Boolean,
|
||||
val autoPlay: Boolean = true,
|
||||
) {
|
||||
|
||||
/** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */
|
||||
val pending: Boolean get() = file == null && failure == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
|
||||
*
|
||||
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
|
||||
* 判据跨了三处状态(这一格的 [TtsClip]、那条唯一播放通道的 [VoicePlaybackState]、以及
|
||||
* `ttsClipIdOf` 铸的键),而它们谁都不该在自己的类里替对方下结论。
|
||||
*/
|
||||
sealed interface TtsSpeakIntent {
|
||||
|
||||
/** 这一条**正在响**,手上也有它的音频 ⇒ 这一下是「停」。 */
|
||||
data object Stop : TtsSpeakIntent
|
||||
|
||||
/** 其余一切(没在响、响的是别的、失败过、缓存已命中想再听一遍)⇒ 交给 VM。 */
|
||||
data object Speak : TtsSpeakIntent
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一下点击是「停」还是「(重新)念」。
|
||||
*
|
||||
* ## 为什么必须有这条判据:否则「停止朗读」是空操作
|
||||
* 图标在播放中画的是暂停符、读屏名字是 `TTS_STOP_DESCRIPTION`(见 `MessageBubble`),所以
|
||||
* 用户点它时**意思是停**。但播放中 `TtsClip.pending` 是假的(文件早就到手了),于是
|
||||
* `ChatViewModel.beginTtsSpeak` 的防抖放行、整格被换成新的 pending、缓存命中秒回、
|
||||
* 起播 effect 又因为「已经在放同一条」而跳过 `toggleClip` —— 三处各自都「没错」,合起来就是
|
||||
* 点了没反应。判据落在**播放通道**这一处(它是「有没有在响」的唯一真相),才不会有第二处
|
||||
* 各自解释「播放中」是什么意思。
|
||||
*
|
||||
* ## 为什么要 `file != null`
|
||||
* 「正在响」与「音频在手上」是两件事:通道处于 [VoicePlaybackState.Loading] 时它也算 active,
|
||||
* 而那一刻 [TtsClip.file] 可能还是 `null`(字节在 VM 那边取)。那时把点击解释成「停」会停掉
|
||||
* 一次**还没起播**的载入,而用户点它的本意是「念」——所以只有文件确实在手、通道确实在响时才判
|
||||
* [TtsSpeakIntent.Stop];其余一律 [TtsSpeakIntent.Speak],由 VM 的 pending 分支去防抖。
|
||||
*
|
||||
* @param playback 那条唯一播放通道此刻的状态(`VoicePlayer.state`)。
|
||||
* @param messageId 被点的那条消息。
|
||||
* @param clip 这一条自己的朗读格(`messageId` 对不上的要当没点过,判据里再核一次)。
|
||||
*/
|
||||
fun ttsSpeakIntent(
|
||||
playback: VoicePlaybackState,
|
||||
messageId: String,
|
||||
clip: TtsClip?,
|
||||
): TtsSpeakIntent {
|
||||
val file = clip?.takeIf { it.messageId == messageId }?.file
|
||||
return if (file != null && playback.isActiveFor(ttsClipIdOf(messageId))) {
|
||||
TtsSpeakIntent.Stop
|
||||
} else {
|
||||
TtsSpeakIntent.Speak
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import top.yeij.cyrene.data.repository.MessageIncrement
|
||||
|
||||
/**
|
||||
* 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。
|
||||
*
|
||||
@@ -18,11 +20,15 @@ internal enum class PersistedRoute {
|
||||
|
||||
/**
|
||||
* **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数**
|
||||
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,问服务端要那个数。
|
||||
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地问服务端要。
|
||||
*
|
||||
* 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。
|
||||
* 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处,
|
||||
* 于是红点必须靠用户开一次抽屉(那次静默重拉)才补上。
|
||||
* 于是红点必须靠用户开一次抽屉才补上。
|
||||
*
|
||||
* 「要」的是什么,W77 换过一次:原来是整表 `GET /sessions`,现在是**这条会话的消息增量**
|
||||
* `GET /messages?after=`(见 [incrementCursors] / [applyIncrement]);整表退回成「增量给不出
|
||||
* 可信答案」时的回落。判据本身不受影响——这一档该不该被窗口挡住,与问的是哪种口径无关。
|
||||
*/
|
||||
InactiveSession,
|
||||
}
|
||||
@@ -190,3 +196,117 @@ internal fun silentRefreshStep(
|
||||
*/
|
||||
internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle =
|
||||
SilentRefreshThrottle(lastPullAt = nowMillis)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// W77:非激活会话的静默刷新,从「整表重拉 GET /sessions」换成「前向增量 GET /messages?after=」
|
||||
//
|
||||
// 后端明确批了这一换(回执二十八第 3 条:`?after=` 毫秒 `createdAt` 早就在册)。换掉的理由是**量**:
|
||||
// 整表一次要回**所有**会话(每条一行预览 + 未读计数),而这里只需要回**真的变了的那一个**会话的新消息。
|
||||
// 下面三个纯函数就是这一换的全部判据,放在这里而不是 VM 里,理由与 [silentRefreshStep] 一样:
|
||||
// 纯函数才穷举得了、才不用在 JVM 上摆一台假服务器。
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 某个会话现在该从哪个 `createdAt` 起问增量(纯函数);`null` = **这一行问不了**。
|
||||
*
|
||||
* 游标就取 [SessionItem.lastMessageAt]——最近一次权威 `GET /sessions` 给的、本端已知最新一条的
|
||||
* `createdAt`——**不另立账本**:
|
||||
* - 服务端的 `after` 是 `created_at > ?`(**严格**大于),而 [SessionItem.lastMessageAt] 正是
|
||||
* 「已经见过的最新一条」⇒ 两边语义天然对齐,多立一个账本只会多一个会漂移的真相源;
|
||||
* - 增量自己会把 [SessionItem.lastMessageAt] 推到新游标([applyIncrement]),于是账本自洽地滚动,
|
||||
* 不需要第二处推进。
|
||||
*
|
||||
* 两种「问不了」都落到 `null`,它们其实是同一件事——**本端不知道这条会话看到哪儿了**:
|
||||
* - 行不在列表里(会话在别处新建、或本端那份列表还没拉到它)⇒ 没有游标可用;
|
||||
* - 行在但 `lastMessageAt` 为 `null`(本端认为它一条消息都没有,却收到了它的落库广播)⇒
|
||||
* 手里那份列表已经陈旧到不可信。
|
||||
*
|
||||
* 两种都由调用方回落到整表(唯一权威),见 `SessionsViewModel.flushPendingIncrements`。
|
||||
*/
|
||||
internal fun incrementCursorFor(item: SessionItem?): Long? = item?.lastMessageAt
|
||||
|
||||
/**
|
||||
* 一批待问增量的会话 id → 各自的 `after` 游标(纯函数)。
|
||||
*
|
||||
* `null` = **这批里有一个问不了**([incrementCursorFor] 给了 `null`)⇒ 调用方**整批**回落整表。
|
||||
* 不混着来是刻意的:一半靠增量、一半靠整表,得到的那份列表没有任何一处能说得清「它是什么时刻的」,
|
||||
* 而未读面上最不能出的就是这种「说不清」。
|
||||
*
|
||||
* 返回 `emptyMap` = 这批本来就是空的(没有事要做),**不是**失败。
|
||||
*/
|
||||
internal fun incrementCursors(
|
||||
sessionIds: Collection<String>,
|
||||
sessions: List<SessionItem>,
|
||||
): Map<String, Long>? {
|
||||
if (sessionIds.isEmpty()) return emptyMap()
|
||||
val byId = sessions.associateBy { it.id }
|
||||
val cursors = LinkedHashMap<String, Long>(sessionIds.size)
|
||||
for (id in sessionIds) {
|
||||
cursors[id] = incrementCursorFor(byId[id]) ?: return null
|
||||
}
|
||||
return cursors
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次增量折进列表(纯函数):**只动那一行**,其余原样返回。
|
||||
*
|
||||
* ## 未读数怎么算:先过已读游标,再加
|
||||
* 增量条数**不是**直接往角标上加的,要先按本地已知的已读游标 [readAt](`readCursors[sessionId]`)筛一遍。
|
||||
* **过滤口径**与服务端同源:`session_read.go` 的 `unreadCountQuery` 是 `count(created_at > read_at)`,
|
||||
* 而 `read_at` 是**账号级单一游标**,可能比本端已知的最新消息还新——用户在这台机器上点开过它
|
||||
* ([SessionsViewModel.select] 推的就是「现在」),随后别的设备又发了消息。那一刻
|
||||
* `(lastMessageAt, readAt]` 之间的消息**服务端算已读**,一律加进角标就会多出来一个红点。所以只数
|
||||
* `createdAt > readAt` 的那几条。
|
||||
*
|
||||
* 已有的 [SessionItem.unreadCount] 直接保留相加是安全的:它来自服务端,而这次增量里的每一条都严格新于
|
||||
* [from](服务端的 `after` 就是 `created_at > ?`),两者没有交集,不会重复计数。
|
||||
*
|
||||
* ## 已知边界:同源的只是**过滤口径**,基数仍是上次整表的旧值
|
||||
* 相加的底数 [SessionItem.unreadCount] 来自上一次整表刷新,而 `read_at` 是账号级的、会被别的设备改写。
|
||||
* 断线期间他端把某个会话读掉时,那条 `session_read` 帧本地**永久收不到**(断线不补帧),于是底数偏大,
|
||||
* 这一行会一直多算那几条。这是**已接受的边界**,不是漏网:红点本来就只承诺「大概有几条新的」,漂移
|
||||
* 到下一次整表刷新(打开抽屉 / 初始化 / 任何一次回落)就自愈。
|
||||
*
|
||||
* 这里**不**去检测它(例如「[readAt] > [from] ⇒ 底数可疑 ⇒ 回落整表」):本端刚点开过这个会话时
|
||||
* `readAt` 本来就被推到「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量
|
||||
* 路径在正常使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门
|
||||
* 不如不装。
|
||||
*
|
||||
* ## 并发的那一份真相
|
||||
* [from] 不是「这次用了什么游标」的装饰:行上的 [SessionItem.lastMessageAt] **已经不等于** [from],
|
||||
* 就说明在这次请求飞在天上的时候,另一份更权威的结果(一次整表刷新、或另一次增量)已经推进过这一行。
|
||||
* 那些条数已经被别人数过一遍了,再加一次就是重复计数 ⇒ 整行不动。
|
||||
*
|
||||
* 一行都没被改动时返回**同一个列表实例**(与 `applyBusyFrame` 同一习惯):这条路径跑在后台,
|
||||
* 没有必要为了「什么都没变」让整个抽屉重组一遍。
|
||||
*
|
||||
* @param from 这次请求用的 `after` 游标。
|
||||
* @param readAt 本端已知的已读游标;`null` = 不知道(当作 `0`,即「一条都还没读过」)。
|
||||
* @param increment 升序的增量(`MessageRepository.loadIncrement` 的产物)。
|
||||
*/
|
||||
internal fun List<SessionItem>.applyIncrement(
|
||||
sessionId: String,
|
||||
from: Long,
|
||||
readAt: Long?,
|
||||
increment: MessageIncrement,
|
||||
): List<SessionItem> {
|
||||
if (increment.messages.isEmpty()) return this
|
||||
val floor = readAt ?: 0L
|
||||
val unseen = increment.messages.count { it.createdAt > floor }
|
||||
// 升序 ⇒ 最后一条就是最新的一条(`loadIncrement` 保证这一点)。
|
||||
val preview = increment.messages.last().content.toPreviewText().takeIf { it.isNotBlank() }
|
||||
var touched = false
|
||||
val updated = map { item ->
|
||||
if (item.id != sessionId || item.lastMessageAt != from) {
|
||||
item
|
||||
} else {
|
||||
touched = true
|
||||
item.copy(
|
||||
unreadCount = item.unreadCount + unseen,
|
||||
lastMessage = preview,
|
||||
lastMessageAt = increment.cursor,
|
||||
)
|
||||
}
|
||||
}
|
||||
return if (touched) updated else this
|
||||
}
|
||||
@@ -28,7 +28,8 @@ data class SessionItem(
|
||||
val lastMessage: String?,
|
||||
/**
|
||||
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
|
||||
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
|
||||
* 两处用:已读游标比对(见 [SessionItem.withReadCursor]),以及增量追尾的 `after` 游标
|
||||
* (见 `incrementCursorFor`,由 `applyIncrement` 推进到这次增量的 `cursor`)。不直接显示。
|
||||
*/
|
||||
val lastMessageAt: Long? = null,
|
||||
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
|
||||
@@ -82,9 +83,12 @@ data class SessionsUiState(
|
||||
*
|
||||
* **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带
|
||||
* `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**:
|
||||
* 收到非激活会话的落库广播就**节流地静默重拉**([requestSilentRefresh],窗口
|
||||
* [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次抽屉**就会出现
|
||||
* (S6,登记见 `devdocs/05-开发进度与交接.md`)。
|
||||
* 收到非激活会话的落库广播就**节流地问一次那条会话的消息增量**([requestSilentRefresh] →
|
||||
* [flushPendingIncrements],窗口 [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次
|
||||
* 抽屉**就会出现(S6,登记见 `devdocs/05-开发进度与交接.md`)。
|
||||
*
|
||||
* 取数走的是 `GET /messages?after=<ms>`(前向增量)而不是整表 `GET /sessions`——W77 换的,理由与回落
|
||||
* 规则都写在 [flushPendingIncrements] 上。整表仍是**唯一权威**,只是不再需要为一条新消息付它的价钱。
|
||||
*
|
||||
* ## WS 连接:全 App 共享一条,而**本类是它唯一的主人**
|
||||
* `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条
|
||||
@@ -136,6 +140,17 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
*/
|
||||
private var trailingRefreshJob: Job? = null
|
||||
|
||||
/**
|
||||
* 窗口里攒下的「要问增量的会话 id」(W77)。
|
||||
*
|
||||
* 为什么是一个**集合**而不是一个 id:节流窗口把同一段时间里的落库广播合并成一次拉取,而那几个
|
||||
* 会话**未必是同一个**(主会话说一句、另一个会话也说一句)。只记住最后一条的话,前面那个会话的
|
||||
* 红点就永远不会出现——那正是 W49 修过的同一类病,只是换了个成因。
|
||||
*
|
||||
* 只在 [requestSilentRefresh] / [flushPendingIncrements] 两处写;登出时清空(它属于上一个身份)。
|
||||
*/
|
||||
private val pendingIncrements = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。
|
||||
*
|
||||
@@ -201,6 +216,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
trailingRefreshJob?.cancel()
|
||||
trailingRefreshJob = null
|
||||
silentRefreshThrottle = SilentRefreshThrottle()
|
||||
// 攒下的会话 id 同理:它们是上一个身份的会话,重登后那些 id 要么不存在、
|
||||
// 要么属于别人;留着只会让重登后的第一次窗口拿旧 id 去问增量。
|
||||
pendingIncrements.clear()
|
||||
pulledForIdentity = null
|
||||
busyTurns = emptyMap()
|
||||
_state.value = SessionsUiState()
|
||||
@@ -316,42 +334,54 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流地静默重拉一次列表 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
|
||||
* 节流地静默对齐一次 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
|
||||
* 必须开一次抽屉才补上」(S6)。
|
||||
*
|
||||
* 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理
|
||||
* 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。
|
||||
*
|
||||
* ## 为什么是「重拉」而不是「本地把未读数加一」
|
||||
* `message_persisted` 帧**不带 `unreadCount`**:本地没有逐条时间线,自己数出来的数会与编辑 /
|
||||
* ## 为什么是「问服务端」而不是「本地把未读数加一」
|
||||
* `message_persisted` 帧**不带 `unreadCount`**:本端没有逐条时间线,靠帧自己数出来的数会与编辑 /
|
||||
* 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**,
|
||||
* 只是把「服务端才是唯一真源」这件事提前做一次 —— 走的是**与抽屉打开时那次完全相同**的路径
|
||||
* ([refresh],含已读游标遮罩),只是不需要用户先开一次抽屉。
|
||||
* 只是把「服务端才是唯一真源」这件事提前做一次。
|
||||
*
|
||||
* ## W77:问的是**那一条会话的增量**,不再是整表
|
||||
* 曾经的落点是 `refresh(silent = true)`(整表 `GET /sessions`),一次要为**所有**会话付一行预览 +
|
||||
* 未读计数的价钱,而真正变了的只有一个会话。现在落到 [flushPendingIncrements]:
|
||||
* `GET /messages?after=<已见最大 createdAt>`,只回那几条新消息。整表仍是唯一权威,但只在
|
||||
* 「增量给不出可信答案」时才请它出场(回落规则写在 [flushPendingIncrements] 上)。
|
||||
*
|
||||
* ## 三条纪律
|
||||
* 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次
|
||||
* 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。
|
||||
* 2. **静默**:`silent = true`,不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。
|
||||
* 失败时做什么**完全沿用既有行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;
|
||||
* 以及「网络异常,显示 x 分钟前的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,
|
||||
* 不该比用户自己打开抽屉时的那次刷新更吵。
|
||||
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。[refresh] 内部唯一会动
|
||||
* `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回主会话),
|
||||
* 那是既有的、正确的收敛,不是重置。
|
||||
* 2. **静默**:不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。失败时做什么**完全沿用既有
|
||||
* 行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;以及「网络异常,显示 x 分钟前
|
||||
* 的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,不该比用户自己打开抽屉时的那次
|
||||
* 刷新更吵。
|
||||
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。回落那条路([refresh])
|
||||
* 内部唯一会动 `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回
|
||||
* 主会话),那是既有的、正确的收敛,不是重置。
|
||||
*
|
||||
* @param cause 这次为什么要拉;决定它走不走窗口(见 [SilentRefreshCause])。
|
||||
* @param sessionId 这一帧说的是**哪个**会话;`null` = 没有具体会话([SilentRefreshCause.ContextChanged]),
|
||||
* 那一档只能走整表。窗口里攒下的 id 由 [pendingIncrements] 记着,因为一次窗口可能合并**几个不同**会话。
|
||||
*/
|
||||
private fun requestSilentRefresh(cause: SilentRefreshCause) {
|
||||
private fun requestSilentRefresh(cause: SilentRefreshCause, sessionId: String? = null) {
|
||||
val step = silentRefreshStep(cause, System.currentTimeMillis(), silentRefreshThrottle)
|
||||
silentRefreshThrottle = step.throttle
|
||||
// 没有具体会话时不留 id:那一次整表会把所有会话都对上,攒着的 id 一个也不缺。
|
||||
if (sessionId != null) pendingIncrements += sessionId
|
||||
when (step.action) {
|
||||
// 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。
|
||||
// (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档
|
||||
// 走到这里时 trailingPending 必为 false,补拉早跑完了。)
|
||||
//
|
||||
// 撤掉补拉**不等于**可以丢掉它攒下的 id:[pendingIncrements] 是独立的一份账本,
|
||||
// 这一趟会把它们一并做掉。
|
||||
SilentRefreshAction.PullNow -> {
|
||||
trailingRefreshJob?.cancel()
|
||||
trailingRefreshJob = null
|
||||
refresh(silent = true)
|
||||
flushPendingIncrements(fullTable = sessionId == null)
|
||||
}
|
||||
|
||||
// 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串
|
||||
@@ -365,15 +395,75 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
trailingRefreshJob = viewModelScope.launch {
|
||||
delay(step.delayMillis)
|
||||
silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis())
|
||||
refresh(silent = true)
|
||||
flushPendingIncrements(fullTable = false)
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。
|
||||
// (但这一帧的会话 id 已经在上面记进 [pendingIncrements] 了 —— 补拉不会漏掉它。)
|
||||
SilentRefreshAction.Ignore -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 [pendingIncrements] 里攒下的会话**逐个问一次消息增量**,问不了就整批回落整表(W77)。
|
||||
*
|
||||
* 这就是 W49 那条「非激活会话落了新消息 ⇒ 整表重拉 `GET /sessions`」的替代品:整表一次要回
|
||||
* **所有**会话(每条一行预览 + 未读计数),而这里只回**真的变了的那一个**会话的新消息。
|
||||
*
|
||||
* ## 回落的三条理由(都落到同一条路:整表)
|
||||
* 1. **问不了增量**:[incrementCursors] 给 `null` —— 行不在列表里,或本端不知道它看到哪儿了;
|
||||
* 2. **追不到尾**:`complete = false` —— 整个追尾里拿过整页(服务端的 `LIMIT` 切过一刀,那一刀
|
||||
* 可能劈开了同一毫秒,而服务端只给严格 `>`,被劈开的尾巴再也追不回来;此后的空页与不满页
|
||||
* 都说不清),或翻满了 `MessageRepository.MAX_INCREMENT_PAGES`。那时增量只覆盖了积压的一部分,
|
||||
* 拿它当「全部」就是把红点做小;
|
||||
* 3. **请求失败**:含会话已在别处被删掉的 `404` —— 那一档要靠整表把这一行从列表里去掉。
|
||||
*
|
||||
* 三条都是「增量给不出可信答案」⇒ 去问唯一权威。**一次批次最多回落一次**:整批共用这一个失败
|
||||
* 出口,否则 N 个会话同时失败就是 N 次整表。
|
||||
*
|
||||
* ## 刻意不做的一件事:不写快照
|
||||
* [refresh] 成功时会 `prefs.saveSnapshot`,这里**不写**。快照的类型是 `List<SessionSummaryDto>`
|
||||
* (整表响应),而增量只回了消息——拿它去拼一份 DTO 列表,缺的字段(`type` / `createdAt` /
|
||||
* `pinned` / `personaId`)只能靠猜,那等于用一个更差的东西覆盖掉一份好快照。代价是冷启动第一帧
|
||||
* 可能显示上一次整表时的未读与预览,紧接着 `init` 里那次 `refresh()` 就会把它修正。
|
||||
*
|
||||
* ## 也不顺带刷角色目录
|
||||
* [refresh] 会捎带 `loadPersonas()`,这条路径不会。角色目录的刷新点是 init / 新建 / 删除 / 抽屉
|
||||
* 打开(那几个都是用户动作或一次性事件),「别的会话来了条消息」从来不是它的刷新时机——此前它只是
|
||||
* 跟着整表搭了便车。W77 把便车取消,角色目录少掉的是几次没人要的请求。
|
||||
*
|
||||
* @param fullTable `true` = 这一趟没有具体会话(或已判明必须整表),直接走 [refresh]。
|
||||
*/
|
||||
private fun flushPendingIncrements(fullTable: Boolean) {
|
||||
val targets = pendingIncrements.toList()
|
||||
pendingIncrements.clear()
|
||||
if (fullTable) {
|
||||
refresh(silent = true)
|
||||
return
|
||||
}
|
||||
val cursors = incrementCursors(targets, _state.value.sessions)
|
||||
if (cursors == null) {
|
||||
refresh(silent = true)
|
||||
return
|
||||
}
|
||||
// 空批次 = 没有事要做(不是失败):不回落、也不发请求。
|
||||
if (cursors.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
for ((sessionId, from) in cursors) {
|
||||
val increment = container.messageRepository.loadIncrement(sessionId, after = from).getOrNull()
|
||||
if (increment == null || !increment.complete) {
|
||||
refresh(silent = true)
|
||||
return@launch
|
||||
}
|
||||
val readAt = readCursors[sessionId]
|
||||
_state.update {
|
||||
it.copy(sessions = it.sessions.applyIncrement(sessionId, from, readAt, increment))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 / 进入会话。
|
||||
*
|
||||
@@ -506,10 +596,11 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
* 帧、不碰下面的分支,所以两条既有路径(W49 的节流静默重拉、[persistedRoute] 的两档)语义
|
||||
* 一个字节都没变。
|
||||
*
|
||||
* **她主动说的话还有第三个落点**(W53N):两条路(实时帧 / 带 `proactive` 的落库帧)**都**要
|
||||
* 喂给 [top.yeij.cyrene.core.notify.ProactiveNotifier]——通知该不该弹、是不是重复由它判。
|
||||
* 选在这里而不是新起一个订阅,是因为本 VM 是**唯一**同时看着这两帧、又活过整个登录态的
|
||||
* 消费者;而它只把帧**转交**出去,不碰连接(连接的所有权仍在登录态那一处)。
|
||||
* **通知不在这里**(W57 起):她主动说的话那两条路(实时帧 / 带 `proactive` 的落库帧)**曾经**
|
||||
* 在本 VM 的帧订阅里转交给 `core.notify.ProactiveNotifier`,而这份订阅挂在 `viewModelScope` 上
|
||||
* ——从最近任务把 Activity 划掉时它就没了,进程被前台服务保活着也收不到帧。那条通路现在由
|
||||
* **进程级**的消费点喂(`app.ProactiveFrameIntake`,与那条共享连接同一层、同寿),本类**不再
|
||||
* 判断通知的任何一件事**:这里只剩列表自己的两件事(未读、「谁在跑」)。
|
||||
*/
|
||||
private fun onEvent(event: WsEvent) {
|
||||
onBusyFrame(event)
|
||||
@@ -519,37 +610,11 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
// 落了新消息。哪一个会话都有事做:正在看的那个推进游标(消息已经显示在屏幕上了,
|
||||
// 否则「自己正看着的会话」会攒出一个未读角标,契约 §3.9 的客户端处理建议),
|
||||
// 别的会话则去问一次服务端(红点不该等用户开抽屉才出现)——两半都在 onMessagePersisted。
|
||||
is WsEvent.MessagePersisted -> {
|
||||
onMessagePersisted(event)
|
||||
// 她主动说的话走 **IM 桥那条路由**时,服务端把它补发在这个落库帧上(帧里的
|
||||
// `proactive` 判据,见 WsEvent.MessagePersisted)。那条路**不发** `proactive_message`,
|
||||
// 所以不在这一档看一眼,通知就只剩一半入口(W53N)。
|
||||
if (event.proactive) {
|
||||
container.proactiveNotifier.onProactiveFrame(
|
||||
sessionId = event.sessionId,
|
||||
id = event.message?.id,
|
||||
messageId = null,
|
||||
timestamp = null,
|
||||
content = event.message?.content.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
is WsEvent.MessagePersisted -> onMessagePersisted(event)
|
||||
// 她主动说的话(v0.72)。服务端**不为这条路补** `message_persisted`(那条只有 IM 路由
|
||||
// 才广播),所以上面那一档永远不会被它触发——不在这里补一句,主会话的红点会停在她
|
||||
// 开口之前,要等用户自己开一次抽屉(那次静默重拉)才补上。
|
||||
//
|
||||
// 同一帧也是「要不要弹通知」的入口之一(另一条是上面那个落库帧):同一条消息两条路都
|
||||
// 可能到,去重由 `core.notify.ProactiveNotifier` 按 id 管,这里不做任何判断。
|
||||
is WsEvent.ProactiveMessage -> {
|
||||
onProactiveMessage(event)
|
||||
container.proactiveNotifier.onProactiveFrame(
|
||||
sessionId = event.sessionId,
|
||||
id = event.id,
|
||||
messageId = event.messageId,
|
||||
timestamp = event.timestamp,
|
||||
content = event.content,
|
||||
)
|
||||
}
|
||||
is WsEvent.ProactiveMessage -> onProactiveMessage(event)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -619,23 +684,24 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
* 两半共用同一条纪律:**本端哪里都不替服务端数未读**(逐条累加会与编辑 / 删除 / 别端操作漂移)。
|
||||
* 区别只在「那它靠什么显示」:
|
||||
* - [PersistedRoute.ActiveSession] ⇒ 就地清零 + 推进游标(这段一个字没改);
|
||||
* - [PersistedRoute.InactiveSession] ⇒ **节流地静默重拉**([requestSilentRefresh]),
|
||||
* 用服务端下发的 `unreadCount` 说话。这正是 W49 修的那一幕:此前这一档直接 `return`,于是
|
||||
* 「App 停在主会话、次级会话来了新消息」没有任何人会去发现,红点要等用户打开一次抽屉(那次
|
||||
* 静默重拉)才补上。
|
||||
* - [PersistedRoute.InactiveSession] ⇒ **节流地问一次这条会话的消息增量**([requestSilentRefresh]
|
||||
* 带上 `sessionId` → [flushPendingIncrements] → `MessageRepository.loadIncrement`),未读由
|
||||
* 「增量里越过已读游标的条数」说话(W77;W49 那一档原本整表重拉 `GET /sessions`)。
|
||||
* 这正是 W49 修的那一幕:此前这一档直接 `return`,于是「App 停在主会话、次级会话来了新消息」
|
||||
* 没有任何人会去发现,红点要等用户打开一次抽屉才补上。
|
||||
*
|
||||
* 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做),
|
||||
* 而「重拉一次列表」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
|
||||
* 而「问一次增量」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
|
||||
* 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的
|
||||
* 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。
|
||||
*/
|
||||
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
|
||||
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
|
||||
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地静默重拉一次列表,
|
||||
// 红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
|
||||
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地问一次**这条会话**的消息
|
||||
// 增量,红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
|
||||
PersistedRoute.InactiveSession ->
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
|
||||
|
||||
// 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。
|
||||
// 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时,
|
||||
@@ -659,9 +725,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
* 判据也共用 [persistedRoute](只是会话 id 由这一帧自己钉:它不带 `sessionId`)。
|
||||
*
|
||||
* 为什么必须在这里补一句:服务端的主动推送 Web 路由只发这一帧,**不补** `message_persisted`
|
||||
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流静默重拉」根本不会被
|
||||
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流刷新」根本不会被
|
||||
* 触发——抽屉里主会话的红点就停在她开口之前,直到用户自己开一次抽屉。这里复用**同一条**路
|
||||
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage]),
|
||||
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage],会话 id 取主会话),
|
||||
* 不新写取数路径,也不动节流判据本身。
|
||||
*
|
||||
* `id` 缺席 = 服务端那一刻**没有落库行** ⇒ 历史里没有这一行,未读面上没有任何东西要更新
|
||||
@@ -672,7 +738,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
val sessionId = SessionSummaryDto.MAIN_SESSION_ID
|
||||
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
|
||||
PersistedRoute.InactiveSession ->
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage)
|
||||
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
|
||||
|
||||
// 屏幕上就是主会话:她这句话已经显示在聊天流里(ChatViewModel 那侧并入)⇒ 与
|
||||
// message_persisted 的同一档一样「看到即已读」,免得抽屉为她刚说的话亮一个红点。
|
||||
|
||||
@@ -114,6 +114,27 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* 「免打扰」单独占一段,不塞进上面那个「聊天」里:它管的是**通知**(屏幕上会不会被
|
||||
* 盖住),与「聊天页怎么渲染」是两件事。下一段「连接」也是同一个道理(那边问的是连接
|
||||
* 会不会断)。
|
||||
*
|
||||
* 写法与「后台保持连接」那一行逐项对齐(`KeepAliveRow.kt`):`SwitchPreference` 外面
|
||||
* 必须包一层 `PreferenceCard`(Miuix 的 Preference 系列自己没有背景与圆角),副文案
|
||||
* 走一个纯函数(好单测,见 `proactiveQuietSummary`),改开关只写偏好、**不在这里做
|
||||
* 任何通知相关的事**——发通知时自己会现读(见 `AppSettings.current`)。
|
||||
*/
|
||||
SectionTitle("通知")
|
||||
PreferenceCard {
|
||||
SwitchPreference(
|
||||
title = "免打扰",
|
||||
summary = proactiveQuietSummary(prefs.proactiveQuiet),
|
||||
checked = prefs.proactiveQuiet,
|
||||
onCheckedChange = { scope.launch { settings.setProactiveQuiet(it) } },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
SectionTitle("她忙时发消息")
|
||||
InterruptMode.entries.forEach { mode ->
|
||||
PreferenceCard {
|
||||
@@ -261,3 +282,19 @@ private fun InterruptMode.summary(): String = when (this) {
|
||||
InterruptMode.Parallel ->
|
||||
"她正忙时不打断当前这一句,另起一路同时处理这条;两路各算一遍,会多花一份算力与 token"
|
||||
}
|
||||
|
||||
/**
|
||||
* 「免打扰」开关下面那行字(**纯函数**,测试钉住)。它要说清的只有一件事:
|
||||
* **这不是静音** —— 关掉的是横幅,不是消息、也不是提示音。
|
||||
*
|
||||
* 为什么非说不可:「免打扰」四个字在系统里到处都是,用户对它的第一印象常是「什么都不响」。而这个
|
||||
* 开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住我正在做的事」——两句话指向的期待
|
||||
* 差得很远,一句不说就等于让用户自己猜(猜错的代价是他以为通知坏了,或以为自己买到了完全静音)。
|
||||
*
|
||||
* 两档都说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,而只写「开着 = 安静」
|
||||
* 等于让他自己去比较两种安静。
|
||||
*/
|
||||
internal fun proactiveQuietSummary(enabled: Boolean): String = when {
|
||||
enabled -> "已开:她主动找你说话只在通知栏提示(有提示音),不在屏幕顶部弹横幅"
|
||||
else -> "关(默认):她主动找你说话会在屏幕顶部弹横幅,同时有提示音与通知栏条目"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.notify.AppPresence
|
||||
import top.yeij.cyrene.core.notify.ProactiveNotifier
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
/**
|
||||
* 「她主动说的话」在**帧这一层**的消费点(W57)。
|
||||
*
|
||||
* 这里钉的是**接线**,不是判据(判据与去重仍在 `ProactiveNotifierTest`):
|
||||
* 1. **它不需要任何 Activity 活着就能收帧**——改前那份订阅挂在 `SessionsViewModel` 的
|
||||
* `viewModelScope` 上,从最近任务划掉 Activity 就没了,而 `ChatSocket.events` 是 `replay = 0`
|
||||
* 的 `SharedFlow` ⇒ 没有订阅者的帧**当场丢掉**。本类的第一条测试就是那个 bug 的现场;
|
||||
* 2. 哪些帧算「她主动说的话」、四个入参各取帧上的哪个键([proactiveFrameOf])——错在这两个方向
|
||||
* 都是静默的(她开口了却不弹、普通消息也弹),所以逐键钉住;
|
||||
* 3. 判据与去重在新的消费点上**没有被放松**:两条路仍只弹一次、看着主会话仍不弹、别的会话仍不弹。
|
||||
*
|
||||
* 收集用的调度器传 `Dispatchers.Unconfined`:`launch` 与 `tryEmit` 都在当前线程上把 collect 体
|
||||
* 走完,于是测试是同步的(不需要等、也不需要 `runBlocking`)。
|
||||
* **测不了的**:真·进程存活(Activity 没了而进程还在)这件事只有真机能造,本层只能钉住「订阅不挂
|
||||
* 在任何会随 UI 结束的 scope 上」这个可测的部分。
|
||||
*/
|
||||
class ProactiveFrameIntakeTest {
|
||||
|
||||
private val presence = AppPresence()
|
||||
private val posted = mutableListOf<String>()
|
||||
|
||||
/** 与 `ChatSocket` 同一档的订阅方式:无 replay、有缓冲、溢出丢最旧。 */
|
||||
private val events = MutableSharedFlow<WsEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = EVENT_BUFFER,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** 真实的帧(键名与类型都对服务端):实时帧带落库行 `id` + IM 号 + RFC3339 时间戳。 */
|
||||
private val liveJson = """
|
||||
{"type":"proactive_message","data":{"platform":"cyrene-backthink","channelId":"chan-1","channelType":"private","content":"记得喝水哦","contentType":"text","sender":"Cyrene","timestamp":"2026-10-06T01:20:05.123+08:00","messageId":"im-7","id":"1371","createdAt":1791212405123}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 同一条消息的另一条路:IM 桥那条路由补发的落库帧(判据键在载荷里)。 */
|
||||
private val persistedProactiveJson = """
|
||||
{"type":"message_persisted","sessionId":"web","data":{"proactive":true,"message":{"id":"1371","sessionId":"web","role":"assistant","content":"记得喝水哦","createdAt":1791212405123}}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 普通消息的落库广播(她正常回话):**不是**她主动说的话。 */
|
||||
private val ordinaryPersistedJson = """
|
||||
{"type":"message_persisted","sessionId":"web","data":{"message":{"id":"1372","sessionId":"web","role":"assistant","content":"在的","createdAt":1791212405123}}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 她主动说的、但不属主会话:绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。 */
|
||||
private val liveJsonForAnotherSession = """
|
||||
{"type":"proactive_message","sessionId":"sess-abc","data":{"content":"在吗","messageId":"im-9","id":"1373"}}
|
||||
""".trimIndent()
|
||||
|
||||
private fun intake() = ProactiveFrameIntake(
|
||||
events = events,
|
||||
notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text }),
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
private fun parse(json: String): WsEvent = requireNotNull(WsEvent.parse(json)) { "帧本身要能成形" }
|
||||
|
||||
// —— 这一档就是那个 bug 的现场 ——
|
||||
|
||||
@Test
|
||||
fun `her words still notify when no activity is alive`() {
|
||||
// Activity 已经从最近任务里划掉(没有任何实例在 started 档、聊天页也不在),进程被
|
||||
// KeepAliveService 撑着、连接照旧在收帧。改前这里是空的:订阅随 VM 一起没了。
|
||||
assertFalse("这一档的前提就是前台没有任何 Activity", presence.appInForeground)
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
|
||||
assertEquals(listOf("记得喝水哦"), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the notification id comes from the notifier not from this layer`() {
|
||||
// 号必须按消息派生(同一条 ⇒ 同一个号、不同的句子各占一条)。这一层只转交帧,不许自己
|
||||
// 造号——这里用一个把号也记下来的假 post 确认它拿到的确实是「按消息派生」的那个值。
|
||||
val ids = mutableListOf<Int>()
|
||||
ProactiveFrameIntake(
|
||||
events = events,
|
||||
notifier = ProactiveNotifier(presence = presence, post = { _, id -> ids += id }),
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
events.tryEmit(parse(ordinaryPersistedJson))
|
||||
|
||||
assertEquals(1, ids.size)
|
||||
}
|
||||
|
||||
// —— 判据与去重在新的消费点上没有被放松 ——
|
||||
|
||||
@Test
|
||||
fun `the live frame and the persisted frame of one message notify once`() {
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再弹。
|
||||
events.tryEmit(parse(persistedProactiveJson))
|
||||
|
||||
assertEquals(listOf("记得喝水哦"), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame that lands on screen while the main session is open does not notify`() {
|
||||
// 用户正看着她说话的那个会话:话就出现在他眼前,不打扰(判据本身在 ProactiveNotify.kt)。
|
||||
presence.onActivityStarted(Any())
|
||||
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
|
||||
assertEquals(emptyList<String>(), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame for another session never notifies`() {
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJsonForAnotherSession))
|
||||
|
||||
assertEquals(emptyList<String>(), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary persisted frame never notifies`() {
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(ordinaryPersistedJson))
|
||||
|
||||
assertEquals(emptyList<String>(), posted)
|
||||
}
|
||||
|
||||
// —— 哪些帧算她的话、四个入参各取自帧上的哪里 ——
|
||||
|
||||
@Test
|
||||
fun `only the two proactive shapes are frames of her words`() {
|
||||
assertNull("普通落库广播不是", proactiveFrameOf(parse(ordinaryPersistedJson)))
|
||||
assertNull("流式增量不是", proactiveFrameOf(WsEvent.MessageDelta(delta = "在的", index = 0)))
|
||||
assertNull("开始帧不是", proactiveFrameOf(WsEvent.MessageStart(index = 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the live frame takes the history row id and the im id apart`() {
|
||||
val frame = requireNotNull(proactiveFrameOf(parse(liveJson)))
|
||||
|
||||
// 帧壳不带会话归属 ⇒「无法判定」,由「她主动说的话落主会话」这条事实兜住(见 ProactiveNotifier)。
|
||||
assertNull(frame.sessionId)
|
||||
assertEquals("1371", frame.id)
|
||||
assertEquals("im-7", frame.messageId)
|
||||
assertEquals("2026-10-06T01:20:05.123+08:00", frame.timestamp)
|
||||
assertEquals("记得喝水哦", frame.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the persisted frame has only the row id and never invents the other two`() {
|
||||
val frame = requireNotNull(proactiveFrameOf(parse(persistedProactiveJson)))
|
||||
|
||||
assertEquals(SessionSummaryDto.MAIN_SESSION_ID, frame.sessionId)
|
||||
assertEquals("1371", frame.id)
|
||||
// 帧上就没有这两个键。编一个出来(比如把 `id` 也当 IM 号)就是伪造服务端键,去重会跟着漂。
|
||||
assertNull(frame.messageId)
|
||||
assertNull(frame.timestamp)
|
||||
assertEquals("记得喝水哦", frame.content)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 与 `ChatSocket.EVENT_BUFFER` 同档:订阅方不设上限、发射方不阻塞。 */
|
||||
const val EVENT_BUFFER = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.ShareTextSource
|
||||
|
||||
/**
|
||||
* 三个文本 extra(`EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE`)**原始值**的读取
|
||||
* ([shareTextExtrasOf])。
|
||||
*
|
||||
* ## 为什么值得单测
|
||||
* 这一层只有两件事要做,而两件都容易错:
|
||||
*
|
||||
* 1. **按形态读**(`as? CharSequence`):真实分享里这几段常是 `Spanned`(富文本),按 `String` 读会
|
||||
* 直接拿到 `null` —— 那正是「分享进来什么都没发生」的来处之一。而「读不出类型」(值是个数字、
|
||||
* 别种 `Parcelable`……)时本端**不猜**:那一站读不到就是读不到,交给下一站。
|
||||
* 2. **「里面躺着值」要如实报上去**([ShareTextExtras.present]):值与正文是两件事。空白值、读不出
|
||||
* 类型的值都算「对方给了这个字段」,于是三个 extra 一起落空时本端会走「读不进来」那条如实告知,
|
||||
* 而不是静默(判据见 `ShareRules.extract` 的 `textPresent`)。
|
||||
*
|
||||
* ⚠️ 这里**不**覆盖 `Bundle.getCharSequence` 到底抛不抛异常:那要真安卓运行时(JVM 单测里 android.jar
|
||||
* 的桩方法一调就抛 `Stub!`)。结论与出处写在 `ShareIntake.kt` 的 `rawExtra` / [ShareTextExtras] 的
|
||||
* KDoc 里(核的是 AOSP 的 `android/os/BaseBundle.java`:类型不符它自己 catch 掉回 `null`,会抛的是
|
||||
* `unparcel()` 的 `BadParcelableException`,那一路由 `runCatching` 挡住)。
|
||||
*/
|
||||
class ShareTextExtrasTest {
|
||||
|
||||
@Test
|
||||
fun `a plain string text is read through`() {
|
||||
val extras = shareTextExtrasOf(text = "正文", subject = null, title = null)
|
||||
|
||||
assertEquals("正文", extras.text)
|
||||
assertNull(extras.subject)
|
||||
assertNull(extras.title)
|
||||
assertTrue(extras.present)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a spanned like char sequence is read through`() {
|
||||
// 分享链路里的 `EXTRA_TEXT` 常常是 `Spanned`(带样式的 HTML 富文本):它**不是** String,
|
||||
// 按 String 读会直接拿到 null。(JVM 单测造不出 `android.text.Spanned`,用另一个
|
||||
// `CharSequence` 实现把这条读法钉住。)
|
||||
val raw: Any = StringBuilder("来自网页的一段富文本")
|
||||
|
||||
val extras = shareTextExtrasOf(text = raw, subject = null, title = null)
|
||||
|
||||
assertEquals("来自网页的一段富文本", extras.text)
|
||||
assertTrue(extras.present)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a value we cannot read as a char sequence yields no body but is still a value`() {
|
||||
// 形态读不出来(这里是数字,真实世界是别种 Parcelable):本端不猜它是什么意思 —— 但也不许
|
||||
// 因此把整次分享当成「什么都没发生」,所以它照样算「里面躺着值」。
|
||||
val extras = shareTextExtrasOf(text = 42, subject = null, title = null)
|
||||
|
||||
assertNull(extras.text)
|
||||
assertTrue(extras.present)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank value is still a value`() {
|
||||
// 空白也算「对方给了这个字段」:`EXTRA_STREAM` 那边早就是这个口径(原始值非 null 即
|
||||
// `streamPresent`),两个 extra 用同一条判据才不会出现「流的空值说话、文本的空值闭嘴」。
|
||||
val extras = shareTextExtrasOf(text = " ", subject = "", title = null)
|
||||
|
||||
assertEquals(" ", extras.text)
|
||||
assertEquals("", extras.subject)
|
||||
assertTrue(extras.present)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent trio yields nothing and is not a value`() {
|
||||
// 「连值都没有」才是真的没有:这条边界撑着「用户确实什么都没分享 ⇒ 一个字都不说」。
|
||||
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
|
||||
|
||||
assertNull(extras.text)
|
||||
assertNull(extras.subject)
|
||||
assertNull(extras.title)
|
||||
assertFalse(extras.present)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subject only pair of raw values lands as the body`() {
|
||||
// 端到端那一步:读出来的三段交给纯函数之后,只有 SUBJECT 的那次分享真的变成了正文,
|
||||
// 并且来源如实记着。
|
||||
val extras = shareTextExtrasOf(text = null, subject = "对方只给了标题", title = null)
|
||||
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
text = extras.text,
|
||||
subject = extras.subject,
|
||||
title = extras.title,
|
||||
textPresent = extras.present,
|
||||
)
|
||||
|
||||
assertEquals("对方只给了标题", share.text)
|
||||
assertEquals(ShareTextSource.Subject, share.textSource)
|
||||
assertTrue(share.worthDelivering)
|
||||
assertFalse(share.contentUnreadable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `raw values that are all unusable speak instead of vanishing`() {
|
||||
// 三个 extra 里躺着值、而一段正文都没读出来:内容照旧为「空」,但**必须**有话可说 ——
|
||||
// 这是本次要修的另一种静默(此前只有 `EXTRA_TEXT` 那一路,后两个 extra 落空时全静默)。
|
||||
val extras = shareTextExtrasOf(text = 42, subject = " ", title = null)
|
||||
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
text = extras.text,
|
||||
subject = extras.subject,
|
||||
title = extras.title,
|
||||
textPresent = extras.present,
|
||||
)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertTrue(share.contentUnreadable)
|
||||
assertTrue(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing at all in the bundle is not a value`() {
|
||||
// 与上一条成对:`ShareIntake` 拿到的原始值全是 null(误触分享、Intent 里什么都没有)时,
|
||||
// 本端一个字都不说 —— 边界不许因为这批改动而放宽。
|
||||
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
|
||||
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
text = extras.text,
|
||||
subject = extras.subject,
|
||||
title = extras.title,
|
||||
textPresent = extras.present,
|
||||
)
|
||||
|
||||
assertFalse(share.worthDelivering)
|
||||
assertFalse(share.contentUnreadable)
|
||||
}
|
||||
}
|
||||
@@ -136,8 +136,78 @@ class ProactiveNotificationShapeTest {
|
||||
"两条渠道各自表达一件事,混在一条里就只能二选一",
|
||||
PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
|
||||
)
|
||||
// 恒定不变:改 id = 换渠道 = 用户对旧渠道做过的静音设置全部作废。
|
||||
assertEquals("proactive", PROACTIVE_CHANNEL_ID)
|
||||
// 🔴 这里是 `proactive_v2` 而**不是** `proactive`:渠道重要级一旦创建就改不了(只有用户能改),
|
||||
// 2026-10-07 把它从 DEFAULT 提到 HIGH 对**已建过旧渠道**的设备不生效 ⇒ 只能换一个新 id
|
||||
// 让系统按新重要级重新建。这条断言就是「以后谁要改重要级,必须同时换 id」的机器可读版本。
|
||||
assertEquals("proactive_v2", PROACTIVE_CHANNEL_ID)
|
||||
assertEquals("proactive", LEGACY_PROACTIVE_CHANNEL_ID)
|
||||
assertTrue(
|
||||
"新 id 必须不同于旧 id —— 相同就说明有人改了重要级却没换 id(对老设备无效)",
|
||||
PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「免打扰」那条渠道:它必须是**另一条**,而且是**安静的那一条**。
|
||||
*
|
||||
* 这几件事各有各的坏法,且都很难在真机上发现:
|
||||
* - 与 `PROACTIVE_CHANNEL_ID` 同 id ⇒ 开关两档发到同一条渠道,「开着免打扰照样弹横幅」;
|
||||
* - 重要级不低于 HIGH ⇒ 它照样弹横幅,用户按了开关却什么都没变(而**不会有任何报错**);
|
||||
* - 撞上旧 id ⇒ 建新渠道时会被顺手删掉(那条通知随后就被系统静默丢弃)。
|
||||
*/
|
||||
@Test
|
||||
fun `the do-not-disturb channel is a second, quieter channel`() {
|
||||
assertEquals("proactive_quiet", QUIET_PROACTIVE_CHANNEL_ID)
|
||||
assertEquals(
|
||||
"免打扰那一档只许是 DEFAULT(有提示音、不弹横幅):HIGH 会照样弹横幅",
|
||||
android.app.NotificationManager.IMPORTANCE_DEFAULT,
|
||||
QUIET_PROACTIVE_IMPORTANCE,
|
||||
)
|
||||
assertTrue(
|
||||
"两条渠道必须真的不同 —— 同 id 就等于「开关两档没有区别」",
|
||||
QUIET_PROACTIVE_CHANNEL_ID != PROACTIVE_CHANNEL_ID,
|
||||
)
|
||||
assertTrue(
|
||||
"它也不许撞上旧 id(否则建新渠道那一步会把自己删掉)",
|
||||
QUIET_PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
|
||||
)
|
||||
assertTrue(
|
||||
"更不许撞上保活那条(否则用户对保活的静音设置会被她的话一起改掉)",
|
||||
QUIET_PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开关 → 渠道 id 的**纯函数**(两档)。这是「免打扰」唯一的落地形状:横幅由渠道重要级决定,
|
||||
* 而重要级建完就改不了 ⇒ 只能按开关选一条渠道发。
|
||||
*
|
||||
* 同时钉住 `ProactiveChannelSpec` 的**成对性**:id / 渠道名 / 重要级必须一起跟着开关走。四处
|
||||
* 各写一份 `if (quiet)` 时的坏法是「换了 id 却没换重要级」,症状是「按了开关,什么都没变」。
|
||||
*/
|
||||
@Test
|
||||
fun `the switch picks the channel and the whole spec moves with it`() {
|
||||
assertEquals(PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = false))
|
||||
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = true))
|
||||
|
||||
val loud = proactiveChannelSpec(quiet = false)
|
||||
val quiet = proactiveChannelSpec(quiet = true)
|
||||
assertEquals(PROACTIVE_CHANNEL_ID, loud.id)
|
||||
assertEquals(PROACTIVE_CHANNEL_NAME, loud.name)
|
||||
assertEquals(PROACTIVE_IMPORTANCE, loud.importance)
|
||||
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, quiet.id)
|
||||
assertEquals(QUIET_PROACTIVE_CHANNEL_NAME, quiet.name)
|
||||
assertEquals(QUIET_PROACTIVE_IMPORTANCE, quiet.importance)
|
||||
|
||||
// 系统设置的渠道列表里这两行会并排出现,用户就是靠它判断「哪条会弹横幅」——同名等于白分两条。
|
||||
assertNotEquals(
|
||||
"两条渠道名必须一眼看得出区别",
|
||||
PROACTIVE_CHANNEL_NAME,
|
||||
QUIET_PROACTIVE_CHANNEL_NAME,
|
||||
)
|
||||
assertTrue(
|
||||
"免打扰那条的渠道名要点明它是什么:$QUIET_PROACTIVE_CHANNEL_NAME",
|
||||
QUIET_PROACTIVE_CHANNEL_NAME.contains("免打扰"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -147,7 +217,13 @@ class ProactiveNotificationShapeTest {
|
||||
"她要提醒人,重要级必须高于保活那条",
|
||||
PROACTIVE_IMPORTANCE > top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_IMPORTANCE,
|
||||
)
|
||||
assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE)
|
||||
// `HIGH`(4) 是**屏幕顶部弹横幅**的门槛:DEFAULT 只会响一声躺在通知栏里。
|
||||
// 主人 2026-10-07 的原话「我在其他 APP 时没看到横幅」就是 DEFAULT 这一档造成的。
|
||||
assertEquals(android.app.NotificationManager.IMPORTANCE_HIGH, PROACTIVE_IMPORTANCE)
|
||||
assertTrue(
|
||||
"免打扰那一档必须比它低 —— 否则「只关横幅」这件事就没有档位可用了",
|
||||
QUIET_PROACTIVE_IMPORTANCE < PROACTIVE_IMPORTANCE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 「免打扰」这个设置项的形状(默认值 + 读写往返)。
|
||||
*
|
||||
* `AppSettings` 本体要 Android + DataStore、单测里起不来(与 `InterruptModeTest` 同一条理由),
|
||||
* 所以钉的是它两侧的**纯**部分。三件事各自对应一种真会发生的坏法:
|
||||
*
|
||||
* 1. **默认必须是 `false`(都弹)**:主人 2026-10-07 的话是「关闭时两者都弹,**默认都弹**」——
|
||||
* 一个默认就安静的 App 会让人以为通知坏了(而这一条恰恰没有任何报错);
|
||||
* 2. **数据类的默认参数与落盘读取口的缺省值必须是同一个值**:它们表达的是同一个「缺省」,
|
||||
* 分叉的症状是「开关显示关着、行为却安静」(或反过来),同样没有任何报错;
|
||||
* 3. **读写往返**:写进去什么就读出什么,两个布尔都走一遍。这一档没有 `Enum` 那种映射层,
|
||||
* 唯一会坏的方式就是某个分支被写反,而写反的后果正好是「开关与行为相反」。
|
||||
*/
|
||||
class AppPrefsQuietTest {
|
||||
|
||||
@Test
|
||||
fun `do not disturb is off by default so both keep alerting`() {
|
||||
assertFalse(
|
||||
"默认必须是「都弹」:默认就安静只会让人以为通知坏了",
|
||||
AppPrefs().proactiveQuiet,
|
||||
)
|
||||
assertFalse("缺省常量本身就是默认值,它不能是 true", PROACTIVE_QUIET_DEFAULT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the data-class default and the read seam agree on one default`() {
|
||||
// 两条路读的是同一个「缺省」:进程刚起来(数据类默认参数)与盘上没有这个键(读取口)。
|
||||
assertEquals(
|
||||
"数据类的默认参数与落盘读取口的缺省值必须同源",
|
||||
AppPrefs().proactiveQuiet,
|
||||
(null as Boolean?).toProactiveQuiet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the switch round-trips from the data class through the read seam`() {
|
||||
listOf(false, true).forEach { wanted ->
|
||||
val written = AppPrefs(proactiveQuiet = wanted).proactiveQuiet
|
||||
|
||||
assertEquals("写进去什么就得读出什么", wanted, (written as Boolean?).toProactiveQuiet())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* `POST /api/v1/audio/speech` 的**运行时接线**:`ApiClientFactory.create`(主 REST client)→
|
||||
* [SpeechApi]。
|
||||
*
|
||||
* 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,
|
||||
* 与 [RestAttemptClientTest] 同一套做法,不引任何新依赖)。
|
||||
*
|
||||
* ## 它钉住的四条事实(每条都是判据,不是描述)
|
||||
* 1. 请求真的写出去,落到 `POST <baseUrl>api/v1/audio/speech` —— 路径**不带前导 `/`**,
|
||||
* 否则部署在子路径下的服务端地址会被整段丢掉([SpeechApi] 的 KDoc 写的就是这条)。
|
||||
* 2. 它带着 **owner Bearer 令牌**与 `X-API-Version: 1`:服务端是 owner-only 的双凭据门,
|
||||
* 本端只走 Bearer 那一支(主人 2026-10-26 拍板不设 API Key 输入框)。
|
||||
* 3. `sessionId` **有值时随体发出**——发音人由那条会话的生效人格决定,这是它唯一的来源。
|
||||
* 4. `sessionId` 为 `null` 时字段**整个缺席**(`explicitNulls = false`),而不是发一个
|
||||
* `"sessionId": null`:契约里「省略」与「显式空」是两件事。
|
||||
*/
|
||||
class SpeechApiTest {
|
||||
|
||||
private val backend = FakeSpeechBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
/** 与生产同形:主 REST client(带令牌与 401 策略),只是 baseUrl 指向本地假端点。 */
|
||||
private fun api(baseUrl: String = backend.baseUrl) = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = SpeechApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
)
|
||||
|
||||
/** 发一次真请求并把响应体关掉(`@Streaming` 的体必须由调用方关闭)。 */
|
||||
private suspend fun speech(dto: SpeechRequestDto) {
|
||||
val response = withTimeoutOrNull(TIMEOUT_MILLIS) { api().speech(dto) }
|
||||
response?.body()?.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the request really puts a POST api v1 audio speech on the wire`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好"))
|
||||
|
||||
// 请求行不含 HTTP 版本(HttpExchange 只给 method + URI),判据是「方法 + 路径」。
|
||||
assertEquals("POST /api/v1/audio/speech", backend.requestLines.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the request carries the owner bearer token and the api version`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好"))
|
||||
|
||||
val headers = backend.headers.single()
|
||||
assertEquals("Bearer $TOKEN", headers["authorization"])
|
||||
assertEquals("1", headers["x-api-version"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session id travels in the body because it decides the voice`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好", sessionId = SESSION_ID))
|
||||
|
||||
val raw = backend.bodies.single()
|
||||
val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw)
|
||||
assertEquals("你好", sent.text)
|
||||
assertEquals(SESSION_ID, sent.sessionId)
|
||||
assertTrue("sessionId 该随体发出,实际 $raw", raw.contains("\"sessionId\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null session id leaves the field out entirely instead of sending null`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
speech(SpeechRequestDto(text = "你好"))
|
||||
|
||||
val raw = backend.bodies.single()
|
||||
val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw)
|
||||
assertNull(sent.sessionId)
|
||||
// 「省略」与「显式 null」在契约里是两件事:后者会让服务端以为本端知道是哪个会话。
|
||||
assertFalse("sessionId 为 null 时不该出现这个键,实际 $raw", raw.contains("sessionId"))
|
||||
}
|
||||
|
||||
/** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**、请求头原文与请求体原文。 */
|
||||
private inner class FakeSpeechBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/audio/speech", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val requestLines = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
|
||||
val headers = CopyOnWriteArrayList<Map<String, String>>()
|
||||
val bodies = CopyOnWriteArrayList<String>()
|
||||
|
||||
@Volatile
|
||||
private var status = 200
|
||||
|
||||
@Volatile
|
||||
private var payload = WAV
|
||||
|
||||
fun reply(status: Int, body: ByteArray) {
|
||||
this.status = status
|
||||
this.payload = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requestLines += "${exchange.requestMethod} ${exchange.requestURI}"
|
||||
headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") }
|
||||
bodies += String(exchange.requestBody.readBytes(), Charsets.UTF_8)
|
||||
exchange.responseHeaders.add("Content-Type", "audio/wav")
|
||||
exchange.sendResponseHeaders(status, payload.size.toLong())
|
||||
exchange.responseBody.use { it.write(payload) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SESSION_ID = "web"
|
||||
const val TOKEN = "test-token"
|
||||
const val TIMEOUT_MILLIS = 10_000L
|
||||
|
||||
/** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */
|
||||
val WAV: ByteArray =
|
||||
"RIFF".toByteArray(Charsets.US_ASCII) +
|
||||
byteArrayOf(36, 0, 0, 0) +
|
||||
"WAVE".toByteArray(Charsets.US_ASCII) +
|
||||
ByteArray(40)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.domain.chat.VoiceSource
|
||||
import top.yeij.cyrene.domain.chat.VoiceStatus
|
||||
|
||||
/**
|
||||
* `voice` 键的解码钉子(契约 **v0.67 登记、v0.79 已实现**,api-client.md §4.4 与 §1.2 U19)。
|
||||
*
|
||||
* 形状来源就是契约里那两处(示例 JSON + 语义段),**逐键手写**(没有 v0.79 的真实抓包);
|
||||
* 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。
|
||||
*
|
||||
* 要钉住的六件事:
|
||||
* 1. **键名逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url`,
|
||||
* 外加**只在 `status = "failed"` 时**出现的第八个键 `fallbackReason`(后端 M57-M59 预告,
|
||||
* 契约 v0.98 的正式回执尚未到)—— 键名不许自己发明(形状错一个字母线上就是「不是语音」);
|
||||
* 2. **缺席与 `null` 是同一件事**:非语音消息整键缺席 ⇒ `null`(不是 `{}`,也不是别的哨兵);
|
||||
* 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉);
|
||||
* 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」);
|
||||
* 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢);
|
||||
* `fallbackReason` 反过来**不是**闭集——本端原样留着、不做任何映射;
|
||||
* 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。
|
||||
*/
|
||||
class VoiceDtoTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
/** 契约 §4.4 的示例逐字(含四个副本键 + `durationMs`)。 */
|
||||
private val transcribed = """
|
||||
{
|
||||
"id": "msg-1",
|
||||
"sessionId": "web",
|
||||
"role": "user",
|
||||
"content": "明天几点出发",
|
||||
"createdAt": 1700000000000,
|
||||
"voice": {
|
||||
"source": "asr",
|
||||
"status": "transcribed",
|
||||
"name": "$name",
|
||||
"mime": "audio/ogg",
|
||||
"size": 12345,
|
||||
"durationMs": 3200,
|
||||
"url": "/api/v1/media/$name"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun decode(body: String) = json.decodeFromString(HistoryMessageDto.serializer(), body)
|
||||
|
||||
@Test
|
||||
fun `a transcribed voice decodes the seven keys verbatim`() {
|
||||
val voice = decode(transcribed).voice!!
|
||||
|
||||
assertEquals(VoiceSource.ASR, voice.source)
|
||||
assertEquals(VoiceStatus.TRANSCRIBED, voice.status)
|
||||
assertEquals(name, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals(12345L, voice.size)
|
||||
assertEquals(3200, voice.durationMs)
|
||||
// url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。
|
||||
assertEquals("/api/v1/media/$name", voice.url)
|
||||
assertFalse(voice.url!!.startsWith("http"))
|
||||
// 转写成功的行不该有降级原因(服务端 `omitempty` 不下发)⇒ 解成 null,显示层据此不加小标。
|
||||
assertNull(voice.fallbackReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message without the voice key decodes to null, not to an empty object`() {
|
||||
val body = """
|
||||
{"id":"msg-2","sessionId":"web","role":"user","content":"在的","createdAt":1700000000001}
|
||||
""".trimIndent()
|
||||
|
||||
assertNull("🎤 判据 = 键存在:缺席必须是 null", decode(body).voice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null is the same thing as an absent key`() {
|
||||
// 契约说 `voice` 是**指针**、非语音消息整键缺席(不是 null);真收到 null 也按「没有」处理。
|
||||
val body = """
|
||||
{"id":"msg-3","sessionId":"web","role":"user","content":"在的","createdAt":1700000000002,
|
||||
"voice":null}
|
||||
""".trimIndent()
|
||||
|
||||
assertNull(decode(body).voice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice with no audio copy has all four copy keys absent`() {
|
||||
// 微信(weixin-ilink):平台自带转写就不白下载那份音频 ⇒ 只有 🎤 标记,四个键全缺席。
|
||||
// ⚠️ 时长与副本**无关**:渠道给了就留着(所以这条夹具带着 durationMs,而四个副本键照样全缺)。
|
||||
val body = """
|
||||
{"id":"msg-4","sessionId":"web","role":"user","content":"在忙吗","createdAt":1700000000003,
|
||||
"voice":{"source":"platform","status":"transcribed","durationMs":5000}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(VoiceSource.PLATFORM, voice.source)
|
||||
assertNull(voice.name)
|
||||
assertNull(voice.mime)
|
||||
assertNull(voice.size)
|
||||
assertNull(voice.url)
|
||||
assertEquals(5000, voice.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy keeps the four keys together including size zero and a missing duration`() {
|
||||
// durationMs 缺席(服务端读不出来时不估算)——**其余四个键照样齐全**。
|
||||
val body = """
|
||||
{"id":"msg-5","sessionId":"web","role":"user","content":"","createdAt":1700000000004,
|
||||
"voice":{"source":"asr","status":"empty","name":"$name","mime":"audio/ogg",
|
||||
"size":0,"url":"/api/v1/media/$name"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(name, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals("size 为 0 也照样解码(本端不拿它推导任何东西)", 0L, voice.size)
|
||||
assertEquals("/api/v1/media/$name", voice.url)
|
||||
assertNull("缺席 ≠ 0:必须是 null,才能让界面「一个字符都不画」", voice.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration reported by the channel is kept as is`() {
|
||||
val body = """
|
||||
{"id":"msg-6","sessionId":"web","role":"user","content":"","createdAt":1700000000005,
|
||||
"voice":{"source":"asr","status":"transcribed","durationMs":60000}}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(60_000, decode(body).voice!!.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the three statuses and both sources are kept verbatim, and an unknown one is not guessed`() {
|
||||
val cases = listOf(
|
||||
VoiceStatus.TRANSCRIBED to VoiceSource.ASR,
|
||||
VoiceStatus.EMPTY to VoiceSource.ASR,
|
||||
VoiceStatus.FAILED to VoiceSource.PLATFORM,
|
||||
// 认不出的取值:原样保留(本端不猜、不报错、不丢——显示层对它们与本端不认识时同形)。
|
||||
"future-status" to "future-source",
|
||||
)
|
||||
|
||||
cases.forEach { (status, source) ->
|
||||
val body = """
|
||||
{"id":"msg-7","sessionId":"web","role":"user","content":"x","createdAt":1700000000006,
|
||||
"voice":{"source":"$source","status":"$status"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(status, voice.status)
|
||||
assertEquals(source, voice.source)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown extra key inside voice does not break the message`() {
|
||||
val body = """
|
||||
{"id":"msg-8","sessionId":"web","role":"user","content":"x","createdAt":1700000000007,
|
||||
"voice":{"source":"asr","status":"transcribed","waveform":[1,2,3]}}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(VoiceStatus.TRANSCRIBED, decode(body).voice!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty voice object decodes as present`() {
|
||||
// 契约说不会下发 `{}`(「不是 {} 也不是 null」),真收到时本端只认「键存在」这一条判据
|
||||
// ⇒ 它算一条语音消息(四个副本键全缺 ⇒ 不画播放控件)。**刻意**在这里钉住这个选择:
|
||||
// 「键在不在」是契约给的唯一判据,本端不额外发明一条「对象够不够完整」的判据。
|
||||
val body = """
|
||||
{"id":"msg-9","sessionId":"web","role":"user","content":"","createdAt":1700000000008,
|
||||
"voice":{}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice
|
||||
assertTrue(voice != null)
|
||||
assertEquals("", voice!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a full voice keeps the seven key names`() {
|
||||
// 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。
|
||||
// 这里没传 `fallbackReason`(非 failed 行本来就不该有)⇒ `explicitNulls = false` 不写这个键,
|
||||
// 键集仍是七个。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(
|
||||
source = VoiceSource.ASR,
|
||||
status = VoiceStatus.TRANSCRIBED,
|
||||
name = name,
|
||||
mime = "audio/ogg",
|
||||
size = 12345,
|
||||
durationMs = 3200,
|
||||
url = "/api/v1/media/$name",
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(
|
||||
setOf("source", "status", "name", "mime", "size", "durationMs", "url"),
|
||||
encoded.keys,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a fallback voice keeps the fallbackReason key`() {
|
||||
// 第八个键(后端 M57-M59 预告):只在 `status = "failed"` 时出现。它在 DTO 上是**真字段**,
|
||||
// 不是靠 `ignoreUnknownKeys` 吞掉的陌生键——否则出站降级那一行写不出去。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(
|
||||
source = VoiceSource.TTS,
|
||||
status = VoiceStatus.FAILED,
|
||||
fallbackReason = "tts_timeout",
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(setOf("source", "status", "fallbackReason"), encoded.keys)
|
||||
assertEquals("tts_timeout", encoded["fallbackReason"]!!.jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fallback voice decodes its reason and has no copy`() {
|
||||
// 降级行:没有可播的声音(四个副本键全缺席),原因原样留在 `fallbackReason` 里——
|
||||
// 本端**不映射、不猜闭集**(取值集合后端尚未定稿),显示层原样小字。
|
||||
val body = """
|
||||
{"id":"msg-10","sessionId":"web","role":"user","content":"想跟你说句话",
|
||||
"createdAt":1700000000009,
|
||||
"voice":{"source":"tts","status":"failed","fallbackReason":"tts_timeout"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(VoiceSource.TTS, voice.source)
|
||||
assertEquals(VoiceStatus.FAILED, voice.status)
|
||||
assertEquals("tts_timeout", voice.fallbackReason)
|
||||
assertNull("降级行没有可播的声音", voice.name)
|
||||
assertNull(voice.mime)
|
||||
assertNull(voice.size)
|
||||
assertNull(voice.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a voice with no copy omits the nullable keys`() {
|
||||
// `explicitNulls = false`(与后端 `omitempty` 同一口径):无副本时那四个键**不写出来**,
|
||||
// 而不是写成 `null`——否则服务端与别的客户端会看到一个「有键但值是 null」的陌生形状。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(source = VoiceSource.PLATFORM, status = VoiceStatus.TRANSCRIBED),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(setOf("source", "status"), encoded.keys)
|
||||
assertEquals(VoiceSource.PLATFORM, encoded["source"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* `GET /api/v1/media/{name}` 的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带的 HTTP 服务端
|
||||
* (与 `DocumentRepositoryTest` / `ImageCacheTest` 同一手法,不引新依赖)。
|
||||
*
|
||||
* 形状与状态码来源:契约 **v0.79** 的 §1.2 **U19** 一行(`200` / `206` / `304` / `401` / `403` /
|
||||
* `404` / `416`;`Content-Type` 是音频类型;`404 media_not_found`)与 §4.5。
|
||||
* **没有真实抓包**:这里的响应是按契约逐条手写的。
|
||||
*
|
||||
* 要证明的四件事:
|
||||
* 1. 请求真的发到**服务端给的那条路径**(`/api/v1/media/<name>`;本端一个字都不拼);
|
||||
* 2. **必须带 `Authorization: Bearer`**(U19 是 `owner|admin` 端点:语音是私密内容);
|
||||
* 3. `200` 回的是**字节本身**(不是 JSON 信封),且一个字不改;
|
||||
* 4. `404 media_not_found` / `401` / 非信封错误体 / 0 字节都折成可分支的失败,
|
||||
* **0 字节不算成功**(那会让下一次播放「命中缓存」一份什么都没有的文件)。
|
||||
*/
|
||||
class MediaRepositoryTest {
|
||||
|
||||
private val backend = FakeMediaBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MediaRepository(
|
||||
ApiClientFactory.create(
|
||||
baseUrl = backend.baseUrl,
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
),
|
||||
)
|
||||
|
||||
/** 一段假的音频字节:这里只关心「字节原样过」,不关心它是不是真能解码。 */
|
||||
private val bytes = ByteArray(64) { it.toByte() }
|
||||
|
||||
@Test
|
||||
fun `a copy is fetched from the path the server gave, with a bearer token`() = runBlocking {
|
||||
backend.reply(200, bytes, "audio/ogg")
|
||||
|
||||
val fetched = repository().download(backend.mediaUrl(backend.name)).getOrThrow()
|
||||
|
||||
assertArrayEquals(bytes, fetched)
|
||||
val request = backend.lastRequest()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals(
|
||||
"请求路径必须**逐字**是服务端给的 voice.url(客户端绝不自己拼)",
|
||||
"/api/v1/media/${backend.name}",
|
||||
request.path,
|
||||
)
|
||||
assertEquals("语音是私密内容:U19 要求 owner|admin,必须带 Bearer", "Bearer $TOKEN", request.authorization)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing copy is reported as not found, not as an empty audio`() = runBlocking {
|
||||
backend.reply(
|
||||
404,
|
||||
"""{"error":{"code":"media_not_found","message":"media not found"}}""".toByteArray(),
|
||||
"application/json",
|
||||
)
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
// 非法名字 / 穿越 / 不存在服务端统一同码(不给探测面)⇒ 用户看到的就是本端那一句
|
||||
// 「服务端上已经没有这条语音了」(**不是**把 media not found 原样抖给用户)。
|
||||
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(error!!))
|
||||
assertNull(repository().download(backend.mediaUrl(backend.name)).getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non envelope error body is not parsed as an envelope`() = runBlocking {
|
||||
// 416(Range 不可满足)由 Go 标准库直写 text/plain + Content-Range,**不套** JSON 信封
|
||||
// (契约 §0.3 / §4.5)⇒ 本端不许拿 error.code 去解析它,也不能因此崩。
|
||||
backend.reply(416, "invalid range".toByteArray(), "text/plain")
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
val mapped = (error as CyreneException).error
|
||||
assertTrue(mapped is CyreneError.Server)
|
||||
assertEquals(
|
||||
"非信封响应不许编出一个 code,也不许炸",
|
||||
"",
|
||||
(mapped as CyreneError.Server).code,
|
||||
)
|
||||
assertEquals(416, mapped.httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unauthorized response is mapped as unauthorized`() = runBlocking {
|
||||
backend.reply(
|
||||
401,
|
||||
"""{"error":{"code":"unauthorized","message":"no token"}}""".toByteArray(),
|
||||
"application/json",
|
||||
)
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue((error as CyreneException).error is CyreneError.Unauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty two hundred is a failure, never a cached empty file`() = runBlocking {
|
||||
backend.reply(200, ByteArray(0), "audio/ogg")
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertEquals(
|
||||
"0 字节必须判失败(否则下一次播放会「命中」一份什么都没有的文件)",
|
||||
CyreneError.Local(CyreneError.LocalKind.Parse),
|
||||
(error as CyreneException).error,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that is not there fails and is classified as a local failure`() = runBlocking {
|
||||
// 对端不在听(换服务器 / adb reverse 掉了)。
|
||||
val dead = MediaRepository(
|
||||
ApiClientFactory.create(
|
||||
baseUrl = "http://127.0.0.1:1/",
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
),
|
||||
)
|
||||
|
||||
val error = dead.download("http://127.0.0.1:1/api/v1/media/${backend.name}").exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
// 只要求「是本地那一档」:具体是哪一种由系统决定,不逐条钉死。
|
||||
assertTrue((error as CyreneException).error is CyreneError.Local)
|
||||
}
|
||||
|
||||
private class FakeMediaBackend {
|
||||
|
||||
val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private val requests = CopyOnWriteArrayList<Request>()
|
||||
private var status = 200
|
||||
private var body = ByteArray(0)
|
||||
private var contentType = "audio/ogg"
|
||||
|
||||
private val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/") { exchange -> handle(exchange) }
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
fun mediaUrl(name: String): String = "${baseUrl}api/v1/media/$name"
|
||||
|
||||
fun reply(status: Int, body: ByteArray, contentType: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
this.contentType = contentType
|
||||
}
|
||||
|
||||
fun lastRequest(): Request = requests.lastOrNull() ?: error("服务端一个请求都没收到")
|
||||
|
||||
fun close() = server.stop(0)
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requests += Request(
|
||||
method = exchange.requestMethod,
|
||||
path = exchange.requestURI.path,
|
||||
authorization = exchange.requestHeaders.getFirst("Authorization"),
|
||||
)
|
||||
exchange.responseHeaders.add("Content-Type", contentType)
|
||||
// 空体用 -1(不是 0):0 会让标准库改用 chunked,那样「0 字节」这条用例测的就不是
|
||||
// 我们想测的那件事了(见 ImageCacheTest 的同名手法)。
|
||||
if (body.isEmpty()) {
|
||||
exchange.sendResponseHeaders(status, -1)
|
||||
} else {
|
||||
exchange.sendResponseHeaders(status, body.size.toLong())
|
||||
}
|
||||
exchange.responseBody.use { it.write(body) }
|
||||
}
|
||||
|
||||
data class Request(val method: String, val path: String, val authorization: String?)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "fake-token-42"
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 增量追尾(W77 `MessageRepository.loadIncrement`)的**数据层**。
|
||||
*
|
||||
* 与 `MessageAfterRepositoryTest` 同一套做法(真 OkHttp + 真 Retrofit + JDK 自带的
|
||||
* `com.sun.net.httpserver`,不引新依赖),只是假后端从「一份固定响应」换成**响应队列**:
|
||||
* 追尾是一次循环,翻页语义只有在「第一页满、第二页空」这种**按次序**的响应下才验得出来。
|
||||
*
|
||||
* 断言分两类,都不看实现细节:
|
||||
* - **线上证据**:每次请求的原始 URI(`after=` 只能有一个值,且随游标前进)、请求次数;
|
||||
* - **结论**:[MessageIncrement.complete] 与 `cursor` —— 这两项是调用方决定「要不要回落整表」的
|
||||
* 唯一依据,错了就是红点做小或做多。
|
||||
*/
|
||||
class MessageIncrementRepositoryTest {
|
||||
|
||||
private val backend = FakeMessagesBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MessageRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a short page is the tail and only the forward cursor goes on the wire`() = runBlocking {
|
||||
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 100).getOrThrow()
|
||||
|
||||
val uri = backend.uris.single()
|
||||
assertTrue(uri, uri.contains("after=100"))
|
||||
assertTrue(uri, uri.contains("limit=50"))
|
||||
assertFalse(uri, uri.contains("before="))
|
||||
assertFalse(uri, uri.contains("q="))
|
||||
// 不满一页 ⇒ 真的追到尾巴了,游标停在最新一条。
|
||||
assertTrue(increment.complete)
|
||||
assertEquals(200L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty first page is a clean tail`() = runBlocking {
|
||||
backend.enqueue(200, """{"data":[]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 300).getOrThrow()
|
||||
|
||||
// 第一页就空 ⇒ 一条新消息都没有,没有可少给的:这是真的追到尾巴,游标原地不动。
|
||||
assertEquals(1, backend.uris.size)
|
||||
assertTrue(backend.uris.single(), backend.uris.single().contains("after=300"))
|
||||
assertTrue(increment.complete)
|
||||
assertEquals(300L, increment.cursor)
|
||||
assertTrue(increment.messages.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty page after a full page is not proof of a clean tail`() = runBlocking {
|
||||
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
backend.enqueue(200, """{"data":[]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow()
|
||||
|
||||
// 满页 ⇒ 多问一次,第二次拿到空页。但客户端**分不出**这次空页是「干净的尾巴」还是
|
||||
// 「空页底下还压着同一毫秒的几条」:服务端只给严格 `> cursor`,被页边界劈开的那一毫秒
|
||||
// 再也追不回来。分不出来就只能说「没追完」,把「要不要回落整表」交回调用方。
|
||||
assertEquals(2, backend.uris.size)
|
||||
assertTrue(backend.uris[1], backend.uris[1].contains("after=200"))
|
||||
assertFalse(increment.complete)
|
||||
assertEquals(200L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short page after a full page is not proof of a clean tail either`() = runBlocking {
|
||||
// 页宽 2、积压 m1@500 / m2@500 / m3@500 / m4@600。第一页给 m1、m2 —— LIMIT 恰好把 500 那一
|
||||
// 毫秒劈开;第二页 after=500 只能给 > 500 的,于是 [m4@600]、不满页。不满只证明「> 500 的行
|
||||
// 就这一条」,对切点那一毫秒还有没有行一无所知:m3@500 既不 > 500,也就永远拿不到。
|
||||
// 空页档与不满页档是同一个洞的两个出口,都要说「没追完」。
|
||||
backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""")
|
||||
backend.enqueue(200, """{"data":[${message("m4", 600)}]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow()
|
||||
|
||||
assertEquals(2, backend.uris.size)
|
||||
assertTrue(backend.uris[1], backend.uris[1].contains("after=500"))
|
||||
assertFalse(increment.complete)
|
||||
assertEquals(600L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2", "m4"), increment.messages.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full page stuck on one millisecond is reported as incomplete`() = runBlocking {
|
||||
// 真后端的形状:m1 / m2 / m3 三条落在同一毫秒,页宽 2。第一页给 m1、m2,第二页问
|
||||
// `after=500` —— 严格 `>` 下后端只会回空页,m3@500 永远拿不到(这里不入队 m3:队列里
|
||||
// 那份空响应就是真后端在 `after=500` 时会给的答案)。
|
||||
backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""")
|
||||
backend.enqueue(200, """{"data":[]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 100, pageSize = 2).getOrThrow()
|
||||
|
||||
assertFalse(increment.complete)
|
||||
// 游标停在那一毫秒(推不动就不装作推得动)。
|
||||
assertEquals(500L, increment.cursor)
|
||||
assertEquals(listOf("m1", "m2"), increment.messages.map { it.id })
|
||||
// 线上证据:第二次真的带上了 `after=500`,即「m3 拿不到」的那一问。
|
||||
assertEquals(2, backend.uris.size)
|
||||
assertTrue(backend.uris[1], backend.uris[1].contains("after=500"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the page budget stops the chase instead of chasing forever`() = runBlocking {
|
||||
// 永远满页:预算只有 1 页时必须停下来并如实说「没追完」。
|
||||
backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
|
||||
val increment = repository()
|
||||
.loadIncrement("web", after = 0, pageSize = 2, maxPages = 1)
|
||||
.getOrThrow()
|
||||
|
||||
assertEquals(1, backend.uris.size)
|
||||
assertFalse(increment.complete)
|
||||
assertEquals(200L, increment.cursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session deleted elsewhere surfaces as session_not_found with 404`() = runBlocking {
|
||||
backend.enqueue(
|
||||
404,
|
||||
"""{"error":{"code":"session_not_found","message":"session \"web:gone\" not found"}}""",
|
||||
)
|
||||
|
||||
val error = repository().loadIncrement("web:gone", after = 0).exceptionOrNull() as CyreneException
|
||||
|
||||
val server = error.error as CyreneError.Server
|
||||
assertEquals("session_not_found", server.code)
|
||||
assertEquals(404, server.httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one increment costs one request and k messages instead of the whole table`() = runBlocking {
|
||||
// 10 个会话的整表长什么样(每条一行预览 + 未读计数):这是被换掉的那条路。
|
||||
val wholeTable = """{"data":[${sessions(10)}]}"""
|
||||
backend.enqueue(200, """{"data":[${message("new", 9_000)}]}""")
|
||||
|
||||
val increment = repository().loadIncrement("web", after = 8_000).getOrThrow()
|
||||
|
||||
assertEquals(1, backend.uris.size)
|
||||
assertEquals(1, increment.messages.size)
|
||||
assertTrue(increment.complete)
|
||||
// 量级证据:增量回的字节数远小于整表(这里只有一条新消息 vs 十行会话预览)。
|
||||
val incrementBytes = backend.responseBytes.single()
|
||||
assertTrue(
|
||||
"increment=$incrementBytes wholeTable=${wholeTable.toByteArray().size}",
|
||||
incrementBytes * 4 < wholeTable.toByteArray().size,
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条 `GET /messages` 形状的消息(字段与 `HistoryMessageDto` 对齐,只留断言用得到的)。 */
|
||||
private fun message(id: String, createdAt: Long): String =
|
||||
"""{"id":"$id","sessionId":"web","role":"user","content":"内容","createdAt":$createdAt}"""
|
||||
|
||||
/** 一段 `GET /sessions` 形状的会话行(约 60 rune 预览 + 未读计数),只用于量级对比。 */
|
||||
private fun sessions(count: Int): String = (1..count).joinToString(",") { i ->
|
||||
val preview = "这是第 $i 个会话的最后一条消息预览,长度大致与真实数据同量级,用来量字节数。"
|
||||
"""{"id":"s$i","type":"secondary","title":"会话 $i","createdAt":${i * 1000},""" +
|
||||
""""pinned":false,"personaId":null,"unreadCount":$i,""" +
|
||||
""""lastMessage":{"id":"p$i","sessionId":"s$i","role":"assistant",""" +
|
||||
""""content":"$preview","createdAt":${i * 1000}}}"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**入队顺序**逐次回响应的假后端:追尾是一次循环,固定响应验不出翻页。
|
||||
*
|
||||
* 队列见底时重复最后一份 —— 这样「永远满页」那种用例只需要入队一次。
|
||||
*/
|
||||
private inner class FakeMessagesBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
/** 每次请求的原始 URI(含 query),按到达顺序。 */
|
||||
val uris = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 每次响应体(UTF-8)的字节数,按到达顺序 —— 量化对比用。 */
|
||||
val responseBytes = CopyOnWriteArrayList<Int>()
|
||||
|
||||
private val queue = ConcurrentLinkedQueue<Pair<Int, String>>()
|
||||
|
||||
private val last = java.util.concurrent.atomic.AtomicReference(200 to """{"data":[]}""")
|
||||
|
||||
fun enqueue(status: Int, body: String) {
|
||||
queue += status to body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
uris += exchange.requestURI.toString()
|
||||
val (status, body) = queue.poll() ?: last.get()
|
||||
last.set(status to body)
|
||||
val bytes = body.toByteArray()
|
||||
responseBytes += bytes.size
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -86,6 +87,35 @@ class MessageMergeTest {
|
||||
assertEquals(listOf("m3"), idsOf(incoming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later copy carrying voice wins over the earlier copy of the same message`() {
|
||||
// 同一条消息会从**多路**到本端:落库帧、握手 `history` 回放、REST 分页、编辑帧。
|
||||
// 按 id 合并时「新到的那一份胜出」是这几路唯一的收口规则——不然后到的那份白到。
|
||||
// 契约 v0.97 之后 `voice` **每一条路都带**(后端 M44 `fb55769`:落库顺序 INSERT →
|
||||
// 写 voice 列 → notify),所以这条规则**不再是**「语音升级的唯一通路」;
|
||||
// 但它照旧必需:谁哪天把「incoming 胜出」改成「保留旧的」,编辑过的正文与任何后补字段
|
||||
// 都会永远不生效。
|
||||
val withoutVoice = msg("m1", 100, content = "明天几点出发")
|
||||
val withVoice = withoutVoice.copy(
|
||||
voice = VoiceDto(
|
||||
source = "asr",
|
||||
status = "transcribed",
|
||||
name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
|
||||
mime = "audio/ogg",
|
||||
size = 12_345,
|
||||
durationMs = 3_200,
|
||||
url = "/api/v1/media/a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
|
||||
),
|
||||
)
|
||||
|
||||
val merged = mergeMessages(listOf(withoutVoice), listOf(withVoice))
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals("m1", merged.single().id)
|
||||
assertEquals("asr", merged.single().voice?.source)
|
||||
assertEquals(3_200, merged.single().voice?.durationMs)
|
||||
}
|
||||
|
||||
private fun msgIn(sessionId: String, id: String) = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* 「播放前取字节」这一层([VoiceStore]):地址怎么拼、缓存怎么命中、落哪儿、失败留不留残骸。
|
||||
*
|
||||
* 用真临时目录 + 一个假的下载器(`suspend (String) -> Result<ByteArray>`)跑完,不需要网络也不需要
|
||||
* Android([VoiceStore] 这一层的行为全是文件系统与地址拼接)。**真的走 HTTP** 那一半在
|
||||
* [MediaRepositoryTest](JDK 自带服务端 + 真 OkHttp/Retrofit)。
|
||||
*
|
||||
* 要钉住的六件事:
|
||||
* 1. **请求地址 = 当前服务器地址 + 服务端给的相对路径**(`voice.url` 逐字保留,本端不拼路径);
|
||||
* 2. **重复播放一个请求都不发**(内容寻址 ⇒ 命中永不失效);
|
||||
* 3. **落盘位置是 `<cacheDir>/media/`**,不在 `documents/`(那是 FileProvider 唯一暴露出去的根,
|
||||
* 本片不扩大暴露面);
|
||||
* 4. 名字不可信 / 地址拼不出来 ⇒ **如实失败且不发请求**(绝不退回「自己拼一条路径」);
|
||||
* 5. 下载失败**不留半截文件**(否则下一次会命中一份被截断的音频);
|
||||
* 6. 0 字节的残留当**未命中**(重新取),缓存目录写不进去则如实报缓存不可用。
|
||||
*/
|
||||
class VoiceStoreTest {
|
||||
|
||||
private lateinit var cacheRoot: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheRoot = Files.createTempDirectory("cyrene-voice").toFile()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cacheRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private val ogg = ByteArray(64) { it.toByte() }
|
||||
|
||||
private fun audio(name: String = this.name, url: String? = null) =
|
||||
VoiceAudio(
|
||||
name = name,
|
||||
mime = "audio/ogg",
|
||||
size = ogg.size.toLong(),
|
||||
url = url ?: "/api/v1/media/$name",
|
||||
durationMs = 3_200,
|
||||
)
|
||||
|
||||
private fun store(
|
||||
baseUrl: String = BASE_URL,
|
||||
download: suspend (String) -> Result<ByteArray>,
|
||||
) = VoiceStore(
|
||||
download = download,
|
||||
cacheDir = cacheRoot,
|
||||
baseUrlProvider = { baseUrl },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the request goes to the current server plus the path the server gave`() = runBlocking {
|
||||
val requested = mutableListOf<String>()
|
||||
val store = store(download = { url -> requested += url; Result.success(ogg) })
|
||||
|
||||
val file = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals(listOf("$BASE_URL/api/v1/media/$name"), requested)
|
||||
// 落盘位置:`<cacheDir>/media/<存储名>`(不是 documents/ —— 那是 FileProvider 那一层)。
|
||||
assertEquals(File(VoiceStore.directory(cacheRoot), name), file)
|
||||
assertEquals(ogg.size.toLong(), file.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing the same copy twice asks the server only once`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val first = store.load(audio()).getOrThrow()
|
||||
val second = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals("名字由内容决定 ⇒ 命中一个请求都不发", 1, downloads)
|
||||
assertEquals(first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
|
||||
val leftover = File(VoiceStore.directory(cacheRoot), name)
|
||||
leftover.parentFile!!.mkdirs()
|
||||
leftover.writeBytes(ByteArray(0))
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val file = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals(1, downloads)
|
||||
assertEquals(ogg.size.toLong(), file.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable server address fails without any request`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(baseUrl = "", download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val result = store.load(audio())
|
||||
|
||||
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
|
||||
assertEquals("拼不出可信地址就如实失败,绝不自己拼一条", 0, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a relative path that is not a media path never becomes a request`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val result = store.load(audio(url = "/api/v1/documents/$name"))
|
||||
|
||||
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
|
||||
assertEquals(0, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an untrustworthy name is never turned into a cache path`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
// 地址那一半照旧可信(服务端给的路径正常),不可信的是**存储名**自己:拼缓存路径的那一步
|
||||
// 必须挡住它(否则能爬出 `cacheDir/media/`)。
|
||||
val result = store.load(audio(name = "../../escape.ogg", url = "/api/v1/media/escape.ogg"))
|
||||
|
||||
assertEquals(
|
||||
CyreneError.Local(CyreneError.LocalKind.Parse),
|
||||
(result.exceptionOrNull() as CyreneException).error,
|
||||
)
|
||||
assertEquals(0, downloads)
|
||||
assertFalse("名字不可信时连缓存目录都不该被碰", VoiceStore.directory(cacheRoot).exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed download leaves no half file for the next play to believe`() = runBlocking {
|
||||
var attempted = 0
|
||||
val failing = store(download = {
|
||||
attempted++
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
|
||||
})
|
||||
|
||||
val first = failing.load(audio())
|
||||
|
||||
assertTrue(first.isFailure)
|
||||
val directory = VoiceStore.directory(cacheRoot)
|
||||
assertTrue(
|
||||
"失败不许留下任何文件(`.part` 临时文件也要清干净)",
|
||||
!directory.exists() || directory.listFiles()!!.isEmpty(),
|
||||
)
|
||||
|
||||
// 再点一次:真的重发(不是「命中一份空文件」)。
|
||||
val retry = store(download = { attempted++; Result.success(ogg) }).load(audio()).getOrThrow()
|
||||
assertEquals(2, attempted)
|
||||
assertEquals(ogg.size.toLong(), retry.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache directory that cannot be written is reported as such`() = runBlocking {
|
||||
// 拿一个**普通文件**当缓存目录:建目录必然失败。
|
||||
val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
|
||||
val store = VoiceStore(
|
||||
download = { Result.success(ogg) },
|
||||
cacheDir = blocked,
|
||||
baseUrlProvider = { BASE_URL },
|
||||
)
|
||||
|
||||
val result = store.load(audio())
|
||||
|
||||
assertTrue(result.exceptionOrNull() is DocumentCacheUnavailableException)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "http://192.168.1.9:8080"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package top.yeij.cyrene.data.voice
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 「把一段文字念出来」的取字节层([SpeechClient]):发什么、收什么、落哪儿、失败算哪一档。
|
||||
*
|
||||
* 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,
|
||||
* 与 [top.yeij.cyrene.data.api.RestAttemptClientTest] 同一套做法,不引任何新依赖),落盘用真临时目录。
|
||||
*
|
||||
* ## 它钉住的六条事实
|
||||
* 1. **产物恒为 WAV,按字节嗅探**:`200` 却回 JSON 信封(网关/反代把错误包了一层的典型形态)
|
||||
* 绝不喂给 `MediaPlayer`,如实判 [SpeechNotAudioException]。这一条刻意让假端点回
|
||||
* `Content-Type: audio/wav` —— 头是**不可信**的,判据只落在字节上。
|
||||
* 2. **0 字节不算「一段静音」**,判解析失败(与 `MediaRepository` 同一口径)。
|
||||
* 3. **落盘位置是 `<cacheDir>/media/tts_<hash>.wav`**:不在 `documents/`(那是 FileProvider 唯一
|
||||
* 暴露出去的根),名字按 `(baseUrl, sessionId, text)` 内容寻址。
|
||||
* 4. **同一段文字一个请求都不发第二次**(用请求计数断言,不是「文件存在」)。
|
||||
* 5. **空文本连请求都不发**:服务端对它只会回 `400`,本端没必要走一次往返。
|
||||
* 6. **非 2xx 保留服务端那句 `code`**:`tts_persona_unmapped` / `tts_no_character` 这两档的文案
|
||||
* 完全不同,折成一句话就说不清了。
|
||||
* 7. **换服务器不是命中**(P2-2):同一个 `(sessionId, text)` 在另一台服务器上可能是另一个声音,
|
||||
* 而地址末尾多写一个 `/` 必须**还是**同一份缓存(`trimEnd('/')` 归一)。
|
||||
*/
|
||||
class SpeechClientTest {
|
||||
|
||||
private lateinit var cacheRoot: File
|
||||
private val backend = FakeSpeechBackend()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheRoot = Files.createTempDirectory("cyrene-tts").toFile()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
cacheRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
/** 与生产同形:令牌由 [top.yeij.cyrene.data.api.ApiClientFactory] 的拦截器注入。 */
|
||||
private fun client(baseUrl: String = backend.baseUrl) = SpeechClient(
|
||||
baseUrlProvider = { baseUrl },
|
||||
tokenProvider = { TOKEN },
|
||||
cacheDir = cacheRoot,
|
||||
)
|
||||
|
||||
private suspend fun speak(
|
||||
client: SpeechClient = client(),
|
||||
text: String = "你好",
|
||||
sessionId: String? = SESSION_ID,
|
||||
): Result<File>? = withTimeoutOrNull(TIMEOUT_MILLIS) { client.speak(text, sessionId) }
|
||||
|
||||
@Test
|
||||
fun `a real wav lands under media as a tts named file and comes back playable`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val file = speak()!!.getOrThrow()
|
||||
|
||||
assertEquals("media", file.parentFile?.name)
|
||||
assertTrue("缓存名该带 tts_ 前缀,实际 ${file.name}", file.name.startsWith("tts_"))
|
||||
assertTrue("缓存名该是 .wav,实际 ${file.name}", file.name.endsWith(".wav"))
|
||||
assertArrayEquals(WAV, file.readBytes())
|
||||
// 令牌确实是本层挂上去的那一条(owner Bearer,不是 API Key)。
|
||||
assertEquals("Bearer $TOKEN", backend.headers.single()["authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same text a second time sends no request at all`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val first = speak()!!.getOrThrow()
|
||||
val second = speak()!!.getOrThrow()
|
||||
|
||||
assertEquals(first, second)
|
||||
assertEquals("第二次该命中缓存,一个请求都不发", 1, backend.requestLines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a json envelope served with 200 is rejected instead of fed to the player`() = runBlocking {
|
||||
// 头照写 audio/wav —— 恰恰证明判据不能落在 Content-Type 上。
|
||||
backend.reply(200, """{"error":{"code":"tts_failed","message":"合成失败"}}""".toByteArray())
|
||||
|
||||
val failure = speak()!!.exceptionOrNull()
|
||||
|
||||
assertTrue("200 却不是 WAV 该判 SpeechNotAudioException,实际 $failure", failure is SpeechNotAudioException)
|
||||
// 绝不落盘:一份不是音频的缓存会在下一次「命中」并被喂给播放器。
|
||||
assertNoCacheFile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty body is a parse failure rather than a silent clip`() = runBlocking {
|
||||
backend.reply(200, ByteArray(0))
|
||||
|
||||
val failure = speak()!!.exceptionOrNull()
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error)
|
||||
assertNoCacheFile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a persona without a voice keeps the server code instead of flattening it`() = runBlocking {
|
||||
backend.reply(500, """{"error":{"code":"$CODE_PERSONA_UNMAPPED","message":"人格没有发音人映射"}}""".toByteArray())
|
||||
|
||||
val failure = speak()!!.exceptionOrNull() as CyreneException
|
||||
|
||||
val server = failure.error as CyreneError.Server
|
||||
assertEquals(500, server.httpStatus)
|
||||
assertEquals(CODE_PERSONA_UNMAPPED, server.code)
|
||||
assertNoCacheFile()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank text fails locally without a round trip`() = runBlocking {
|
||||
val failure = speak(text = " \n ")!!.exceptionOrNull()
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error)
|
||||
assertTrue("空文本不该发请求", backend.requestLines.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two sessions with the same text are two different clips`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val web = speak(sessionId = "web")!!.getOrThrow()
|
||||
val phone = speak(sessionId = "phone")!!.getOrThrow()
|
||||
|
||||
// 发音人由会话的生效人格决定 ⇒ 同一段文字在不同会话里可以是两个声音,缓存名必须分开。
|
||||
assertFalse("不同会话的同一段文字不该撞同一个缓存名", web == phone)
|
||||
assertEquals(2, backend.requestLines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same session and text on another server is not a cache hit`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
val here = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
|
||||
|
||||
// 换一台服务器:会话 id 与文字都一样,但那边的人格映射 / 发音人 / 合成实现是它自己的。
|
||||
val elsewhere = FakeSpeechBackend()
|
||||
try {
|
||||
elsewhere.reply(200, WAV)
|
||||
val there = speak(client = client(baseUrl = elsewhere.baseUrl))!!.getOrThrow()
|
||||
|
||||
assertFalse("换服务器后不该命中上一台合成的那份 WAV(那是别人的声音)", here == there)
|
||||
assertEquals("这台各请求一次", 1, backend.requestLines.size)
|
||||
assertEquals("那台也请求了一次", 1, elsewhere.requestLines.size)
|
||||
} finally {
|
||||
elsewhere.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing slash on the base url is the same server`() = runBlocking {
|
||||
backend.reply(200, WAV)
|
||||
|
||||
val withSlash = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
|
||||
val without = speak(client = client(baseUrl = backend.baseUrl.trimEnd('/')))!!.getOrThrow()
|
||||
|
||||
// 地址末尾多写一个 `/` 不该让整份合成缓存作废(归一在 `cacheName` 里)。
|
||||
assertEquals(withSlash, without)
|
||||
assertEquals(1, backend.requestLines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wav sniffing looks at the bytes and nothing else`() {
|
||||
assertTrue(isWavBytes(WAV))
|
||||
assertFalse("11 字节连 RIFF 头都不完整", isWavBytes(WAV.copyOf(11)))
|
||||
assertFalse("空数组", isWavBytes(ByteArray(0)))
|
||||
// RIFX 是 RIFF 的大端变体,不是本端承诺的产物形状。
|
||||
assertFalse("RIFX 不是 WAVE", isWavBytes("RIFX".toByteArray() + WAV.copyOfRange(4, WAV.size)))
|
||||
// 头对了但第 8 字节起不是 WAVE:RIFF 容器里装的是别的东西。
|
||||
assertFalse("RIFF 里不是 WAVE", isWavBytes("RIFF".toByteArray() + ByteArray(WAV.size - 4)))
|
||||
}
|
||||
|
||||
private fun assertNoCacheFile() {
|
||||
val media = File(cacheRoot, "media")
|
||||
val leftovers = media.listFiles()?.filter { it.name.startsWith("tts_") }.orEmpty()
|
||||
assertTrue("失败时不该留下缓存文件,实际 $leftovers", leftovers.isEmpty())
|
||||
}
|
||||
|
||||
/** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**与请求头原文。 */
|
||||
private inner class FakeSpeechBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/audio/speech", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val requestLines = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
|
||||
val headers = CopyOnWriteArrayList<Map<String, String>>()
|
||||
|
||||
@Volatile
|
||||
private var status = 200
|
||||
|
||||
@Volatile
|
||||
private var payload = WAV
|
||||
|
||||
fun reply(status: Int, body: ByteArray) {
|
||||
this.status = status
|
||||
this.payload = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requestLines += "${exchange.requestMethod} ${exchange.requestURI}"
|
||||
headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") }
|
||||
exchange.requestBody.readBytes()
|
||||
// 一律回 audio/wav:判据必须落在字节上,本测试**故意**让头说谎。
|
||||
exchange.responseHeaders.add("Content-Type", "audio/wav")
|
||||
exchange.sendResponseHeaders(status, payload.size.toLong())
|
||||
exchange.responseBody.use { it.write(payload) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SESSION_ID = "web"
|
||||
const val TOKEN = "test-token"
|
||||
const val TIMEOUT_MILLIS = 10_000L
|
||||
const val CODE_PERSONA_UNMAPPED = "tts_persona_unmapped"
|
||||
|
||||
/** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */
|
||||
val WAV: ByteArray =
|
||||
"RIFF".toByteArray(Charsets.US_ASCII) +
|
||||
byteArrayOf(36, 0, 0, 0) +
|
||||
"WAVE".toByteArray(Charsets.US_ASCII) +
|
||||
ByteArray(40)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.chat.hasVoice
|
||||
import top.yeij.cyrene.domain.chat.isVoiceMessage
|
||||
import top.yeij.cyrene.domain.chat.voiceAudio
|
||||
import top.yeij.cyrene.domain.chat.voiceFallbackNote
|
||||
|
||||
/**
|
||||
* 落库帧上的 `voice`(契约 **v0.97**,后端 M44 `fb55769`;`failed` / `fallbackReason` 见 M57-M59)。
|
||||
*
|
||||
* ## 这条钉子钉的是「撤掉兜底」之后剩下的判据
|
||||
* W73 时期的旧口径是「`voice` 只在四个 REST 读取面填充,实时帧与握手回放缺席 ⇒ 实时到达的语音
|
||||
* 先按普通文本显示,等下一次拉取自动升级」。后端把落库顺序改成 **INSERT → 写 `voice` 列 →
|
||||
* notify** 之后,语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,
|
||||
* 于是那条兜底**整条撤掉**。判据剩下两条:`voice` 键存在(`hasVoice`)**且** `status != "failed"`
|
||||
* (`isVoiceMessage`)—— 降级行没有可播的声音,画成语音形态就是给一个点下去必然失败的按钮。
|
||||
*
|
||||
* 钉在这里的四件事,任何一件被将来的改动破坏都会让语音消息静默退回纯文本(或反过来,
|
||||
* 把一条降级行画成播不出来的语音):
|
||||
* 1. `message_persisted` 载荷里的 `voice` **真的被解出来**(`PersistedData` 只解 `message`,
|
||||
* 而生产 `Json` 是 `ignoreUnknownKeys = true` ⇒ 漏解不报错,只是判据永远为假);
|
||||
* 2. `channel_persisted` 同样——两条帧共用一个载荷类型;
|
||||
* 3. `voice` 键**缺席**仍是「不是语音」(不为「实时帧可能不带」留任何分支);
|
||||
* 4. `status = "failed"` 的降级行**不是**语音形态,但也不是纯文本:正文 + 一行降级小标
|
||||
* (`voiceFallbackNote`),且 `voiceAudio` 为 null ⇒ 没有播放控件。
|
||||
*
|
||||
* 帧形状照服务端实现:`data:{message:{…}}`(`channel_persisted` 同形;与 `message_edited`
|
||||
* 那条「`data` 就是消息本身」**不同**)。
|
||||
*/
|
||||
class WsEventPersistedVoiceTest {
|
||||
|
||||
private fun frame(type: String, data: String) = """{"type":"$type","sessionId":"web","data":$data}"""
|
||||
|
||||
private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
/** 一条真实的入站语音行:转写文本在 `content` 里,`voice` 四键齐全(有音频副本)。 */
|
||||
private val voiceRow = """
|
||||
{"id":"1371","sessionId":"web","role":"user","content":"明天几点出发",
|
||||
"voice":{"source":"asr","status":"transcribed","name":"$copyName","mime":"audio/ogg",
|
||||
"size":12345,"durationMs":3200,"url":"/api/v1/media/$copyName"},
|
||||
"createdAt":1791088571785}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
/** 微信那条真实的无副本形状:平台自带转写 ⇒ 只有 🎤 标记,四个音频键全缺席。 */
|
||||
private val noCopyRow = """
|
||||
{"id":"1372","sessionId":"web","role":"user","content":"晚上回来吃饭吗",
|
||||
"voice":{"source":"platform","status":"transcribed"},
|
||||
"createdAt":1791088571786}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
private val plainRow = """
|
||||
{"id":"1373","sessionId":"web","role":"user","content":"在的","createdAt":1791088571787}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
/** 我们自己合成的出站语音(后端 M57-M59):与入站语音**同形**,走同一条渲染路径。 */
|
||||
private val ttsRow = """
|
||||
{"id":"1375","sessionId":"web","role":"assistant","content":"早上好呀",
|
||||
"voice":{"source":"tts","status":"transcribed","name":"$copyName","mime":"audio/ogg",
|
||||
"size":12345,"durationMs":3200,"url":"/api/v1/media/$copyName"},
|
||||
"createdAt":1791088571789}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
/** 合成降级那一行:`status = "failed"` + `fallbackReason`,四个副本键全缺席(没有可播的声音)。 */
|
||||
private val fallbackRow = """
|
||||
{"id":"1374","sessionId":"web","role":"assistant","content":"想跟你说句话",
|
||||
"voice":{"source":"tts","status":"failed","fallbackReason":"tts_timeout"},
|
||||
"createdAt":1791088571788}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
|
||||
@Test
|
||||
fun `a persisted frame carries voice back to the decoder`() {
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$voiceRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
val voice = message.voice!!
|
||||
assertEquals("asr", voice.source)
|
||||
assertEquals("transcribed", voice.status)
|
||||
assertEquals(copyName, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals(12_345L, voice.size)
|
||||
assertEquals(3_200, voice.durationMs)
|
||||
assertEquals("/api/v1/media/$copyName", voice.url)
|
||||
// 判据:键存在即语音——这条帧到达的那一刻就该是语音气泡,不需要任何后续拉取。
|
||||
assertTrue("落库帧上的语音必须在第一帧就判成语音", isVoiceMessage(message))
|
||||
assertEquals(copyName, voiceAudio(voice)?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a channel persisted frame carries voice too`() {
|
||||
// 隔离会话那条帧与 message_persisted 共用 `PersistedData`:给载荷加字段不许只覆盖其中一条。
|
||||
val event = WsEvent.parse(frame("channel_persisted", """{"message":$voiceRow}""")) as WsEvent.ChannelPersisted
|
||||
|
||||
val message = event.message!!
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals(3_200, message.voice?.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transcribed voice without an audio copy is still a voice message`() {
|
||||
// 「是语音」与「能不能播」是两件事:判据只看键在不在,播放控件另看四键是否齐全。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$noCopyRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals("platform", message.voice?.source)
|
||||
assertNull("没有副本就不许画播放控件", voiceAudio(message.voice!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent voice key still means not a voice message`() {
|
||||
// 判据只有一个方向上的推论:缺席 = 不是语音。这里刻意不留「实时帧可能不带,先按文本显示」
|
||||
// 那条分支——M44 之后缺席就只意味着「这条本来就不是语音」。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$plainRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
assertNull(event.message?.voice)
|
||||
assertFalse(isVoiceMessage(event.message!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edited frame keeps the voice of the message it replaces`() {
|
||||
// 编辑帧的 `data` **就是**消息本身(没有 `message` 包一层),但它同样是 `voice` 的读面之一:
|
||||
// 编辑正文不许把已渲染的语音气泡打回纯文本。
|
||||
val event = WsEvent.parse(
|
||||
"""{"type":"message_edited","sessionId":"web","data":$voiceRow}""",
|
||||
) as WsEvent.MessageEdited
|
||||
|
||||
val message = event.message!!
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals("明天几点出发", message.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tts fallback row is text with a note, not a voice bubble`() {
|
||||
// 合成降级那一行:键在(`hasVoice` 为真 ⇒ 不许当成「什么都没有」),但 `status = "failed"`
|
||||
// ⇒ 没有可播的声音,画语音形态就是给一个点下去必然失败的按钮。正文照旧 + 一行原样小标。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$fallbackRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
val voice = message.voice!!
|
||||
assertTrue("降级行仍来自一段声音", hasVoice(message))
|
||||
assertFalse("但它没有可播的声音,不许画成语音形态", isVoiceMessage(message))
|
||||
assertNull("没有副本 ⇒ 一个播放控件都不画", voiceAudio(voice))
|
||||
assertEquals("想跟你说句话", message.content)
|
||||
// 原因**原样**出现在小标里(不映射、不猜闭集)。
|
||||
assertNotNull(voiceFallbackNote(voice))
|
||||
assertTrue(voiceFallbackNote(voice)!!.contains("tts_timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synthesized voice row is an ordinary voice bubble`() {
|
||||
// `source = tts` 与 asr / platform 走**同一条**路:判据只看 `status`,不看 `source`。
|
||||
val event = WsEvent.parse(frame("message_persisted", """{"message":$ttsRow}""")) as WsEvent.MessagePersisted
|
||||
|
||||
val message = event.message!!
|
||||
val voice = message.voice!!
|
||||
assertEquals("tts", voice.source)
|
||||
assertTrue(isVoiceMessage(message))
|
||||
assertEquals(copyName, voiceAudio(voice)?.name)
|
||||
assertNull("合成成功的那条不该有降级小标", voiceFallbackNote(voice))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import org.junit.Test
|
||||
* 帧形状逐字取自后端 `internal/services/gateway/ws_bridge.go` 的 `toolExecutionData`
|
||||
* (`tool` + `phase` 恒在,`id` 空则省略该键,`delta` 只在 `.update` 且非空时出现)与
|
||||
* `streamEventData` 的类型名;这三帧**不带 `index` / `streamId`**(不属于模型流)。
|
||||
*
|
||||
* 新契约切片给 `.end` 补了一个**可选**的 `resultSummary`(脱敏 + 截断 ≤500 字符的成品,
|
||||
* 只有这一轮真的拿到执行结果才在场)。服务端**尚未部署**它,所以「没有这个键」那一档才是
|
||||
* 今天真机上的唯一路径——它必须解出 `null` 而不是报错。
|
||||
*/
|
||||
class WsEventToolExecutionTest {
|
||||
|
||||
@@ -112,6 +116,75 @@ class WsEventToolExecutionTest {
|
||||
assertNull(ev.id)
|
||||
}
|
||||
|
||||
// —— end 的可选 resultSummary(新契约切片;服务端**未部署**) ——
|
||||
|
||||
@Test
|
||||
fun `tool execution end decodes the result summary when the backend sends one`() {
|
||||
val ev = parse(
|
||||
frame(
|
||||
"tool_execution.end",
|
||||
"""{"tool":"bash","phase":"done","id":"call_1","resultSummary":"12 行输出,退出码 0"}""",
|
||||
),
|
||||
) as WsEvent.ToolExecutionEnd
|
||||
|
||||
assertEquals("12 行输出,退出码 0", ev.resultSummary)
|
||||
assertEquals("bash", ev.tool)
|
||||
assertEquals("done", ev.phase)
|
||||
assertEquals("call_1", ev.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool execution end without the key yields null rather than a runtime error`() {
|
||||
// 服务端**当前未部署**这个键:老帧必须照旧解出来,`resultSummary` 是 `null`——
|
||||
// 不是 `""`。两者在渲染上等价(都按「不画结果区」处理),但「键在不在」这件事别在下游丢掉。
|
||||
val ev = parse(frame("tool_execution.end", """{"tool":"bash","phase":"done","id":"call_1"}"""))
|
||||
as WsEvent.ToolExecutionEnd
|
||||
|
||||
assertNull(ev.resultSummary)
|
||||
assertEquals("done", ev.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty result summary decodes to the empty string and is not invented into anything`() {
|
||||
// 契约承诺不会用空串顶位;真出现了也照原样解,别在解码层替它编一个结论。
|
||||
val ev = parse(
|
||||
frame("tool_execution.end", """{"tool":"bash","phase":"done","resultSummary":""}"""),
|
||||
) as WsEvent.ToolExecutionEnd
|
||||
|
||||
assertEquals("", ev.resultSummary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a five hundred character summary survives decoding untouched`() {
|
||||
// 后端已按同一份预算截断到 ≤500 字符;本端不许再截第二刀。
|
||||
val summary = "输".repeat(500)
|
||||
|
||||
val ev = parse(
|
||||
frame("tool_execution.end", """{"tool":"bash","phase":"done","resultSummary":"$summary"}"""),
|
||||
) as WsEvent.ToolExecutionEnd
|
||||
|
||||
assertEquals(summary, ev.resultSummary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the end frame carries a result summary`() {
|
||||
// 契约只给 `.end` 补了这个键:`.start` / `.update` 的领域事件上根本没有该字段。
|
||||
// (多一个未知键也不该让整帧解析失败——[WsEvent.parse] 本来就是宽容的。)
|
||||
val start = parse(
|
||||
frame("tool_execution.start", """{"tool":"bash","phase":"running","resultSummary":"不该在这"}"""),
|
||||
)
|
||||
val update = parse(
|
||||
frame(
|
||||
"tool_execution.update",
|
||||
"""{"tool":"bash","phase":"running","delta":"x","resultSummary":"不该在这"}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(start is WsEvent.ToolExecutionStart)
|
||||
assertEquals("bash", (update as WsEvent.ToolExecutionUpdate).tool)
|
||||
assertEquals("x", update.delta)
|
||||
}
|
||||
|
||||
// —— 回归:三帧都不能再被丢弃 / 不能读错字段 ——
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 下行帧 `voice_output`(契约 v0.96 §3.15)。
|
||||
*
|
||||
* 两个容易踩空的地方在这里钉死:
|
||||
* 1. **帧壳没有 `sessionId`**(契约明写,会话 id 在 `data` 里)——解析层必须把它提到帧上,
|
||||
* 否则 `isForSession` 会把本帧**放行给每一个会话的 VM**(空值 = 无法判定),切到别的会话
|
||||
* 时也会响一段本属于别的会话的语音;
|
||||
* 2. `text` / `reply` / `provider` 三个键**一个都不能丢**:`reply` 是她说的话、`text` 是转写、
|
||||
* `provider == "silent"` 是「这一轮没有音频」的唯一信号(判据在 `domain.chat`)。
|
||||
*/
|
||||
class WsEventVoiceOutputTest {
|
||||
|
||||
private val realFrame = """
|
||||
{"type":"voice_output","data":{"audio":"UklGRg==","format":"wav","text":"你今天在吗","reply":"我在呢","sessionId":"web","deviceId":"voiceassist:cyrene:v1","provider":"emotiontts"}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `the real frame decodes with every payload key`() {
|
||||
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
|
||||
|
||||
assertEquals("voice_output", event.type)
|
||||
assertEquals("UklGRg==", event.audio)
|
||||
assertEquals("wav", event.format)
|
||||
assertEquals("你今天在吗", event.text)
|
||||
assertEquals("我在呢", event.reply)
|
||||
assertEquals("voiceassist:cyrene:v1", event.deviceId)
|
||||
assertEquals("emotiontts", event.provider)
|
||||
// 帧壳没有轮次 / 序号(这一帧不属任何流式段)。
|
||||
assertNull(event.turnId)
|
||||
assertNull(event.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the payload session id is lifted onto the frame so the session gate works`() {
|
||||
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
|
||||
|
||||
assertEquals("web", event.sessionId)
|
||||
assertTrue("本会话放行", event.isForSession("web"))
|
||||
assertFalse("别的会话必须挡下(否则会在别的会话里响起来)", event.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silent frame keeps the marker and the text`() {
|
||||
val frame = """
|
||||
{"type":"voice_output","data":{"audio":"","format":"wav","text":"嗯","reply":"嗯,我在","sessionId":"web","provider":"silent"}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.VoiceOutput
|
||||
|
||||
assertEquals("silent", event.provider)
|
||||
assertEquals("", event.audio)
|
||||
assertEquals("嗯,我在", event.reply)
|
||||
assertEquals("web", event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without a payload still forms the event instead of vanishing`() {
|
||||
// 坏帧(没有 data):会话归属退化成「无法判定」,但「这一帧来过」必须留下痕迹——
|
||||
// 整帧丢掉就是「她回了语音而屏幕上什么都没有」,那比一张空卡片糟得多。
|
||||
val event = WsEvent.parse("""{"type":"voice_output"}""") as WsEvent.VoiceOutput
|
||||
|
||||
assertNull(event.audio)
|
||||
assertNull(event.reply)
|
||||
assertEquals("", event.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 上行帧 `voice_input`(契约 v0.96 §3.15)。
|
||||
*
|
||||
* 两个键是**纯增量**:[WsOutbound.VoiceInput.deviceId] 与 `clientMsgId`。骨架里本来只有
|
||||
* `audio` / `format` / `text` / `sessionId` 四个——那四者的线上形状在这里逐字钉死(老帧一个
|
||||
* 字节都不许变),新增的两个只在有值时才出现(与后端 `omitempty` 同义)。
|
||||
*
|
||||
* `clientMsgId` 与文本 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条。所以真正的
|
||||
* 「幂等」不在这一层(它只负责照发),而在 `ChatViewModel` + 纯函数 `voiceClientMsgIdFor`:
|
||||
* 重发复用同一个键(那边有穷举单测)。
|
||||
*/
|
||||
class WsOutboundVoiceTest {
|
||||
|
||||
@Test
|
||||
fun `the original four key frame is byte for byte what it was`() {
|
||||
assertEquals(
|
||||
"""{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web"}}""",
|
||||
WsOutbound.VoiceInput(audio = "QUJD", format = "wav", sessionId = "web").toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device id and client message id are carried next to the audio`() {
|
||||
val frame = WsOutbound.VoiceInput(
|
||||
audio = "QUJD",
|
||||
format = "wav",
|
||||
sessionId = "web",
|
||||
deviceId = "voiceassist:cyrene:v1",
|
||||
clientMsgId = "voice-9f3c",
|
||||
).toJson()
|
||||
|
||||
val expected = """{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web","deviceId":"voiceassist:cyrene:v1","clientMsgId":"voice-9f3c"}}"""
|
||||
|
||||
assertEquals(expected, frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omitting the idempotency key omits the key itself rather than sending null`() {
|
||||
val json = WsOutbound.VoiceInput(audio = "QUJD", format = "wav").toJson()
|
||||
|
||||
assertFalse("省略时不该出现 clientMsgId 键:$json", json.contains("clientMsgId"))
|
||||
assertFalse("省略时不该出现 deviceId 键:$json", json.contains("deviceId"))
|
||||
assertNull(WsOutbound.VoiceInput(audio = "QUJD").reqId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
|
||||
/**
|
||||
* 预览内容下方那一行元数据的组装(逐档穷举)。
|
||||
*
|
||||
* 这一块的价值全在「**不出现**」上:一屏小字里凭空多出一项(一个假的编码、一个猜的日期、
|
||||
* 一个 `--` 占位符)比少一项糟得多,而且它看起来完全正常。所以这里的断言分两类:
|
||||
* 1. **有证据时逐字钉住那一项**(连限定语一起钉:`UTF-8(本端解码口径)` 少了括号里的四个字
|
||||
* 就从「本端口径」变成了对文件编码的断言);
|
||||
* 2. **没证据时断言它整项缺席**(`assertFalse(line.contains(...))`),而不只是断言列表长度。
|
||||
*
|
||||
* 另有一档是「行数」本身:空文本 / 只有换行 / `\r\n` / 结尾没有换行这四种边界都在
|
||||
* [textItems] 里重算过一次(不是照抄 `PreviewSlice.shownLines`),所以要从**字节**跑起,
|
||||
* 走 [DocumentPreview.slice] 这条真实的路。
|
||||
*/
|
||||
class PreviewMetadataTest {
|
||||
|
||||
// —— 行数:从预览内容算,四个边界 ——
|
||||
|
||||
@Test
|
||||
fun `empty text is zero lines`() {
|
||||
val items = textItems(DocumentPreview.slice(ByteArray(0)))
|
||||
|
||||
assertEquals("0 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single newline is one line not two`() {
|
||||
val items = textItems(DocumentPreview.slice("\n".toByteArray()))
|
||||
|
||||
assertEquals("1 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `carriage returns do not add lines`() {
|
||||
// `\r\n` 只数 `\n`:Windows 换行不该被算成两行,`\r` 单独出现也不是换行。
|
||||
val items = textItems(DocumentPreview.slice("a\r\nb\r\nc".toByteArray()))
|
||||
|
||||
assertEquals("3 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing trailing newline still counts the last line`() {
|
||||
val items = textItems(DocumentPreview.slice("a\nb".toByteArray()))
|
||||
|
||||
assertEquals("2 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing newline does not count an extra line`() {
|
||||
val items = textItems(DocumentPreview.slice("a\nb\n".toByteArray()))
|
||||
|
||||
assertEquals("2 行", items.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a preview cut short reports both the shown and the total line count`() {
|
||||
// 5000 行上限正好等于上限时不截断;这里给 5001 行,确认「显示 / 共」两个数都在。
|
||||
val bytes = lines(DocumentPreviewLimits.MAX_LINES + 1).toByteArray()
|
||||
val slice = DocumentPreview.slice(bytes)
|
||||
|
||||
assertEquals(PreviewTruncation.Lines, slice.truncatedBy)
|
||||
assertEquals(
|
||||
"显示 ${grouped(DocumentPreviewLimits.MAX_LINES)} 行 / 共 ${grouped(DocumentPreviewLimits.MAX_LINES + 1)} 行",
|
||||
textItems(slice).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a complete preview says one number only`() {
|
||||
val items = textItems(DocumentPreview.slice("a\nb\nc".toByteArray()))
|
||||
|
||||
assertFalse(items.first(), items.first().contains("共"))
|
||||
assertFalse(items.first(), items.first().contains("显示"))
|
||||
}
|
||||
|
||||
// —— 字符数:两个口径分得开 ——
|
||||
|
||||
@Test
|
||||
fun `the server character count is shown with its scope`() {
|
||||
val items = textItems(sliceOf("hello"), serverCharsLabel = "8,000 字")
|
||||
|
||||
assertEquals("8,000 字(服务端注入口径)", items[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without a server count the local one is used and says so`() {
|
||||
val items = textItems(sliceOf("你好"), serverCharsLabel = null)
|
||||
|
||||
assertEquals("2 字(本端预览内容)", items[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the local count is code points not utf16 units`() {
|
||||
// 一个 emoji 在 UTF-16 里是两个单元(代理对);「字符数」该数成 1。
|
||||
val items = textItems(sliceOf("😀"), serverCharsLabel = null)
|
||||
|
||||
assertEquals("1 字(本端预览内容)", items[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server count of zero is still a report and is shown`() {
|
||||
// `chars: 0` 是「服务端报了 0」,与「服务端没报」是两件事——不许被归一成后者。
|
||||
val items = textItems(sliceOf("abc"), serverCharsLabel = DocumentChips.charsLabel(0))
|
||||
|
||||
assertEquals("0 字(服务端注入口径)", items[1])
|
||||
}
|
||||
|
||||
// —— 解码口径 ——
|
||||
|
||||
@Test
|
||||
fun `the decoding scope is always stated and never as a bare utf8`() {
|
||||
val items = textItems(sliceOf("x"), serverCharsLabel = null)
|
||||
|
||||
assertTrue(items.toString(), items.contains("UTF-8(本端解码口径)"))
|
||||
assertFalse("裸写 UTF-8 会被读成「这份文件是 UTF-8 编码的」", items.contains("UTF-8"))
|
||||
}
|
||||
|
||||
// —— 服务端的 truncated:两态 ——
|
||||
|
||||
@Test
|
||||
fun `the server truncation flag is spelled out when set`() {
|
||||
val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = true)
|
||||
|
||||
assertEquals("正文被截断(进上下文的部分)", items.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is said about truncation when the server did not report it`() {
|
||||
val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = false)
|
||||
|
||||
assertFalse(items.toString(), items.any { it.contains("截断") })
|
||||
assertEquals(3, items.size)
|
||||
}
|
||||
|
||||
// —— 非文本档:只有嗅探所得的 mime ——
|
||||
|
||||
@Test
|
||||
fun `a binary file shows the sniffed mime`() {
|
||||
assertEquals(listOf("application/pdf"), PreviewMetadata.fileItems("application/pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local draft without a server mime shows nothing at all`() {
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(null))
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(""))
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a binary file line never invents a placeholder`() {
|
||||
val line = PreviewMetadata.join(PreviewMetadata.fileItems(null))
|
||||
|
||||
assertEquals("", line)
|
||||
assertFalse(line.contains("未知"))
|
||||
assertFalse(line.contains("--"))
|
||||
}
|
||||
|
||||
// —— 图片:四项各自的可有可无 ——
|
||||
|
||||
@Test
|
||||
fun `a full image line carries format, size in pixels, bytes and the shot date`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = "image/jpeg",
|
||||
width = 4032,
|
||||
height = 3024,
|
||||
sizeBytes = 2_411_724,
|
||||
capturedAt = "2019:07:04 20:31:05",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("image/jpeg", "4032 × 3024", "2.3 MB", "拍摄于 2019年7月4日 20:31"),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image with no exif simply has no date`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = "image/png",
|
||||
width = 100,
|
||||
height = 50,
|
||||
sizeBytes = 2048,
|
||||
capturedAt = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("image/png", "100 × 50", "2.0 KB"), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an undecodable size is left out rather than guessed`() {
|
||||
val items = PreviewMetadata.imageItems(mime = "image/gif", width = null, height = null)
|
||||
|
||||
assertEquals(listOf("image/gif"), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a half known size is not reported`() {
|
||||
// `inJustDecodeBounds` 只会两侧都给或两侧都不给;真出现半边时宁可不报。
|
||||
val items = PreviewMetadata.imageItems(mime = null, width = 800, height = null)
|
||||
|
||||
assertEquals(emptyList<String>(), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non positive sizes are not reported`() {
|
||||
val items = PreviewMetadata.imageItems(mime = null, width = -1, height = 0)
|
||||
|
||||
assertEquals(emptyList<String>(), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown byte count leaves the size out`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = null,
|
||||
sizeBytes = PreviewMetadata.UNKNOWN_BYTES,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the image wildcard is not a format and must not be shown`() {
|
||||
// 调用方在这一档传的是 null(`image/` 通配由弹层滤掉);纯函数这层再确认一遍空值不产出项。
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.imageItems(mime = null))
|
||||
assertEquals(emptyList<String>(), PreviewMetadata.imageItems(mime = " "))
|
||||
}
|
||||
|
||||
// —— EXIF 日期:严格到不近人情 ——
|
||||
|
||||
@Test
|
||||
fun `a canonical exif timestamp is formatted`() {
|
||||
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time is zero padded and the date is not`() {
|
||||
assertEquals("2001年1月2日 03:04", PreviewMetadata.formatExifDateTime("2001:01:02 03:04:09"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surrounding whitespace is tolerated`() {
|
||||
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime(" 2019:07:04 20:31:05 "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the all zero placeholder means unknown and is rejected`() {
|
||||
assertNull(PreviewMetadata.formatExifDateTime("0000:00:00 00:00:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of range components are rejected`() {
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:13:04 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:32 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 24:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:61:05"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leap second is a valid exif value`() {
|
||||
assertEquals("2016年12月31日 23:59", PreviewMetadata.formatExifDateTime("2016:12:31 23:59:60"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed values are rejected rather than repaired`() {
|
||||
assertNull("另一个分隔符的形态不当成 EXIF 认", PreviewMetadata.formatExifDateTime("2019-07-04 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04T20:31:05"))
|
||||
assertNull("缺秒", PreviewMetadata.formatExifDateTime("2019:07:04 20:31"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:7:4 20:31:05"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("hello"))
|
||||
assertNull(PreviewMetadata.formatExifDateTime(""))
|
||||
assertNull(PreviewMetadata.formatExifDateTime(null))
|
||||
assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05+08:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invalid exif date leaves the whole item out`() {
|
||||
val items = PreviewMetadata.imageItems(
|
||||
mime = "image/jpeg",
|
||||
capturedAt = "0000:00:00 00:00:00",
|
||||
)
|
||||
|
||||
assertEquals(listOf("image/jpeg"), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exif time is read as local wall clock with no timezone conversion`() {
|
||||
// 同一串数字在任何时区下都得原样出来——EXIF 里根本没有偏移量字段,换算就是凭空错几小时。
|
||||
assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05"))
|
||||
}
|
||||
|
||||
// —— 拼接 ——
|
||||
|
||||
@Test
|
||||
fun `items are joined with a middle dot`() {
|
||||
assertEquals("a · b · c", PreviewMetadata.join(listOf("a", "b", "c")))
|
||||
assertEquals("", PreviewMetadata.join(emptyList()))
|
||||
}
|
||||
|
||||
// —— 走一遍真实链路:DTO → 芯片 → 这一行 ——
|
||||
|
||||
@Test
|
||||
fun `the chip model feeds the two server facts without re-formatting them`() {
|
||||
val chip = DocumentChips.of(
|
||||
DocumentRefDto(
|
||||
name = "0123456789abcdef0123456789abcdef.txt",
|
||||
mime = "text/plain",
|
||||
size = 12_345,
|
||||
chars = 8_000,
|
||||
truncated = true,
|
||||
status = DocumentStatus.EXTRACTED,
|
||||
),
|
||||
)
|
||||
val items = PreviewMetadata.textItems(
|
||||
slice = DocumentPreview.slice("a\nb".toByteArray()),
|
||||
serverCharsLabel = chip.charsLabel,
|
||||
contextTruncated = chip.truncatedLabel != null,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("2 行", "8,000 字(服务端注入口径)", "UTF-8(本端解码口径)", "正文被截断(进上下文的部分)"),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft attachment only has the local half`() {
|
||||
val chip = DocumentChips.of(
|
||||
DraftDocument(uri = "content://x/y.txt", displayName = "notes.txt", sizeBytes = 1024),
|
||||
)
|
||||
val items = PreviewMetadata.textItems(
|
||||
slice = DocumentPreview.slice("hello\n".toByteArray()),
|
||||
serverCharsLabel = chip.charsLabel,
|
||||
contextTruncated = chip.truncatedLabel != null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("1 行", "6 字(本端预览内容)", "UTF-8(本端解码口径)"), items)
|
||||
}
|
||||
|
||||
private fun textItems(
|
||||
slice: PreviewSlice,
|
||||
serverCharsLabel: String? = null,
|
||||
contextTruncated: Boolean = false,
|
||||
) = PreviewMetadata.textItems(slice, serverCharsLabel, contextTruncated)
|
||||
|
||||
private fun sliceOf(text: String): PreviewSlice = DocumentPreview.slice(text.toByteArray())
|
||||
|
||||
private fun lines(count: Int): String = (1..count).joinToString("\n") { "line $it" } + "\n"
|
||||
|
||||
private fun grouped(value: Int): String = String.format(java.util.Locale.ROOT, "%,d", value)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 语音**上行**那一半的纯函数(契约 v0.96 §3.15 ①):录音参数、WAV 封装、体积闸门、幂等键。
|
||||
*
|
||||
* 这一组钉的是「发出去的字节长什么样」——WAV 头一旦有一个字段写错,服务端的解码器与 STT
|
||||
* 会**静默**给出垃圾(或直接拒),而客户端这边完全看不出问题。所以头的每个字段都在这里逐个
|
||||
* 读出来断言,而不是只比长度。
|
||||
*/
|
||||
class VoiceClipTest {
|
||||
|
||||
// —— 换算:16 kHz / 单声道 / 16-bit ⇒ 1 ms = 32 字节 ——
|
||||
|
||||
@Test
|
||||
fun `one second of pcm is 32 000 bytes at the chosen parameters`() {
|
||||
assertEquals(32_000, VoiceClip.BYTES_PER_SECOND)
|
||||
assertEquals(32_000, VoiceClip.pcmBytesFor(1_000))
|
||||
assertEquals(1_000, VoiceClip.durationMillisOf(32_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration and byte count round trip for typical clips`() {
|
||||
assertEquals(1_920_000, VoiceClip.MAX_PCM_BYTES)
|
||||
assertEquals(VoiceClip.MAX_RECORDING_MILLIS, VoiceClip.durationMillisOf(VoiceClip.MAX_PCM_BYTES))
|
||||
// 不足一毫秒的零头一律向下取整(绝不把 0 字节说成 1 毫秒)。
|
||||
assertEquals(0, VoiceClip.durationMillisOf(31))
|
||||
assertEquals(0, VoiceClip.durationMillisOf(0))
|
||||
assertEquals(0, VoiceClip.durationMillisOf(-5))
|
||||
assertEquals(0, VoiceClip.pcmBytesFor(-5))
|
||||
}
|
||||
|
||||
// —— WAV 头:逐字段 ——
|
||||
|
||||
@Test
|
||||
fun `the wav header carries the riff wav fmt data chunks of pcm`() {
|
||||
val pcmBytes = 32_000
|
||||
val header = VoiceClip.wavHeader(pcmBytes)
|
||||
|
||||
assertEquals("RIFF-WAVE 头恒为 44 字节", 44, header.size)
|
||||
assertEquals("RIFF", header.ascii(0, 4))
|
||||
// RIFF 的 size 字段 = 文件总长 - 8(不含 "RIFF" 与 size 自己)。
|
||||
assertEquals(44 - 8 + pcmBytes, header.intLe(4))
|
||||
assertEquals("WAVE", header.ascii(8, 4))
|
||||
assertEquals("fmt ", header.ascii(12, 4))
|
||||
assertEquals("fmt 子块长度 = 16(PCM)", 16, header.intLe(16))
|
||||
assertEquals("audioFormat = 1(PCM)", 1, header.shortLe(20))
|
||||
assertEquals("单声道", 1, header.shortLe(22))
|
||||
assertEquals("采样率 16 kHz", 16_000, header.intLe(24))
|
||||
assertEquals("字节率 = 采样率 × 声道 × 位深/8", 32_000, header.intLe(28))
|
||||
assertEquals("块对齐 = 声道 × 位深/8", 2, header.shortLe(32))
|
||||
assertEquals("16-bit", 16, header.shortLe(34))
|
||||
assertEquals("data", header.ascii(36, 4))
|
||||
assertEquals("data 子块长度 = PCM 字节数", pcmBytes, header.intLe(40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toWav length is header plus pcm and the payload is untouched`() {
|
||||
val pcm = ByteArray(64) { (it + 1).toByte() }
|
||||
|
||||
val wav = VoiceClip.toWav(pcm)
|
||||
|
||||
assertEquals("字节数 = 44 + PCM", 44 + pcm.size, wav.size)
|
||||
assertEquals(44 - 8 + pcm.size, wav.intLe(4))
|
||||
assertEquals(pcm.size, wav.intLe(40))
|
||||
// 头之后一个字节都不许动(重采样 / 量化是录音机的事,这里只搬字节)。
|
||||
assertArrayEquals(pcm, wav.copyOfRange(44, wav.size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty pcm still yields a self consistent 44 byte wav`() {
|
||||
val wav = VoiceClip.toWav(ByteArray(0))
|
||||
|
||||
assertEquals(44, wav.size)
|
||||
assertEquals(44 - 8, wav.intLe(4))
|
||||
assertEquals(0, wav.intLe(40))
|
||||
assertEquals("RIFF", wav.ascii(0, 4))
|
||||
assertEquals("data", wav.ascii(36, 4))
|
||||
}
|
||||
|
||||
// —— 准入:空 / 太短 / 超限 / 可发 ——
|
||||
|
||||
@Test
|
||||
fun `an empty recording is rejected as empty rather than too short`() {
|
||||
assertEquals(VoiceClipPlan.Empty, VoiceClip.plan(ByteArray(0)))
|
||||
assertEquals(VOICE_EMPTY_MESSAGE, voiceClipRejection(VoiceClipPlan.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a few milliseconds of audio is rejected as too short`() {
|
||||
// 100 字节 = 3 ms:按下就松手的那一下。
|
||||
val plan = VoiceClip.plan(ByteArray(100))
|
||||
|
||||
assertTrue("极短录音判 TooShort,实际 $plan", plan is VoiceClipPlan.TooShort)
|
||||
assertEquals(3, (plan as VoiceClipPlan.TooShort).durationMs)
|
||||
assertEquals(VOICE_TOO_SHORT_MESSAGE, voiceClipRejection(plan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one second is the shortest acceptable clip`() {
|
||||
val plan = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(1_000)))
|
||||
|
||||
assertTrue("满一秒就该放行,实际 $plan", plan is VoiceClipPlan.Ready)
|
||||
assertEquals(1_000, (plan as VoiceClipPlan.Ready).durationMs)
|
||||
assertEquals(44 + 32_000, plan.wav.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one millisecond under the minimum is still rejected`() {
|
||||
val pcm = ByteArray(VoiceClip.pcmBytesFor(1_000) - 32) // 32 字节 = 1 ms
|
||||
|
||||
val plan = VoiceClip.plan(pcm)
|
||||
|
||||
assertTrue("999 ms 仍算太短,实际 $plan", plan is VoiceClipPlan.TooShort)
|
||||
assertEquals(999, (plan as VoiceClipPlan.TooShort).durationMs)
|
||||
}
|
||||
|
||||
// —— 体积闸门(含「等于」那一格)——
|
||||
|
||||
@Test
|
||||
fun `the byte ceiling equals the server's decoded inline cap`() {
|
||||
// 与 chat.image 解码后同一条线:契约没给 voice_input 的上限,本端按已知最严的那一档设计。
|
||||
assertEquals(ChatImageBudget.SERVER_MAX_DECODED_BYTES, VoiceClip.MAX_AUDIO_BYTES)
|
||||
assertEquals(5 * 1024 * 1024, VoiceClip.MAX_AUDIO_BYTES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clip exactly at the ceiling is accepted`() {
|
||||
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES)
|
||||
|
||||
val total = VoiceClip.totalBytesFor(pcm.size)
|
||||
|
||||
assertEquals("上界取等于(<=)", VoiceClip.MAX_AUDIO_BYTES, total)
|
||||
assertTrue(VoiceClip.withinLimit(total))
|
||||
assertTrue("恰好到顶应当放行", VoiceClip.plan(pcm) is VoiceClipPlan.Ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clip one byte over the ceiling is refused with a named reason`() {
|
||||
// 43 字节:total = 44 + 43 = MAX + 1。
|
||||
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES + 1)
|
||||
|
||||
val total = VoiceClip.totalBytesFor(pcm.size)
|
||||
|
||||
assertEquals(VoiceClip.MAX_AUDIO_BYTES + 1, total)
|
||||
assertFalse(VoiceClip.withinLimit(total))
|
||||
val plan = VoiceClip.plan(pcm)
|
||||
assertTrue("超限判 TooLarge,实际 $plan", plan is VoiceClipPlan.TooLarge)
|
||||
assertEquals(total, (plan as VoiceClipPlan.TooLarge).totalBytes)
|
||||
assertEquals(VOICE_TOO_LARGE_MESSAGE, voiceClipRejection(plan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 60 second recording stays well under the ceiling`() {
|
||||
// 录满长度只用到闸门的 36%:撞上它说明硬件给了异常的量,不是正常路径。
|
||||
val total = VoiceClip.totalBytesFor(VoiceClip.MAX_PCM_BYTES)
|
||||
|
||||
assertTrue(VoiceClip.withinLimit(total))
|
||||
assertEquals(1_920_044, total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rejection of a sendable clip is null`() {
|
||||
val ready = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(2_000)))
|
||||
|
||||
assertNull(voiceClipRejection(ready))
|
||||
}
|
||||
|
||||
// —— 录音中的时长文案 ——
|
||||
|
||||
@Test
|
||||
fun `the elapsed label starts at zero and pads seconds`() {
|
||||
assertEquals("0:00", voiceElapsedLabel(0))
|
||||
assertEquals("0:00", voiceElapsedLabel(999))
|
||||
assertEquals("0:07", voiceElapsedLabel(7_000))
|
||||
assertEquals("1:05", voiceElapsedLabel(65_000))
|
||||
// 负数(时钟回拨 / 调用方算错)不显示成 -1:-1。
|
||||
assertEquals("0:00", voiceElapsedLabel(-1_000))
|
||||
}
|
||||
|
||||
// —— 幂等键:重发必须复用(契约 v0.96:同一个非空值只承认第一条)——
|
||||
|
||||
@Test
|
||||
fun `a fresh recording mints a new prefixed key`() {
|
||||
val key = voiceClientMsgIdFor(null) { "9f3c" }
|
||||
|
||||
assertEquals("voice-9f3c", key)
|
||||
assertTrue("前缀写进键里,好让服务端与日志一眼看出这条是语音", key.startsWith(VOICE_CLIENT_MSG_ID_PREFIX))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying the same clip reuses its client message id`() {
|
||||
val draft = VoiceDraftInfo(clientMsgId = "voice-keep", durationMs = 1_800, totalBytes = 57_644)
|
||||
|
||||
// 用户连点三次「重发」:每一次都必须拿同一个键(否则服务端会把同一句话答三遍)。
|
||||
val attempts = List(3) {
|
||||
voiceClientMsgIdFor(draft) { error("重发不该铸新键") }
|
||||
}
|
||||
|
||||
assertEquals(listOf("voice-keep", "voice-keep", "voice-keep"), attempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different recording never inherits the old key`() {
|
||||
val old = VoiceDraftInfo("voice-old", durationMs = 1_200, totalBytes = 38_444)
|
||||
|
||||
// 丢弃之后再录一段:状态里那一格已经清掉 ⇒ 现铸新键(否则新录音会被服务端当成重发吞掉)。
|
||||
val fresh = voiceClientMsgIdFor(null) { "fresh" }
|
||||
|
||||
assertEquals("voice-fresh", fresh)
|
||||
assertTrue(fresh != old.clientMsgId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 从字节数组里取一段 ASCII(断言 WAV 头的四个 chunk 名用)。 */
|
||||
private fun ByteArray.ascii(offset: Int, length: Int): String =
|
||||
String(this, offset, length, Charsets.US_ASCII)
|
||||
|
||||
/** 小端有符号整数(WAV 头里全是 32 位小端)。 */
|
||||
private fun ByteArray.intLe(offset: Int): Int =
|
||||
(this[offset].toInt() and 0xFF) or
|
||||
((this[offset + 1].toInt() and 0xFF) shl 8) or
|
||||
((this[offset + 2].toInt() and 0xFF) shl 16) or
|
||||
((this[offset + 3].toInt() and 0xFF) shl 24)
|
||||
|
||||
/** 小端 16 位无符号整数。 */
|
||||
private fun ByteArray.shortLe(offset: Int): Int =
|
||||
(this[offset].toInt() and 0xFF) or ((this[offset + 1].toInt() and 0xFF) shl 8)
|
||||
@@ -0,0 +1,342 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceTooLargeException
|
||||
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
|
||||
import top.yeij.cyrene.ui.chat.isContentlessUserMessage
|
||||
|
||||
/**
|
||||
* 语音消息的**呈现判据**(纯函数,接收侧)。
|
||||
*
|
||||
* 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,六条口径逐条钉在这里:
|
||||
* 1. **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**(`isVoiceMessage`)——**绝不**从正文里
|
||||
* 认媒体类型(正文里可能出现用户自己打出的 `[语音]` 字样),也绝不把 `failed` 行画成语音形态
|
||||
* (那一行没有可播的声音,见 `isPlayableVoice` / `hasVoice`);
|
||||
* 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全);
|
||||
* 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充;
|
||||
* 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示;
|
||||
* 5. **`failed` 行多一行灰色小标**(`voiceFallbackNote`):固定前半句 + 服务端 `fallbackReason` 原样;
|
||||
* 6. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**,
|
||||
* 绝不退回「自己拼一条 `/api/v1/media/<name>`」。
|
||||
*/
|
||||
class VoiceMessageTest {
|
||||
|
||||
private fun message(
|
||||
content: String = "",
|
||||
role: String = "user",
|
||||
voice: VoiceDto? = null,
|
||||
) = HistoryMessageDto(
|
||||
id = "msg-1",
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = content,
|
||||
voice = voice,
|
||||
createdAt = 1_791_088_571_785L,
|
||||
)
|
||||
|
||||
private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private fun voice(
|
||||
source: String = VoiceSource.ASR,
|
||||
status: String = VoiceStatus.TRANSCRIBED,
|
||||
name: String? = copyName,
|
||||
mime: String? = "audio/ogg",
|
||||
size: Long? = 12_345,
|
||||
durationMs: Int? = 3_200,
|
||||
fallbackReason: String? = null,
|
||||
url: String? = "/api/v1/media/$copyName",
|
||||
) = VoiceDto(
|
||||
source = source,
|
||||
status = status,
|
||||
name = name,
|
||||
mime = mime,
|
||||
size = size,
|
||||
durationMs = durationMs,
|
||||
fallbackReason = fallbackReason,
|
||||
url = url,
|
||||
)
|
||||
|
||||
// —— 1. 「来自一条语音」的判据 = 键存在(`hasVoice`)——
|
||||
|
||||
@Test
|
||||
fun `the voice key is the only criterion for coming from a voice`() {
|
||||
assertTrue(hasVoice(message(voice = voice())))
|
||||
assertFalse(hasVoice(message(content = "在的")))
|
||||
// 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发,
|
||||
// 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。
|
||||
assertFalse(hasVoice(message(content = "[语音]")))
|
||||
assertFalse(hasVoice(message(content = "语音")))
|
||||
assertTrue(hasVoice(message(content = "[语音]", voice = voice())))
|
||||
// 「要不要画语音形态」是**另一个**问题(见下面第 4b 节),但键在是它的前提。
|
||||
assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice())))
|
||||
}
|
||||
|
||||
// —— 2. 四者同生共死 ⇒ 能不能播 ——
|
||||
|
||||
@Test
|
||||
fun `an audio copy is playable only when all four keys are there`() {
|
||||
val audio = voiceAudio(voice())!!
|
||||
assertEquals(copyName, audio.name)
|
||||
assertEquals("audio/ogg", audio.mime)
|
||||
assertEquals(12_345L, audio.size)
|
||||
assertEquals("/api/v1/media/$copyName", audio.url)
|
||||
assertEquals(3_200, audio.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing key on any of the four makes the copy unplayable`() {
|
||||
// 四者同生共死:缺任何一个 ⇒ 一个播放控件都不画(不能给一个点下去必然失败的按钮)。
|
||||
assertNull(voiceAudio(voice(name = null)))
|
||||
assertNull(voiceAudio(voice(mime = null)))
|
||||
assertNull(voiceAudio(voice(size = null)))
|
||||
assertNull(voiceAudio(voice(url = null)))
|
||||
// 空白与 null 同等对待(空白串拼出来是一条没有名字的地址)。
|
||||
assertNull(voiceAudio(voice(name = " ")))
|
||||
assertNull(voiceAudio(voice(mime = "")))
|
||||
assertNull(voiceAudio(voice(url = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent voice key has no copy and a zero byte copy is still a copy`() {
|
||||
assertNull(voiceAudio(null))
|
||||
// size 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西。
|
||||
assertEquals(0L, voiceAudio(voice(size = 0))!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice without a copy keeps its duration`() {
|
||||
// 微信那条路:`source = platform`、四个副本键全缺 ⇒ 不可播,但时长照留(渠道给了)。
|
||||
val platform = voice(
|
||||
source = VoiceSource.PLATFORM,
|
||||
name = null,
|
||||
mime = null,
|
||||
size = null,
|
||||
url = null,
|
||||
durationMs = 5_000,
|
||||
)
|
||||
assertNull(voiceAudio(platform))
|
||||
assertEquals("0:05", voiceDurationLabel(platform.durationMs))
|
||||
}
|
||||
|
||||
// —— 3. 时长 ——
|
||||
|
||||
@Test
|
||||
fun `a missing duration shows nothing at all`() {
|
||||
// 「缺席就不显示时长」:不拿 size / 码率反算,也不显示 `0:00` 冒充。
|
||||
assertNull(voiceDurationLabel(null))
|
||||
assertNull(voiceDurationLabel(0))
|
||||
assertNull(voiceDurationLabel(-1))
|
||||
// 不足一秒时**向下取整会发生什么**:100ms 会变成 `0:00` —— 那正是「冒充」,
|
||||
// 所以这一条也当缺席(契约里不存在 0 秒的语音)。
|
||||
assertNull(voiceDurationLabel(100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration is formatted as minutes and seconds`() {
|
||||
assertEquals("0:01", voiceDurationLabel(1_000))
|
||||
assertEquals("0:03", voiceDurationLabel(3_200))
|
||||
assertEquals("0:59", voiceDurationLabel(59_999))
|
||||
assertEquals("1:00", voiceDurationLabel(60_000))
|
||||
assertEquals("1:05", voiceDurationLabel(65_000))
|
||||
assertEquals("10:00", voiceDurationLabel(600_000))
|
||||
// 不折算成小时制:语音消息不会长到那个量级,多一种表达就多一种自己发明的说法。
|
||||
assertEquals("124:03", voiceDurationLabel(7_443_000))
|
||||
}
|
||||
|
||||
// —— 4. 三态 status 在界面上多说的那一句 ——
|
||||
|
||||
@Test
|
||||
fun `only an empty transcription with blank content gets the extra line`() {
|
||||
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, ""))
|
||||
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, " "))
|
||||
// transcribed:正文就是转写文本,多一句都是噪声。
|
||||
assertNull(voiceNote(VoiceStatus.TRANSCRIBED, "明天几点出发"))
|
||||
// failed:正文**就是**服务端那行诚实提示,本端一个字都不改、也不添油加醋。
|
||||
assertNull(voiceNote(VoiceStatus.FAILED, "这段语音没能转成文字(未配置语音识别)"))
|
||||
// 认不出的 status:不猜(与 failed 同形:只显示正文)。
|
||||
assertNull(voiceNote("", ""))
|
||||
assertNull(voiceNote("future-status", ""))
|
||||
// empty 而正文非空(契约说不会出现):不补一句自相矛盾的话。
|
||||
assertNull(voiceNote(VoiceStatus.EMPTY, "其实有字"))
|
||||
}
|
||||
|
||||
// —— 4b. failed 行:没有可播的声音,但仍然是「一条语音消息」——
|
||||
|
||||
@Test
|
||||
fun `a failed voice is not a voice bubble but still a voice message`() {
|
||||
// 判据拆成两个问题:hasVoice 答「这条来自一条语音吗」,isVoiceMessage 答「画不画语音形态」。
|
||||
// 合成降级 / 转写失败的行两问的答案是「是」「否」——正因如此才不能混用。
|
||||
val outbound = message(content = "想跟你说句话", voice = voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED))
|
||||
assertTrue(hasVoice(outbound))
|
||||
assertFalse(isVoiceMessage(outbound))
|
||||
// 入站转写失败同形:本端分不清(也不该分)这两个方向。
|
||||
assertFalse(isVoiceMessage(message(voice = voice(status = VoiceStatus.FAILED))))
|
||||
// 键缺席才是「不是语音消息」。
|
||||
assertFalse(hasVoice(message(content = "在的")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed voice is excluded by status, not by its copy keys`() {
|
||||
// 两个判据各判各的、互不顶替:voiceAudio 只看四键齐不齐(**不看** status),
|
||||
// isPlayableVoice 只看 status(**不看**四键)。所以「四键齐全但 status = failed」这种
|
||||
// 后端按契约不该发的形状,voiceAudio 照样能构造出来 —— 拦住语音形态的是 isVoiceMessage。
|
||||
val contradictory = voice(status = VoiceStatus.FAILED)
|
||||
assertNotNull(voiceAudio(contradictory))
|
||||
assertFalse(isPlayableVoice(contradictory))
|
||||
assertFalse(isVoiceMessage(message(voice = contradictory)))
|
||||
// 反向:四键不齐的 transcribed 行照样「可播」(画语音形态、只是没有播放控件)。
|
||||
val noCopy = voice(name = null, mime = null, size = null, url = null)
|
||||
assertNull(voiceAudio(noCopy))
|
||||
assertTrue(isPlayableVoice(noCopy))
|
||||
assertTrue(isVoiceMessage(message(voice = noCopy)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fallback reason is shown verbatim and never mapped`() {
|
||||
// 原样放进括号:后端换一个取值本端一个字符都不改(闭集还没定,映射比原样更难排查)。
|
||||
assertEquals(
|
||||
"这条语音没有可播放的声音(tts_timeout)",
|
||||
voiceFallbackNote(voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED, fallbackReason = "tts_timeout")),
|
||||
)
|
||||
assertEquals(
|
||||
"这条语音没有可播放的声音(budget_exceeded)",
|
||||
voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = "budget_exceeded")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed voice without a reason still says something`() {
|
||||
// 缺席 / 空白:只剩固定前半句。这一档同时是「空正文 + failed」的兜底 ——
|
||||
// 屏幕上至少还有这行小标,而不是一个什么都没有的空白。
|
||||
assertEquals(VOICE_FALLBACK_NOTE, voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = null)))
|
||||
assertEquals(VOICE_FALLBACK_NOTE, voiceFallbackNote(voice(status = VoiceStatus.FAILED, fallbackReason = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a failed voice gets a fallback note`() {
|
||||
assertNull(voiceFallbackNote(null))
|
||||
assertNull(voiceFallbackNote(voice()))
|
||||
assertNull(voiceFallbackNote(voice(status = VoiceStatus.EMPTY)))
|
||||
// 认不出的 status 也不加这一行:多画一句「没有可播放的声音」就是替服务端断言它没有。
|
||||
assertNull(voiceFallbackNote(voice(status = "future-status")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synthesized voice that went through is an ordinary voice bubble`() {
|
||||
// source = tts 与 asr / platform 走**同一条**路:判据只看 status,不看 source。
|
||||
val tts = voice(source = VoiceSource.TTS)
|
||||
assertTrue(isVoiceMessage(message(content = "早上好呀", voice = tts)))
|
||||
assertNotNull(voiceAudio(tts))
|
||||
assertNull(voiceFallbackNote(tts))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown status is played rather than silently swallowed`() {
|
||||
// fail-open:认不出的 status 一律当可播。这个方向错得便宜(多画一枚语音形态,最坏点了播不出来,
|
||||
// 有既有的失败文案兜着);反方向错得贵(把真有副本的语音静默吞成纯文字,用户再也点不到)。
|
||||
assertTrue(isVoiceMessage(message(voice = voice(status = "truncated"))))
|
||||
}
|
||||
|
||||
// —— 5. 相对路径接到当前服务器地址 ——
|
||||
|
||||
@Test
|
||||
fun `a server relative path is rebased onto the current server`() {
|
||||
assertEquals(
|
||||
"http://192.168.1.9:8080/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "http://192.168.1.9:8080"),
|
||||
)
|
||||
// 尾斜杠、带路径的 baseUrl、大小写 scheme:都按「scheme://authority」处理(与 ImageHostRebase 同)。
|
||||
assertEquals(
|
||||
"http://host:8080/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "http://host:8080/"),
|
||||
)
|
||||
assertEquals(
|
||||
"HTTPS://host/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "HTTPS://host/cyrene"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable base url yields nothing instead of a guessed path`() {
|
||||
// 拼不出可信地址就回 null(调用方据此如实报「暂时播不了」),**绝不**退回自己拼一条路径。
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", ""))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "192.168.1.9:8080"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "http://"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "ftp://host"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a path that is not exactly one media name is refused`() {
|
||||
// 只看服务端给的那条路径:不是「恰好一段 /api/v1/media/<name>」就不接(看不懂的不改、不发)。
|
||||
assertNull(voiceMediaUrl("/api/v1/files/$copyName", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/.", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/..", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a/../b.ogg", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/%2e%2e%2fsecret", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a.ogg?token=x", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a.ogg#f", "http://host"))
|
||||
assertNull(voiceMediaUrl("http://evil/api/v1/media/a.ogg", "http://host"))
|
||||
}
|
||||
|
||||
// —— 失败文案 ——
|
||||
|
||||
@Test
|
||||
fun `a failure always says something, and says the server's own words when it has them`() {
|
||||
// 服务端有话说(404 media_not_found 的两种情形同码)⇒ 只说本端能确定的那半句。
|
||||
val notFound = CyreneException(
|
||||
CyreneError.Server(code = "media_not_found", message = "media not found", httpStatus = 404),
|
||||
)
|
||||
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(notFound))
|
||||
// 其它服务端错 / 本地网络错:原样带上服务端那句(或本端的分类说明),**绝不静默**。
|
||||
assertEquals(
|
||||
"语音加载失败,服务器开小差了",
|
||||
voicePlaybackFailureMessage(
|
||||
CyreneException(CyreneError.Server(code = "x", message = "服务器开小差了", httpStatus = 500)),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"语音加载失败,网络不可用,请检查连接",
|
||||
voicePlaybackFailureMessage(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
|
||||
),
|
||||
)
|
||||
assertEquals("当前服务器地址不可用,暂时播不了这条语音", voicePlaybackFailureMessage(VoiceUrlUnavailableException()))
|
||||
assertEquals("这条语音太大,本机放不了", voicePlaybackFailureMessage(VoiceTooLargeException()))
|
||||
assertEquals(
|
||||
"本地缓存不可用,暂时播不了这条语音",
|
||||
voicePlaybackFailureMessage(DocumentCacheUnavailableException()),
|
||||
)
|
||||
assertEquals("语音加载失败,请稍后重试", voicePlaybackFailureMessage(IllegalStateException("boom")))
|
||||
}
|
||||
|
||||
// —— 与「这条消息什么都没有」那条既有判据的交界 ——
|
||||
|
||||
@Test
|
||||
fun `a voice message is never reported as contentless`() {
|
||||
// 语音的正文可能为空(status = empty,或服务端那行提示没到),但那条消息**不是什么都没有**
|
||||
// ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。
|
||||
val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null))
|
||||
assertFalse(isContentlessUserMessage(empty))
|
||||
// `failed` 那一档同样不成立,只是理由不同:它**没有**语音形态,但有那行降级小标
|
||||
// (见 `voiceFallbackNote`)—— 同样不是「什么都没有」。判据因此挂在 `hasVoice` 上而**不是**
|
||||
// `isVoiceMessage` 上:两种落点都画得出东西。
|
||||
val failed = message(
|
||||
content = "",
|
||||
voice = voice(source = VoiceSource.TTS, status = VoiceStatus.FAILED, fallbackReason = "tts_timeout"),
|
||||
)
|
||||
assertFalse(isContentlessUserMessage(failed))
|
||||
// 对照:同样空的正文,没有 voice 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。
|
||||
assertTrue(isContentlessUserMessage(message(content = "")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* `voice_output` 的呈现判据(契约 v0.96 §3.15):两件事必须判对,否则 UI 一定画错。
|
||||
*
|
||||
* 1. **这一轮有没有真音频**——`provider == "silent"` 是明文降级信号(`audio` 空、`text`/`reply`
|
||||
* 有)。判错成「有」就会画出一个点下去必然失败的播放器;判错成「没有」则让本来能放的语音
|
||||
* 变成一段文字。
|
||||
* 2. **屏幕上该显示哪一句**——`reply` 是**她说的话**(音频念的就是它),`text` 是**转写**
|
||||
* (我们那段音频被识别成什么)。两者的顺序反过来,用户看到的就是自己说的话冒充她的回答。
|
||||
*/
|
||||
class VoiceOutputTest {
|
||||
|
||||
// —— provider 降级信号 ——
|
||||
|
||||
@Test
|
||||
fun `the silent provider is recognised literally`() {
|
||||
assertTrue(isSilentVoiceProvider(VoiceProvider.SILENT))
|
||||
// 机器可读码:容忍首尾空白与大小写,但不容忍别的词。
|
||||
assertTrue(isSilentVoiceProvider(" SILENT "))
|
||||
assertFalse(isSilentVoiceProvider("emotiontts"))
|
||||
assertFalse(isSilentVoiceProvider(""))
|
||||
assertFalse(isSilentVoiceProvider(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silent frames never count as having audio`() {
|
||||
// 契约:silent ⇒ audio 空。万一服务端还是塞了字节,也不能画播放器(它自己说这轮没发音)。
|
||||
assertFalse(voiceOutputHasAudio(audio = null, provider = VoiceProvider.SILENT))
|
||||
assertFalse(voiceOutputHasAudio(audio = "QUJD", provider = VoiceProvider.SILENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a normal provider needs actual bytes to be playable`() {
|
||||
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = "emotiontts"))
|
||||
// 没给 provider(老服务端 / 省略该键)、但有字节 ⇒ 能放。
|
||||
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = null))
|
||||
// 有 provider 却没字节 ⇒ 没有音频(空串与 null 同一档)。
|
||||
assertFalse(voiceOutputHasAudio(audio = "", provider = "emotiontts"))
|
||||
assertFalse(voiceOutputHasAudio(audio = null, provider = "emotiontts"))
|
||||
}
|
||||
|
||||
// —— text vs reply ——
|
||||
|
||||
@Test
|
||||
fun `reply wins because it is what she actually said`() {
|
||||
assertEquals("我在呢", voiceReplyText(reply = "我在呢", text = "你今天在吗"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing reply falls back to the transcript`() {
|
||||
assertEquals("你今天在吗", voiceReplyText(reply = null, text = "你今天在吗"))
|
||||
assertEquals("你今天在吗", voiceReplyText(reply = " ", text = "你今天在吗"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both missing means show nothing instead of inventing a line`() {
|
||||
assertNull(voiceReplyText(reply = null, text = null))
|
||||
assertNull(voiceReplyText(reply = "", text = " "))
|
||||
}
|
||||
|
||||
// —— 那一句说明:两档说的不是同一件事 ——
|
||||
|
||||
@Test
|
||||
fun `the silent note says only text this round`() {
|
||||
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = false))
|
||||
// silent 档即便本地碰巧有个文件也不该说「本机没准备好」。
|
||||
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local decode failure is reported as a local problem not as silence`() {
|
||||
assertEquals(VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE, voiceReplyNote(silent = false, hasAudioFile = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a playable reply needs no note`() {
|
||||
assertNull(voiceReplyNote(silent = false, hasAudioFile = true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 语音播放通道的状态机(纯函数)。
|
||||
*
|
||||
* 它要钉住的是**跨气泡**的那几条事实——少了任何一条,真机上就会出现「两条一起响」、
|
||||
* 「界面显示 A 在响而喇叭里是 B」或者「A 的失败提示挂在 B 上」:
|
||||
*
|
||||
* 1. 再点一下同一目标 = 停(**加载中也算**:用户不该被慢下载困住);
|
||||
* 2. 点另一条 = 从它重新开始(旧的那条由调用方停下并释放,纯函数只说状态变成什么);
|
||||
* 3. **迟到的回调不许改状态**:`prepared` / `failed` / `finished` 三个转换都只认「目标就是当前
|
||||
* 这一条」,而且 `prepared` 只从 `Loading` 出发(正在响的那一条不会被一个迟到的失败说成失败);
|
||||
* 4. `Failed` 不粘:再点一次就是重试(回 `Loading`);
|
||||
* 5. `silenced`(停下 / 换会话 / 组件销毁)**收所有态**,包括 `Failed` 那句提示。
|
||||
*/
|
||||
class VoicePlaybackTest {
|
||||
|
||||
private val a = "msg-a"
|
||||
private val b = "msg-b"
|
||||
|
||||
// —— 1 / 2. 按下与换目标 ——
|
||||
|
||||
@Test
|
||||
fun `pressing from idle starts loading that message`() {
|
||||
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Idle.pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing the message that is playing stops it`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing the message that is loading cancels it`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing another message switches to it`() {
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Playing(a).pressed(b))
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(a).pressed(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing a failed message retries it`() {
|
||||
val failed = VoicePlaybackState.Failed(a, "网络不可用")
|
||||
|
||||
assertFalse("失败不算「占着这条」,否则再点一下只会被当成「停」", failed.isActiveFor(a))
|
||||
assertEquals(VoicePlaybackState.Loading(a), failed.pressed(a))
|
||||
}
|
||||
|
||||
// —— 3. 迟到的回调 ——
|
||||
|
||||
@Test
|
||||
fun `prepared only moves the message that was loading`() {
|
||||
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Loading(a).prepared(a))
|
||||
// 用户已经点了别的 / 点了停:迟到的「准备好」不许把界面拽回去。
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).prepared(a))
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.prepared(a))
|
||||
// 正在响的那一条收到 prepared 也原样(它已经在响了)。
|
||||
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Playing(a).prepared(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed only moves the message that was loading`() {
|
||||
assertEquals(VoicePlaybackState.Failed(a, "坏了"), VoicePlaybackState.Loading(a).failed(a, "坏了"))
|
||||
// 正在响的那一条不该被一个迟到的失败回调说成失败(解码错误的真实顺序是「先停再报」)。
|
||||
assertEquals(
|
||||
VoicePlaybackState.Playing(a),
|
||||
VoicePlaybackState.Playing(a).failed(a, "坏了"),
|
||||
)
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).failed(a, "坏了"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finished only clears the message that was playing`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).finished(a))
|
||||
assertEquals(VoicePlaybackState.Playing(b), VoicePlaybackState.Playing(b).finished(a))
|
||||
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Loading(a).finished(a))
|
||||
}
|
||||
|
||||
// —— 4 / 5. 停下来收所有态 ——
|
||||
|
||||
@Test
|
||||
fun `silencing collects every state including a failure note`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Failed(a, "坏了").silenced)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silenced channel is active for nobody`() {
|
||||
assertFalse(VoicePlaybackState.Idle.silenced.isActiveFor(a))
|
||||
assertTrue(VoicePlaybackState.Loading(a).isActiveFor(a))
|
||||
assertTrue(VoicePlaybackState.Playing(a).isActiveFor(a))
|
||||
}
|
||||
|
||||
// —— 视图查询(气泡据此决定画什么)——
|
||||
|
||||
@Test
|
||||
fun `only playing counts as playing`() {
|
||||
assertTrue(VoicePlaybackState.Playing(a).isPlayingFor(a))
|
||||
assertFalse("加载中不算在响", VoicePlaybackState.Loading(a).isPlayingFor(a))
|
||||
assertFalse(VoicePlaybackState.Playing(b).isPlayingFor(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading and playing are two different questions`() {
|
||||
// 界面那一句「加载中…」只能在**真的在取字节**时出现(`isActiveFor` 会把「正在响」也算进来,
|
||||
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」)。
|
||||
assertTrue(VoicePlaybackState.Loading(a).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Playing(a).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Loading(b).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Idle.isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Failed(a, "坏了").isLoadingFor(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure note is only reported for its own message`() {
|
||||
val failed = VoicePlaybackState.Failed(a, "这条语音太大,本机放不了")
|
||||
|
||||
assertEquals("这条语音太大,本机放不了", failed.failureOf(a))
|
||||
assertNull(failed.failureOf(b))
|
||||
assertNull(VoicePlaybackState.Idle.failureOf(a))
|
||||
assertNull(VoicePlaybackState.Playing(a).failureOf(a))
|
||||
}
|
||||
|
||||
// —— 一条完整的用户路径 ——
|
||||
|
||||
@Test
|
||||
fun `a full press load failure retry stop path`() {
|
||||
var state: VoicePlaybackState = VoicePlaybackState.Idle
|
||||
|
||||
state = state.pressed(a)
|
||||
assertEquals(VoicePlaybackState.Loading(a), state)
|
||||
state = state.failed(a, "语音加载失败,请稍后重试")
|
||||
assertEquals("失败提示挂在这一条上", "语音加载失败,请稍后重试", state.failureOf(a))
|
||||
state = state.pressed(a)
|
||||
assertEquals("重试清掉上一句", null, state.failureOf(a))
|
||||
state = state.prepared(a)
|
||||
assertEquals(VoicePlaybackState.Playing(a), state)
|
||||
state = state.finished(a)
|
||||
assertEquals("放完回初始外观,不留痕迹", VoicePlaybackState.Idle, state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* 「朗读」这条路上本端要说的那几句话与纯判据([ttsTextOf] / [ttsFailureOf] / [ttsClipIdOf])。
|
||||
*
|
||||
* 这个文件全是纯函数,不碰网络也不碰 Android:取字节在 `SpeechClientTest`(真 HTTP),
|
||||
* 图标与播放接线在界面层。
|
||||
*
|
||||
* ## 它钉住的四件事
|
||||
* 1. **念什么**:只念正文(`body`)——抬头(`head`)像字幕、旁白(`tail`)是舞台指示,都不念;
|
||||
* 一条消息切成多个气泡时按 `\n` 拼,空正文不产生空行。
|
||||
* 2. **能不能念**:「这条消息有没有可念的正文」是唯一判据,`ChatViewModel.speak`(发不发请求)与
|
||||
* `MessageBubble`(画不画图标)用的必须是同一个答案;`mode` 不是 `chat` 时念的是**原文**,
|
||||
* 因为那时气泡里放的就是原文、一个 markup 都不解析。
|
||||
* 3. **失败算哪一档**:只有服务端明确回了「配置缺失」类 `code` 才进
|
||||
* [TtsFailure.Unconfigured](重试永远不会成功,文案要说清缺什么、且不提重试),
|
||||
* 其余一切(网络、解析、401、`voice_unavailable` / `tts_failed` / `tts_empty`)都是
|
||||
* [TtsFailure.Soft]。
|
||||
* 4. **播放键**:`tts:<messageId>`,与语音副本的键(就是 `messageId`)分得开。
|
||||
*/
|
||||
class VoiceSpeakTest {
|
||||
|
||||
// ---- 念什么:气泡 → 文本 ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `bubbles are joined by a newline so the pause lands between paragraphs`() {
|
||||
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p>再见</p>")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty message has nothing to read out loud`() {
|
||||
assertNull(ttsTextOf(parse("")))
|
||||
assertNull(ttsTextOf(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narration alone is stage direction and is not read out loud`() {
|
||||
// 旁白整段进 head(首部依附),正文为空 ⇒ 没有可念的字。
|
||||
assertNull(ttsTextOf(parse("<p><narration>她笑了笑</narration></p>")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the speaker head is skipped and only the body is read`() {
|
||||
val bubbles = parse("<p><narration>她笑了笑</narration>你好</p>")
|
||||
|
||||
assertEquals("你好", ttsTextOf(bubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank bubble does not become a blank line`() {
|
||||
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p> </p><p>再见</p>")))
|
||||
}
|
||||
|
||||
// ---- 能不能念:一条消息 → 文本 ------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a chat message is read after the markup is stripped`() {
|
||||
assertEquals("你好\n再见", ttsTextOf(message(content = "<p>你好</p><p>再见</p>", mode = "chat")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message without a mode counts as a chat message`() {
|
||||
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = null)))
|
||||
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non chat mode is read verbatim because the bubble shows the raw text`() {
|
||||
// 非 chat 的 mode 下气泡里放的就是原文(MessageBubble 的 isChat 判据),
|
||||
// 所以朗读念的也必须是那同一段字,一个标签都不许吞。
|
||||
assertEquals("<p>你好</p>", ttsTextOf(message(content = "<p>你好</p>", mode = "roleplay")))
|
||||
assertEquals("你好", ttsTextOf(message(content = " 你好 ", mode = "roleplay")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message with nothing to say answers null in both modes`() {
|
||||
assertNull(ttsTextOf(message(content = "", mode = "chat")))
|
||||
assertNull(ttsTextOf(message(content = " ", mode = "roleplay")))
|
||||
assertNull(ttsTextOf(message(content = "<p><narration>她笑了笑</narration></p>", mode = "chat")))
|
||||
}
|
||||
|
||||
// ---- 失败算哪一档 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a persona without a voice mapping says so and is not worth retrying`() {
|
||||
val failure = ttsFailureOf(serverError(TTS_CODE_PERSONA_UNMAPPED))
|
||||
|
||||
assertEquals(TtsFailure.Unconfigured(TTS_CODE_PERSONA_UNMAPPED), failure)
|
||||
assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, failure.message)
|
||||
assertFalse("配置缺失类重试永远不会成功", failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session without any character says so instead of blaming the network`() {
|
||||
val failure = ttsFailureOf(serverError(TTS_CODE_NO_CHARACTER))
|
||||
|
||||
assertEquals(TTS_NO_CHARACTER_MESSAGE, failure.message)
|
||||
assertFalse(failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognized code is not guessed into the configuration bucket`() {
|
||||
// 本端认不出这个 code,就**不知道**它是配置缺失还是临时故障 ⇒ 不猜,落柔和档:
|
||||
// 判成「配置没配好」等于对用户说了一句本端没有依据的话。
|
||||
val failure = ttsFailureOf(serverError("tts_something_new"))
|
||||
|
||||
assertEquals(TtsFailure.Soft, failure)
|
||||
assertTrue(failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a configuration failure built from an unknown code still explains itself`() {
|
||||
// 兜底分支:万一将来有调用点直接构造了一个本端还不认识的配置缺失码,
|
||||
// 文案仍是「缺发音人」而不是「网络不好」,且照样不提重试。
|
||||
val failure = TtsFailure.Unconfigured("tts_something_new")
|
||||
|
||||
assertEquals(TTS_UNCONFIGURED_MESSAGE, failure.message)
|
||||
assertFalse(failure.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that is merely unavailable right now stays in the soft bucket`() {
|
||||
listOf("voice_unavailable", "tts_failed", "tts_empty").forEach { code ->
|
||||
val failure = ttsFailureOf(serverError(code))
|
||||
|
||||
assertEquals("$code 该是柔和档", TtsFailure.Soft, failure)
|
||||
assertEquals(TTS_SOFT_FAILURE_MESSAGE, failure.message)
|
||||
assertTrue("$code 过一会儿再点是有意义的", failure.retryable)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired token is soft because renewal can still save it`() {
|
||||
val failure = ttsFailureOf(CyreneException(CyreneError.Unauthorized(code = "", message = "")))
|
||||
|
||||
assertEquals(TtsFailure.Soft, failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network or parse failure is soft and does not pretend to know why`() {
|
||||
assertEquals(TtsFailure.Soft, ttsFailureOf(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))))
|
||||
assertEquals(TtsFailure.Soft, ttsFailureOf(IOException("boom")))
|
||||
// 非 CyreneException 一律柔和档:本端确实不知道断在哪一段。
|
||||
assertEquals(TtsFailure.Soft, ttsFailureOf(IllegalStateException("boom")))
|
||||
}
|
||||
|
||||
// ---- 播放键 ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the clip key is namespaced so it never collides with a voice copy`() {
|
||||
assertEquals("tts:m-1", ttsClipIdOf("m-1"))
|
||||
assertFalse("朗读与语音副本的键必须分得开", ttsClipIdOf("m-1") == "m-1")
|
||||
}
|
||||
|
||||
private fun serverError(code: String) = CyreneException(
|
||||
CyreneError.Server(code = code, message = "服务端说明", httpStatus = 500),
|
||||
)
|
||||
|
||||
private fun message(content: String, mode: String?) = HistoryMessageDto(
|
||||
id = "m-1",
|
||||
sessionId = "web",
|
||||
role = "assistant",
|
||||
content = content,
|
||||
mode = mode,
|
||||
createdAt = 0L,
|
||||
)
|
||||
}
|
||||
@@ -68,6 +68,348 @@ class ChatContentParserTest {
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].tail)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// `<br>` 分隔符(契约 v0.89 更正:与 `<p>` 同级的气泡/消息分隔符,不是行内标签)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 契约表第一行:`<p>甲<br>乙</p>` ⇒ **两条**气泡。 */
|
||||
@Test
|
||||
fun lineBreakInsideParagraphSplitsBubbles() {
|
||||
val bubbles = parse("<p>甲<br>乙</p>")
|
||||
assertEquals(2, bubbles.size)
|
||||
assertEquals(listOf("甲", "乙"), bubbles.map { it.body })
|
||||
assertEquals(listOf(ContentPart.Text("甲"), ContentPart.Text("乙")), bubbles.flatMap { it.parts })
|
||||
}
|
||||
|
||||
/** 契约表第二行:没有 `<p>` 时同理 ⇒ 两条气泡。 */
|
||||
@Test
|
||||
fun lineBreakWithoutParagraphAlsoSplitsBubbles() {
|
||||
assertEquals(listOf("甲", "乙"), parse("甲<br>乙").map { it.body })
|
||||
assertEquals(listOf("甲", "乙", "丙"), parse("甲<br>乙<br>丙").map { it.body })
|
||||
}
|
||||
|
||||
/** 契约表第三行:`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`(**空气泡照旧丢弃**,与既有 `<p>` 口径一致)。 */
|
||||
@Test
|
||||
fun emptyBubblesBetweenLineBreaksAreDropped() {
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲<br><br>乙</p>").map { it.body })
|
||||
assertEquals(listOf("甲", "乙"), parse("甲<br><br>乙").map { it.body })
|
||||
assertEquals(listOf("甲", "乙"), parse("甲<br>\n<br>乙").map { it.body })
|
||||
assertTrue(parse("<p><br></p>").isEmpty())
|
||||
assertTrue(parse("<br>").isEmpty())
|
||||
assertTrue(parse("<br><br>").isEmpty())
|
||||
}
|
||||
|
||||
/** 契约表第四行:`<br>` 两侧都是空白 ⇒ 不产生多余空气泡(只吃空白,正文一个字不掉)。 */
|
||||
@Test
|
||||
fun whitespaceAroundLineBreakProducesNoExtraBubble() {
|
||||
assertEquals(listOf("甲", "乙"), parse("甲 <br> 乙").map { it.body })
|
||||
assertEquals(listOf("甲", "乙"), parse(" 甲<br>乙 ").map { it.body })
|
||||
assertEquals(listOf("甲"), parse(" <br>甲").map { it.body })
|
||||
assertEquals(listOf("甲"), parse("甲<br> ").map { it.body })
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲<br> </p><p>乙</p>").map { it.body })
|
||||
}
|
||||
|
||||
/** 三种写法等价(`<br>` / `<br/>` / `</br>`),带属性一样算,`<br >` 这种空白写法也收。 */
|
||||
@Test
|
||||
fun lineBreakFormsAreEquivalent() {
|
||||
for (form in listOf("<br>", "<br/>", "<br />", "</br>", "</br/>", "<br >", "<br class=x>")) {
|
||||
assertEquals("「$form」应当是一样的一条分隔", listOf("甲", "乙"), parse("甲${form}乙").map { it.body })
|
||||
assertEquals("「$form」在 `<p>` 里同理", listOf("甲", "乙"), parse("<p>甲${form}乙</p>").map { it.body })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 大小写不敏感(`<BR>` 也分隔):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,且后端 IM
|
||||
* 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 原话)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakTagIsCaseInsensitive() {
|
||||
for (form in listOf("<BR>", "<Br>", "</BR>", "<bR/>", "<BR class=x>")) {
|
||||
assertEquals("「$form」应当是一样的一条分隔", listOf("甲", "乙"), parse("甲${form}乙").map { it.body })
|
||||
}
|
||||
}
|
||||
|
||||
/** 名字后面不是 `>`/空白/`/` 的都不是分隔符:`<br2>`、`<boolean>`、`<br-x>` 只是普通文本。 */
|
||||
@Test
|
||||
fun lookalikeTagsAreNotLineBreaks() {
|
||||
for (raw in listOf("<br2>甲</br2>", "<boolean>甲</boolean>", "<br-x>甲</br-x>", "<br/x>甲", "1 < 2")) {
|
||||
assertEquals("「$raw」不是分隔符", listOf(raw), parse(raw).map { it.body })
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// `<br>` 的**保护区**(`回执三十三 §三②`:只有普通文本层的 `<br>` 才分隔)
|
||||
//
|
||||
// `回执三十一` 说的是「**始终**处理为多条消息隔断」,`回执三十三` 把它收紧了:`<code>` 与角色
|
||||
// 注记**内部**的不分隔。后端给的两条理由在我们这边同样成立:
|
||||
// 1. `<code>` 是**成对**元素,从中间切开必然两半都不平衡 ⇒ 按硬口径「不平衡 ⇒ 不渲染」
|
||||
// **整段降级为字面量**,用户直接看到裸标签;
|
||||
// 2. `<action>她<br>点点头</action>` 若切成两段,就成了「她」「点点头」**两条聊天消息**
|
||||
// ——等于把注记正文当正文发了出去。
|
||||
// 口径与理由的全文写在 `ChatContentParser.appendRegion` / `protectedSpans` 的 KDoc 里。
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `回执三十三 §三②` 第一半:**`<code>` 内部的 `<br>` 不分隔**,且作用域不能被切坏。
|
||||
*
|
||||
* 我们定的口径:作用域里的 `<br>` 就是**普通字符、逐字原样**——与 `<code><b>甲</b></code>` 里的
|
||||
* `<b>` 同一规矩(`codeContentIsNotParsed` 钉着)。所以这条同时钉三件事:**只有一条气泡**、
|
||||
* `<code>` 那一对**被消费掉**(正文里没有裸 `<code>` ⇒ 作用域没被降级)、内容**逐字保留**
|
||||
* (含那个字面的 `<br>`,字符一个不丢)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakInsideCodeDoesNotSplitAndTheScopeSurvives() {
|
||||
val bubbles = parse("<p><code>甲<br>乙</code></p>")
|
||||
assertEquals("`<code>` 里的 `<br>` 不该分隔", 1, bubbles.size)
|
||||
assertEquals("甲<br>乙", bubbles.single().body)
|
||||
assertEquals(listOf(run("甲<br>乙", InlineStyle.Code)), bubbles.single().runs)
|
||||
assertFalse("`<code>` 作用域不该被降级成裸标签", bubbles.single().body.contains("<code"))
|
||||
// 无 `<p>` 时同理(分隔符本来就与 `<p>` 同级)
|
||||
assertEquals(listOf("甲<br>乙"), parse("<code>甲<br>乙</code>").map { it.body })
|
||||
// 分隔符的三种写法 + 大小写 + 属性,在保护区里同样都拦下来(它们先要是分隔符候选)
|
||||
for (form in listOf("<br>", "<br/>", "<br />", "</br>", "<BR>", "<br class=x>")) {
|
||||
val one = parse("<p><code>甲${form}乙</code></p>")
|
||||
assertEquals("「$form」在 `<code>` 里不该分隔", 1, one.size)
|
||||
assertEquals("「$form」必须逐字留在代码里", "甲${form}乙", one.single().body)
|
||||
}
|
||||
// 保护区**只管**作用域内部:`<code>` 外面的 `<br>` 照旧分隔
|
||||
assertEquals(listOf("丙", "甲<br>乙"), parse("<p>丙<br><code>甲<br>乙</code></p>").map { it.body })
|
||||
}
|
||||
|
||||
/**
|
||||
* `回执三十三 §三②` 第二半:**角色注记内部的 `<br>` 不分隔、不产生气泡**。
|
||||
*
|
||||
* 「不产生气泡」正是后端点名的信息泄露:切成两段等于把注记正文当两条聊天消息发出去。
|
||||
* 我们这边的落点是**既有旁白语义**——注记整体被 `ROLE_RE` 认下、进 `head` / `tail`、正文为空;
|
||||
* 注记文本**逐字原文**(`parts` 是红线:编辑框/搜索/通知都读它,而旁白那一行画的正是它),
|
||||
* 所以里面那个 `<br>` 与 `<b>` 一样只是字面文本(`narrationTextIsLeftExactlyAsWritten` 同款)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakInsideNoteBlocksDoesNotSplitAndProducesNoBubble() {
|
||||
for (raw in listOf("<p><action>她<br>点点头</action></p>", "<action>她<br>点点头</action>")) {
|
||||
val bubbles = parse(raw)
|
||||
assertEquals("「$raw」不该切成两条消息", 1, bubbles.size)
|
||||
assertEquals("注记仍然不产生正文气泡", "", bubbles.single().body)
|
||||
assertTrue("注记不做行内渲染 ⇒ runs 为空", bubbles.single().runs.isEmpty())
|
||||
assertEquals(listOf(ContentPart.Action("她<br>点点头")), bubbles.single().head)
|
||||
assertEquals(
|
||||
"注记文本逐字原文(`parts` 红线)",
|
||||
listOf(ContentPart.Action("她<br>点点头")),
|
||||
bubbles.single().parts,
|
||||
)
|
||||
}
|
||||
|
||||
// `<narration>` 同理,且**附着语义一个字没动**:尾部 → tail,首部 → head
|
||||
val tailed = parse("<p>你好<narration>她<br>点点头</narration></p>").single()
|
||||
assertEquals("你好", tailed.body)
|
||||
assertEquals(listOf(ContentPart.Narration("她<br>点点头")), tailed.tail)
|
||||
val headed = parse("<p><narration>她<br>点点头</narration>你好</p>").single()
|
||||
assertEquals("你好", headed.body)
|
||||
assertEquals(listOf(ContentPart.Narration("她<br>点点头")), headed.head)
|
||||
|
||||
// 保护区只管作用域内部:注记**外面**的 `<br>` 照旧分隔,旁白照旧附着到下一段
|
||||
val across = parse("<p><action>她<br>点点头</action><br>你好</p>")
|
||||
assertEquals(listOf("你好"), across.map { it.body })
|
||||
assertEquals(listOf(ContentPart.Action("她<br>点点头")), across.single().head)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保护区**只管 `<br>` 算不算分隔符**,别的语义一个字都不动;而且只有 `<code>` 与角色注记算
|
||||
* 保护区——**白名单行内元素(`<b>` / `<i>` / `<u>`)不算**(W68 登记的「跨分隔符的标签各自回落
|
||||
* 原文」因此逐字保留,见 [inlineTagsCannotSpanALineBreak])。
|
||||
*/
|
||||
@Test
|
||||
fun onlyCodeAndNoteScopesProtectLineBreaks() {
|
||||
// `<b>` 里那个 `<br>`:照样分隔(口径没变)
|
||||
assertEquals(listOf("<b>甲", "乙</b>"), parse("<p><b>甲<br>乙</b></p>").map { it.body })
|
||||
// `<u>` 一样(W71 刚加回白名单,但它不是作用域)
|
||||
assertEquals(listOf("<u>甲", "乙</u>"), parse("<p><u>甲<br>乙</u></p>").map { it.body })
|
||||
// 一个 `<code>` 里几个 `<br>` 全都不分隔
|
||||
assertEquals(listOf("甲<br>乙<br>丙"), parse("<code>甲<br>乙<br>丙</code>").map { it.body })
|
||||
// 保护区**不**改注记/旁白/星号的任何既有语义:注记照样进 tail、`*笑*` 照样是动作,
|
||||
// 正文里只剩 `<code>` 那一段(它没被切开,`<br>` 是代码里的内容)
|
||||
val mixed = parse("<p><code>甲<br>乙</code>与<action>她<br>点点头</action>*笑*</p>").single()
|
||||
assertEquals("甲<br>乙与", mixed.body)
|
||||
assertEquals(
|
||||
listOf(ContentPart.Action("她<br>点点头"), ContentPart.Action("笑")),
|
||||
mixed.tail,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **嵌套**:`<code>` 里再套白名单标签时,里面的东西仍是「内容」(不解析、不计结构),
|
||||
* 保护区也照样罩着它;`<code>` 套在 `<b>` 里时两边的边界各自成立。
|
||||
*/
|
||||
@Test
|
||||
fun nestedTagsInsideACodeZoneAreContentAndTheirLineBreakStillDoesNotSplit() {
|
||||
val codeInCode = parse("<p><code>甲<b>乙<br>丙</b></code></p>")
|
||||
assertEquals(1, codeInCode.size)
|
||||
assertEquals("甲<b>乙<br>丙</b>", codeInCode.single().body)
|
||||
assertEquals(listOf(run("甲<b>乙<br>丙</b>", InlineStyle.Code)), codeInCode.single().runs)
|
||||
|
||||
val boldAroundCode = parse("<p><b>甲<code>乙<br>丙</code>丁</b></p>")
|
||||
assertEquals(1, boldAroundCode.size)
|
||||
assertEquals("甲乙<br>丙丁", boldAroundCode.single().body)
|
||||
assertEquals(
|
||||
listOf(
|
||||
run("甲", InlineStyle.Bold),
|
||||
run("乙<br>丙", InlineStyle.Bold, InlineStyle.Code),
|
||||
run("丁", InlineStyle.Bold),
|
||||
),
|
||||
boldAroundCode.single().runs,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **未闭合的作用域不算作用域**(我们定的口径,理由写在 `appendRegion` 的 KDoc 里):
|
||||
* 判据只看「**成对**」,而消费侧本来就不认未闭合的那个(未闭合的 `<code>` 在行内层不是作用域、
|
||||
* 整段降级为原文;未闭合的 `<action>` 在 `ROLE_RE` 那里也配不出旁白)——去保护一个消费侧不认的
|
||||
* 作用域,等于让切分层与消费层对同一段文本有两种结构判断。
|
||||
* 另一条理由是**爆炸半径**:若「未闭合 ⇒ 保护到这段文本结尾」,一个漏掉 `</code>` 的笔误就会把
|
||||
* 后面所有 `<br>` 一起吞掉、把本该分开的消息并成一条。
|
||||
* 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切。
|
||||
*/
|
||||
@Test
|
||||
fun unclosedProtectedScopesDoNotProtectLineBreaks() {
|
||||
assertEquals(listOf("<code>甲", "乙"), parse("<code>甲<br>乙").map { it.body })
|
||||
assertEquals(listOf("<action>她", "点点头"), parse("<action>她<br>点点头").map { it.body })
|
||||
assertEquals(listOf("<code>甲", "乙"), parse("<p><code>甲<br>乙</p>").map { it.body })
|
||||
// 闭合写错了名字(`</code>` 写成别的)同样不算成对
|
||||
assertEquals(listOf("<code>甲", "乙</action>"), parse("<code>甲<br>乙</action>").map { it.body })
|
||||
// 而且它仍然是**字面**(未闭合那一档照旧「不平衡 ⇒ 不渲染」),一个字都没丢
|
||||
assertEquals("<code>甲", parse("<code>甲<br>乙").first().body)
|
||||
}
|
||||
|
||||
/**
|
||||
* **两支各算各的**(我们定的口径,见 `protectedSpans`):`<code>` 与角色注记各自配对,
|
||||
* 因为那本来就是两个消费侧各自的尺子。所以「某一支没闭合」只影响**那一支**——里面那个已经
|
||||
* 配对好的作用域照样保护它自己的 `<br>`,而没闭合的那一支里的 `<br>` 照旧分隔。
|
||||
*/
|
||||
@Test
|
||||
fun aPairedZoneStillProtectsItsLineBreakWhileAnotherZoneIsUnclosed() {
|
||||
// 外层 `<code>` 没闭合(不保护),里面那个配对好的 `<action>` 照样保护它的 `<br>`
|
||||
val bubbles = parse("<code>甲<br>乙<action>她<br>点点头</action>丙")
|
||||
assertEquals(2, bubbles.size)
|
||||
assertEquals(listOf("<code>甲", "乙丙"), bubbles.map { it.body })
|
||||
assertEquals(
|
||||
"注记整体被认下(语序照原文:`parts` 为准)",
|
||||
listOf(ContentPart.Text("乙"), ContentPart.Action("她<br>点点头"), ContentPart.Text("丙")),
|
||||
bubbles[1].parts,
|
||||
)
|
||||
assertEquals(listOf(ContentPart.Action("她<br>点点头")), bubbles[1].head)
|
||||
assertTrue("注记不产生气泡正文 ⇒ 第二条气泡的 runs 为空", bubbles[1].runs.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式:**保护区**里的残缺 `<br` 一样不许把字吐上屏。
|
||||
*
|
||||
* 中间态靠的是既有那条扣留规则——`<code>` / `<action>` / `<narration>` 的开标签本来就在栈里,
|
||||
* 所以整个作用域(连同里面还没写完的 `<br`)一起被扣到闭合为止。收口后与一次性解析逐字一致,
|
||||
* 且作用域真的没被切开:`<code>` 里的那个 `<br>` 是字面内容。
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsPartialLineBreakInsideProtectedScopes() {
|
||||
// 作用域还没闭合时,屏幕上只该有它前面那一段(里面的字面内容一个字都不许先露脸)
|
||||
for (partial in listOf(
|
||||
"甲<code", "甲<code>乙", "甲<code>乙<br", "甲<CODE>乙<br",
|
||||
"甲<action>她", "甲<action>她<br", "甲<narration>她<br",
|
||||
)) {
|
||||
assertEquals(
|
||||
"「$partial」残缺时必须扣住,只显示作用域前的正文",
|
||||
listOf("甲"),
|
||||
StreamingContentParser().onDelta(partial).map { it.body },
|
||||
)
|
||||
}
|
||||
|
||||
val text = "甲<code>乙<br>丙</code>丁"
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in text) {
|
||||
val bodies = parser.onDelta(ch.toString()).map { it.body }
|
||||
assertTrue("`<code>` 漏上了屏:$bodies", bodies.none { it.contains("<code") })
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
assertEquals(listOf("甲乙<br>丙丁"), parser.finish().map { it.body })
|
||||
assertEquals(parse(text), parser.finish())
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨分隔符的行内标签:**先切分、再逐段各自做行内解析** ⇒ 两段各自「结构不平衡」⇒ 两段都回落原文。
|
||||
*
|
||||
* 层序理由写在 `ChatContentParser.appendRegion` 的 KDoc:`<br>` 与 `<p>` 同级 ⇒ 分隔符先于行内
|
||||
* 解析;而且一个气泡 = 一条消息,跨分隔符的标签在结构上不可能成立(后端剥标签时同样是按单条
|
||||
* 消息判平衡的)。
|
||||
*/
|
||||
@Test
|
||||
fun inlineTagsCannotSpanALineBreak() {
|
||||
for (raw in listOf("<p><b>甲<br>乙</b></p>", "<b>甲<br>乙</b>")) {
|
||||
val bubbles = parse(raw)
|
||||
assertEquals("「$raw」两段都该回落原文", listOf("<b>甲", "乙</b>"), bubbles.map { it.body })
|
||||
assertTrue(bubbles.all { it.runs.isEmpty() })
|
||||
}
|
||||
|
||||
// 每一段**各自**平衡时照常渲染:粗体只罩住它自己那一段
|
||||
val ok = parse("<p><b>甲</b><br><b>乙</b></p>")
|
||||
assertEquals(listOf("甲", "乙"), ok.map { it.body })
|
||||
assertEquals(listOf(run("甲", InlineStyle.Bold)), ok[0].runs)
|
||||
assertEquals(listOf(run("乙", InlineStyle.Bold)), ok[1].runs)
|
||||
}
|
||||
|
||||
/** `parts` 仍是**逐字原文**:分隔符 `<br>` 与 `<p>` / `</p>` 一样属于结构,不进片段。 */
|
||||
@Test
|
||||
fun lineBreakIsStructureSoItStaysOutOfParts() {
|
||||
val bubble = parse("<p>日落 <b>18:35</b><br>记住了。</p>").first()
|
||||
assertEquals(listOf(ContentPart.Text("日落 <b>18:35</b>")), bubble.parts)
|
||||
assertEquals("日落 18:35", bubble.body)
|
||||
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubble.runs)
|
||||
}
|
||||
|
||||
/** `<br>` 与旁白依附的关系:纯旁白那一段照旧不独立成气泡,跨分隔符附着到下一段(与 `<p>` 同款)。 */
|
||||
@Test
|
||||
fun noteOnlySegmentStillAttachesAcrossALineBreak() {
|
||||
val bubbles = parse("<action>笑</action><br>你好")
|
||||
assertEquals(1, bubbles.size)
|
||||
assertEquals("你好", bubbles[0].body)
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].head)
|
||||
}
|
||||
|
||||
/** 逐字符流式:中间态一帧都不许把 `<br` 当正文吐上屏,收口后真的变成两条气泡。 */
|
||||
@Test
|
||||
fun lineBreakSplitsBubblesWhileStreaming() {
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in "甲<br>乙") {
|
||||
val bodies = parser.onDelta(ch.toString()).map { it.body }
|
||||
assertTrue("中间态把标签漏上了屏:$bodies", bodies.none { it.contains('<') })
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
assertEquals(listOf("甲", "乙"), parser.finish().map { it.body })
|
||||
}
|
||||
|
||||
/** 残缺的 `<br` 同样扣留(否则中间态先把字面 `<br` 吐上屏、收口时再变成两条气泡——字会跳)。 */
|
||||
@Test
|
||||
fun streamingWithholdsPartialLineBreakTag() {
|
||||
for (partial in listOf("甲<br", "甲<BR", "甲</br", "甲<br/", "甲<br ", "甲<br class=\"x\"")) {
|
||||
val mid = StreamingContentParser().onDelta(partial)
|
||||
assertEquals("「$partial」残缺时必须扣住,只显示分隔符前的正文", listOf("甲"), mid.map { it.body })
|
||||
}
|
||||
|
||||
// 补全之后才产出第二条气泡,且已显示过的正文没有改口
|
||||
val parser = StreamingContentParser()
|
||||
assertEquals(listOf("甲"), parser.onDelta("甲<br").map { it.body })
|
||||
assertEquals(listOf("甲"), parser.onDelta(" class=x").map { it.body })
|
||||
assertEquals(listOf("甲", "乙"), parser.onDelta(">乙").map { it.body })
|
||||
assertEquals(listOf("甲", "乙"), parser.finish().map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 旁白依附:首部 → head,尾部 → tail,中间 → 上提 head
|
||||
// ------------------------------------------------------------------
|
||||
@@ -325,6 +667,14 @@ class ChatContentParserTest {
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
|
||||
}
|
||||
|
||||
/** 剥不剥只看它**后面**还有没有内容:后面有正文的留字面,后面什么都没有的才剥。 */
|
||||
@Test
|
||||
fun aLooseTagIsKeptWheneverThereIsStillContentAfterIt() {
|
||||
assertEquals(listOf("<p>甲<p>乙"), parse("<p>甲<p>乙").map { it.body })
|
||||
assertEquals(listOf("<p>甲"), parse("<p>甲<p>").map { it.body })
|
||||
assertEquals(listOf("<p>甲"), parse("<p>甲<p><p><p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayRoleTagOutsideParagraphIsDropped() {
|
||||
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
|
||||
@@ -509,6 +859,115 @@ class ChatContentParserTest {
|
||||
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 复杂度回归:一大段落单标签不许把 parse 拖成 O(N²)
|
||||
//
|
||||
// ⚠️ 这条测的是**复杂度**,不是精确耗时:阈值宽到 20000 个落单标签给 2 秒,只用来区分
|
||||
// 「线性」与「平方」。修复前实测:2000 个 854ms、8000 个 4254ms、20000 个 26405ms(每翻
|
||||
// 一倍涨约 4 倍,典型平方);修复后 20000 个是十几毫秒。所以阈值别卡紧(CI 机器可能更慢),
|
||||
// 这里量到的数也别当性能指标读——要盯的是「有没有退回 O(N²)」。
|
||||
//
|
||||
// 这个形状同时压住两处(修复前各占一部分,都在 `ChatContentParser` 里):
|
||||
// - 落单标签的「后面还有没有正文」判据(`ContentAfterQuery`,修复前约 23 秒);
|
||||
// - 完整块搜索 `P_BLOCK_RE.findAll`(没有 `</p>` 时每个 `<p` 候选点都回溯到串尾,约 8.2 秒)。
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 复杂度回归的宽松上限(毫秒):只区分线性与平方,不是耗时指标。 */
|
||||
private val looseTagBudgetMs = 2_000L
|
||||
|
||||
@Test
|
||||
fun hugeRunOfLooseTagsIsDroppedWithinALinearBudget() {
|
||||
val count = 20_000
|
||||
val inputs = listOf(
|
||||
"<p>".repeat(count), // 一串纯落单开标签
|
||||
" \n<p> ".repeat(count), // 标签之间夹空白:跳过空白后仍是「后面什么都没有」
|
||||
)
|
||||
for (input in inputs) {
|
||||
val startedAt = System.nanoTime()
|
||||
val bubbles = parse(input)
|
||||
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
|
||||
assertTrue("落单标签应当被剥净,实际留下:${bubbles.map { it.body }}", bubbles.isEmpty())
|
||||
assertTrue(
|
||||
"${input.length} 字符($count 个落单标签)用了 ${elapsedMs}ms," +
|
||||
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
|
||||
elapsedMs < looseTagBudgetMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `<br>` 那趟扫描([BR_TAG_RE] 的 `findAll`)同样不许把 `parse` 拖成平方:一串只有尖括号、
|
||||
* 或一串「像分隔符但缺一个字符」的文本,每个 `<` 都只允许常数级尝试。
|
||||
*/
|
||||
@Test
|
||||
fun hugeRunOfLineBreakLookalikesStaysWithinALinearBudget() {
|
||||
val count = 20_000
|
||||
for (input in listOf("<br".repeat(count), "<".repeat(count), "<br)x".repeat(count / 3))) {
|
||||
val startedAt = System.nanoTime()
|
||||
val bubbles = parse(input)
|
||||
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
|
||||
assertTrue("「${input.take(6)}…」不该分出气泡:${bubbles.size}", bubbles.size == 1)
|
||||
assertTrue(
|
||||
"${input.length} 字符(一串像 `<br>` 的文本)用了 ${elapsedMs}ms," +
|
||||
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
|
||||
elapsedMs < looseTagBudgetMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* W71 新增的那趟**保护区扫描**([protectedSpans])同样不许把 `parse` 拖成平方:一串只有作用域
|
||||
* 开标签、或一串「像作用域」的尖括号,每个 `<` 都只允许常数级尝试;成对作用域那一趟靠
|
||||
* 「区间按起点升序 + 游标不回头」,整体仍是线性的。判据与上一条同源(同样只是「线性 or 平方」)。
|
||||
*
|
||||
* ⚠️ 刻意**不**喂 `"<action>".repeat(n)`:那个形状会先踩到 `ROLE_RE` 自己的回溯(一串没有闭合的
|
||||
* 角色开标签,正则引擎每处都要 `([\s\S]*?)` 一路试到串尾),那是**既有**的平方点、与本次改动无关,
|
||||
* 拿它来当预算用例只会把一个既存问题记到新代码头上。这里用的是 `<code>`(不是 `ROLE_RE` 的词)。
|
||||
*/
|
||||
@Test
|
||||
fun hugeRunOfScopeLikeTagsStaysWithinALinearBudget() {
|
||||
val count = 20_000
|
||||
val budget = { input: String, label: String, elapsedMs: Long ->
|
||||
assertTrue(
|
||||
"${input.length} 字符($label)用了 ${elapsedMs}ms," +
|
||||
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
|
||||
elapsedMs < looseTagBudgetMs,
|
||||
)
|
||||
}
|
||||
|
||||
// 一串只有开标签:未闭合 ⇒ 不产生区间
|
||||
val unclosed = "<code>".repeat(count)
|
||||
val unclosedAt = System.nanoTime()
|
||||
val unclosedBubbles = parse(unclosed)
|
||||
budget(unclosed, "一串未闭合的 `<code>`", (System.nanoTime() - unclosedAt) / 1_000_000)
|
||||
assertEquals(1, unclosedBubbles.size)
|
||||
assertEquals(unclosed, unclosedBubbles.single().body)
|
||||
|
||||
// 一串「开标签 + `<br>`」:每个 `<br>` 都不在保护区里 ⇒ 每个都分隔
|
||||
val split = "<code>甲<br>".repeat(count)
|
||||
val splitAt = System.nanoTime()
|
||||
val splitBubbles = parse(split)
|
||||
budget(split, "$count 个未闭合的 `<code>` + `<br>`", (System.nanoTime() - splitAt) / 1_000_000)
|
||||
assertEquals(count, splitBubbles.size)
|
||||
|
||||
// 一串**成对**的作用域(每个里面一个 `<br>`):全部被保护 ⇒ 一条气泡、逐字内容
|
||||
val paired = "<code>甲<br>乙</code>".repeat(count / 4)
|
||||
val pairedAt = System.nanoTime()
|
||||
val pairedBubbles = parse(paired)
|
||||
budget(paired, "$count 个成对作用域", (System.nanoTime() - pairedAt) / 1_000_000)
|
||||
assertEquals("每个 `<br>` 都在保护区里 ⇒ 一条气泡", 1, pairedBubbles.size)
|
||||
assertEquals("甲<br>乙".repeat(count / 4), pairedBubbles.single().body)
|
||||
|
||||
// 角色注记那一支:一串成对的注记,各自保护自己的 `<br>`(一条气泡、注记全认下)
|
||||
val noted = "<action>她<br>点点头</action>".repeat(2_000)
|
||||
val notedAt = System.nanoTime()
|
||||
val notedBubbles = parse(noted)
|
||||
budget(noted, "2000 条成对注记", (System.nanoTime() - notedAt) / 1_000_000)
|
||||
assertEquals(1, notedBubbles.size)
|
||||
assertEquals(2_000, notedBubbles.single().head.size)
|
||||
assertEquals("", notedBubbles.single().body)
|
||||
}
|
||||
|
||||
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
|
||||
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
|
||||
for (bubble in bubbles) {
|
||||
@@ -517,4 +976,6 @@ class ChatContentParserTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun run(text: String, vararg styles: InlineStyle) = InlineRun(text, styles.toSet())
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
package top.yeij.cyrene.domain.parse
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.chat.editableMessageOf
|
||||
import top.yeij.cyrene.ui.chat.plainSearchText
|
||||
import top.yeij.cyrene.ui.chat.sharePreviewBlocks
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* 行内标签(`InlineMarkup.kt`)单测:六项白名单各自渲染成什么、边界口径、以及
|
||||
* 「星号语义一个字都没被动过」的反证。
|
||||
*
|
||||
* 断言的是**纯函数的结果**(片段文本 + 样式),不碰 Compose:`runs` 拼起来就是渲染文本,
|
||||
* 所以「哪段字是什么样式」在这里是直接可断言的。
|
||||
*
|
||||
* ## 口径沿革(三轮,都按后端契约改的)
|
||||
* **W68 按 v0.89**:标签名**大小写不敏感**(`<B>` = `<b>`);**属性一律不解析**
|
||||
* (`<b class="x">` 就是粗体,属性整段丢掉);**结构不平衡 ⇒ 该气泡正文整段降级为原文**
|
||||
* (取代 W67 的「只坏那一对」)。⚠️ 大小写那条**消费侧与流式扣留侧必须一起改**:
|
||||
* `<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>` 到了再吃掉,
|
||||
* 屏幕上显示过的字会改口(由 `streamingWithholdsMixedCaseAndAttributeTagsToo` 钉着)。
|
||||
*
|
||||
* **W69 按 `回执三十` / `回执三十一`**:
|
||||
* 1. **白名单收窄到五项**(`b` / `strong` / `i` / `em` / `code`)⇒ **`u` / `s` 撤掉**:
|
||||
* 它们在她 100 条输出里 0 命中,多支持反而是制造跨端不一致(Web 不渲染、我们渲染)。
|
||||
* 2. **`<br>` 不是行内标签**,而是与 `<p>` 同级的气泡/消息分隔符 ⇒ 从这一层**整体移出**,
|
||||
* 切分改在 `ChatContentParser.appendRegion`,用例搬去 `ChatContentParserTest`。
|
||||
* 这一层里 `<br>` 只剩「普通字符」这一种身份(见 [lineBreakTagIsNotAnInlineTagHere])。
|
||||
*
|
||||
* **W71 按 v0.89 最终版(`回执三十三`,收口)**:
|
||||
* 1. **`u` 加回**:`回执三十三 §一` 明说「收窄到五项」是**后端的疏漏** —— 主人裁定下划线保留,
|
||||
* 后端把 `u` 加回契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。
|
||||
* 最终白名单 = **六项**,`u` 在这一层渲染成下划线([underlineBecomesAnUnderlineRun]);
|
||||
* **`s` 仍不在内**、逐字原样([sEveryCaseAndShapeIsStillShownLiterally])。
|
||||
* 2. **`<br>` 的「始终分隔」收紧成「只有普通文本层才分隔」**(`回执三十三 §三②`):`<code>` 与
|
||||
* 角色注记**内部**的不分隔。那两处的切分在 `ChatContentParser`(保护区),这一层只是因此**真的**
|
||||
* 会收到 `<br>` —— `<code>` 里的那个会走到这里,按「内容」字面显示
|
||||
* ([lineBreakInsideCodeStaysLiteralContent])。
|
||||
*
|
||||
* 凡是不与这三轮契约冲突的老断言(绝不丢字、`parts` 逐字原文、星号语义、流式一致性、复杂度)
|
||||
* 一条都没放松。
|
||||
*/
|
||||
class InlineMarkupTest {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 六项白名单各自渲染成什么
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun boldTagBecomesABoldRun() {
|
||||
assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("<b>18:35</b>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strongIsJustAnotherWayToWriteBold() {
|
||||
assertEquals(listOf(run("18:35", InlineStyle.Bold)), parseInlineMarkup("<strong>18:35</strong>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iAndEmBothBecomeItalicRuns() {
|
||||
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<i>斜</i>"))
|
||||
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<em>斜</em>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun codeTagBecomesACodeRun() {
|
||||
assertEquals(listOf(run("abc", InlineStyle.Code)), parseInlineMarkup("<code>abc</code>"))
|
||||
}
|
||||
|
||||
/**
|
||||
* W71 按 `回执三十三 §一` 新增(`u` 加回白名单,六项):`<u>x</u>` 渲染成**下划线**
|
||||
* (`InlineStyle.Underline` → `MessageBubble.inlineBody` 里的 `TextDecoration.Underline`)。
|
||||
*
|
||||
* 加回的原因写在类 KDoc:`回执三十` 那次「收窄到五项」是后端的疏漏,主人裁定下划线保留。
|
||||
*/
|
||||
@Test
|
||||
fun underlineBecomesAnUnderlineRun() {
|
||||
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<u>重点</u>"))
|
||||
// 大小写不敏感、属性同样整段丢掉(与其余五项同一套判据)
|
||||
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<U>重点</U>"))
|
||||
assertEquals(listOf(run("重点", InlineStyle.Underline)), parseInlineMarkup("<u class=x>重点</u>"))
|
||||
// 与别的样式叠加、与别的标签混排
|
||||
assertEquals(
|
||||
listOf(run("加粗又下划线", InlineStyle.Bold, InlineStyle.Underline)),
|
||||
parseInlineMarkup("<b><u>加粗又下划线</u></b>"),
|
||||
)
|
||||
val bubble = parse("<p><b>粗</b>与<u>下划线</u></p>").single()
|
||||
assertEquals("粗与下划线", bubble.body)
|
||||
assertEquals(
|
||||
listOf(run("粗", InlineStyle.Bold), run("与"), run("下划线", InlineStyle.Underline)),
|
||||
bubble.runs,
|
||||
)
|
||||
// 未配成对时仍是「整段降级」那一档,不因为多了一项而放松
|
||||
assertTrue(parseInlineMarkup("<u>没有闭合").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedTagsSplitTheTextIntoStyledRuns() {
|
||||
assertEquals(
|
||||
listOf(run("记住 "), run("abc", InlineStyle.Code), run(" 与 "), run("斜体", InlineStyle.Italic), run(" 哦")),
|
||||
parseInlineMarkup("记住 <code>abc</code> 与 <i>斜体</i> 哦"),
|
||||
)
|
||||
}
|
||||
|
||||
/** 线上实测的那一条:只有 `<b>` 一种标签,命中的正是她给的那个时间点。 */
|
||||
@Test
|
||||
fun theRealMessageRendersAsThreeRuns() {
|
||||
val runs = parseInlineMarkup("日落 <b>18:35</b>,比上海晚了整整一小时呢。")
|
||||
assertEquals(
|
||||
listOf(
|
||||
run("日落 "),
|
||||
run("18:35", InlineStyle.Bold),
|
||||
run(",比上海晚了整整一小时呢。"),
|
||||
),
|
||||
runs,
|
||||
)
|
||||
assertEquals("日落 18:35,比上海晚了整整一小时呢。", runs.concat())
|
||||
}
|
||||
|
||||
/** 没有标签时返回空列表 = 调用方走原来的字面路径(这是绝大多数消息的那一档)。 */
|
||||
@Test
|
||||
fun plainTextYieldsNoRunsAtAll() {
|
||||
assertTrue(parseInlineMarkup("今天那儿是晴,13~25℃").isEmpty())
|
||||
assertTrue(parseInlineMarkup("").isEmpty())
|
||||
assertTrue(parseInlineMarkup("多行\n文本\n也是普通的").isEmpty())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约 v0.89 之一:标签名大小写不敏感
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 新增(原口径「只认小写」与它冲突,见类 KDoc):**六项**标签的大小写任意写法
|
||||
* 都认,且改不成别的样式——`<B>` 就是 `<b>`(`u` 由 W71 按 `回执三十三 §一` 加回)。
|
||||
*/
|
||||
@Test
|
||||
fun tagNamesAreCaseInsensitive() {
|
||||
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<B>粗</B>"))
|
||||
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<Strong>粗</Strong>"))
|
||||
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<STRONG>粗</STRONG>"))
|
||||
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<I>斜</I>"))
|
||||
assertEquals(listOf(run("斜", InlineStyle.Italic)), parseInlineMarkup("<Em>斜</Em>"))
|
||||
assertEquals(listOf(run("码", InlineStyle.Code)), parseInlineMarkup("<CODE>码</CODE>"))
|
||||
assertEquals(listOf(run("线", InlineStyle.Underline)), parseInlineMarkup("<U>线</U>"))
|
||||
|
||||
// 大小写混着嵌套也照常叠加
|
||||
assertEquals(
|
||||
listOf(run("粗斜", InlineStyle.Bold, InlineStyle.Italic)),
|
||||
parseInlineMarkup("<B><i>粗斜</i></B>"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 大写**只对行内标签**成立:段落/旁白标签仍只认小写(既有契约 `<P>大小写不认</P>`、
|
||||
* `MessageEditText` 同一口径;契约也写明「`<action>`/`<narration>` 的既有行为逐字未改」)。
|
||||
*/
|
||||
@Test
|
||||
fun uppercaseStillMeansNothingForParagraphAndRoleTags() {
|
||||
assertEquals(listOf("<P>大小写不认</P>"), parse("<P>大小写不认</P>").map { it.body })
|
||||
assertEquals(listOf("甲<ACTION>笑</ACTION>"), parse("<p>甲<ACTION>笑</ACTION></p>").map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约 v0.89 之二:属性一律不解析
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 改(原口径「`<b class="x">` 原样保留」与它冲突,见类 KDoc):
|
||||
* 白名单标签带任何属性都按标签名处理,属性**整段丢掉**,绝不落进正文、更不落成任何视图属性。
|
||||
*/
|
||||
@Test
|
||||
fun attributesAreDroppedEntirely() {
|
||||
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b class=\"x\">粗</b>"))
|
||||
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b onclick=\"y\">粗</b>"))
|
||||
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b class=x onclick=y style=z>粗</b>"))
|
||||
assertEquals(listOf(run("粗", InlineStyle.Bold)), parseInlineMarkup("<b data-甲=一>粗</b>"))
|
||||
assertEquals(listOf(run("码", InlineStyle.Code)), parseInlineMarkup("<code class=lang>码</code>"))
|
||||
|
||||
// 属性里那串字一个都不许留在渲染文本里(`class` / `x` / `onclick` / `y` 全都不见)
|
||||
val runs = parseInlineMarkup("日落 <b class=\"x\" onclick=\"y\">18:35</b>,比上海晚了整整一小时呢。")
|
||||
assertEquals(
|
||||
listOf(run("日落 "), run("18:35", InlineStyle.Bold), run(",比上海晚了整整一小时呢。")),
|
||||
runs,
|
||||
)
|
||||
assertFalse(runs.concat().contains("class"))
|
||||
assertFalse(runs.concat().contains("onclick"))
|
||||
|
||||
// 带属性的标签同样要配成对:`<b class=x>` 没闭合 ⇒ 整段降级(不是「属性被丢、粗体照旧」)
|
||||
assertEquals("<b class=\"x\">甲", parse("<p><b class=\"x\">甲</p>").single().body)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 边界 2:嵌套;边界 5:code 里的内容不再解析
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun nestedBoldItalicMergesTheTwoStyles() {
|
||||
assertEquals(
|
||||
listOf(run("粗", InlineStyle.Bold), run("粗斜", InlineStyle.Bold, InlineStyle.Italic)),
|
||||
parseInlineMarkup("<b>粗<i>粗斜</i></b>"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boldOutsideItalicKeepsBothStyles() {
|
||||
assertEquals(
|
||||
listOf(run("甲", InlineStyle.Bold), run("乙", InlineStyle.Bold, InlineStyle.Code), run("丙", InlineStyle.Bold)),
|
||||
parseInlineMarkup("<b>甲<code>乙</code>丙</b>"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `<code>` 里想显示什么就显示什么——包括尖括号(这也是「想显示尖括号怎么办」的答案)。
|
||||
*
|
||||
* 它与契约「白名单外按纯文本原样显示」**不冲突、方向一致**,但不是同一条规则:第 1 条管的是
|
||||
* 白名单外的**标签名**,`<code>` 管的是一条**作用域**(里面连白名单标签也按内容处理)。
|
||||
* 两者的结果都落在同一条硬口径上:字符一个不丢。理由写在 `parseInlineMarkup` 的 KDoc 里。
|
||||
*/
|
||||
@Test
|
||||
fun codeContentIsNotParsed() {
|
||||
assertEquals(listOf(run("<b>甲</b>", InlineStyle.Code)), parseInlineMarkup("<code><b>甲</b></code>"))
|
||||
assertEquals(listOf(run("1 < 2 && a<b", InlineStyle.Code)), parseInlineMarkup("<code>1 < 2 && a<b</code>"))
|
||||
// 里面的东西也**不算结构**:这个「未闭合的 `<b>`」只是四个字符,不把整段拖成字面
|
||||
assertEquals(listOf(run("<b>甲", InlineStyle.Code), run(" 尾")), parseInlineMarkup("<code><b>甲</code> 尾"))
|
||||
}
|
||||
|
||||
/**
|
||||
* W69(`回执三十一`):`<br>` **不再是行内标签**,所以这一层对它只剩「普通字符」这一种身份——
|
||||
* 它不换行、也不影响旁边的成对标签(`<b>甲<br>乙</b>` 在这里是一对正常的粗体,文本里带着
|
||||
* 字面的 `<br>`)。
|
||||
*
|
||||
* 真实链路上这一层**只有一条路**会看到 `<br>`:`<code>` 作用域内部的那些(`回执三十三 §三②`
|
||||
* 起,分隔符切分层有保护区,`<code>` / `<action>` / `<narration>` 里的 `<br>` 不分隔)。
|
||||
* 其余形状只可能出现在「函数被单独调用」的场景里。气泡切分那一侧的用例在
|
||||
* `ChatContentParserTest`(含三种写法等价、空气泡丢弃、保护区、跨分隔符的标签各自回落原文)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakTagIsNotAnInlineTagHere() {
|
||||
assertEquals(listOf(run("甲<br>乙", InlineStyle.Bold)), parseInlineMarkup("<b>甲<br>乙</b>"))
|
||||
assertTrue(parseInlineMarkup("甲<br>乙").isEmpty())
|
||||
assertTrue(parseInlineMarkup("甲<br/>乙").isEmpty())
|
||||
assertTrue(parseInlineMarkup("甲</br>乙").isEmpty())
|
||||
assertTrue(parseInlineMarkup("甲<br2>乙</br2>").isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* W71(`回执三十三 §三②`):`<code>` 作用域**内部**的 `<br>` 走到这一层时,就是代码里的几个
|
||||
* 普通字符——**逐字原样**(不换行、也不消掉),与 `<code><b>甲</b></code>` 里的 `<b>` 同一规矩。
|
||||
*
|
||||
* 口径的理由写在 `ChatContentParser.appendRegion` 的 KDoc 里(注记那一侧要显示 `parts` 的逐字
|
||||
* 原文 = 红线,所以同一个形状不能在 `<code>` 里是一种意思、在 `<action>` 里又是另一种;
|
||||
* 「换成 `\n`」还得在这一层破例给 `<br>` 单开一条规则——W68 刚从这一层把它整体删掉)。
|
||||
*/
|
||||
@Test
|
||||
fun lineBreakInsideCodeStaysLiteralContent() {
|
||||
assertEquals(listOf(run("甲<br>乙", InlineStyle.Code)), parseInlineMarkup("<code>甲<br>乙</code>"))
|
||||
// `<code>` 里再套 `<b>`、`<code>` 套在 `<b>` 里:内容一律字面,`<br>` 也是内容的一部分
|
||||
assertEquals(
|
||||
listOf(run("甲<b>乙<br>丙</b>", InlineStyle.Code)),
|
||||
parseInlineMarkup("<code>甲<b>乙<br>丙</b></code>"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(run("甲", InlineStyle.Bold), run("乙<br>丙", InlineStyle.Bold, InlineStyle.Code), run("丁", InlineStyle.Bold)),
|
||||
parseInlineMarkup("<b>甲<code>乙<br>丙</code>丁</b>"),
|
||||
)
|
||||
}
|
||||
|
||||
/** 而它作为**气泡分隔符**的作用由气泡切分层负责:`<br>` 现在真的分成两条气泡。 */
|
||||
@Test
|
||||
fun lineBreakTagSplitsBubblesAtTheParserLevel() {
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲<br>乙</p>").map { it.body })
|
||||
assertEquals(listOf("甲", "乙"), parse("甲<br>乙").map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约 v0.89 之三:结构不平衡 ⇒ 整段降级为原文
|
||||
// (粒度是「该气泡的正文」;W67 的局部降级口径与它冲突,W68 已按契约改掉)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 改:**未闭合**的开标签 ⇒ 整段原文。
|
||||
* 返回空列表 = 调用方按字面渲染,正文一个字符都不动。
|
||||
*/
|
||||
@Test
|
||||
fun unpairedTagsStayLiteral() {
|
||||
// 没有闭合 ⇒ 整段字面(列表为空 = 按字面渲染,正文一个字符都不动)
|
||||
assertTrue(parseInlineMarkup("<b>没有闭合").isEmpty())
|
||||
assertTrue(parseInlineMarkup("没有闭合</b>").isEmpty())
|
||||
assertTrue(parseInlineMarkup("日落 <b>18:35").isEmpty())
|
||||
assertTrue(parseInlineMarkup("<i>斜").isEmpty())
|
||||
assertTrue(parseInlineMarkup("<code>看星星").isEmpty())
|
||||
// 大小写混用、带属性也一样算未闭合
|
||||
assertTrue(parseInlineMarkup("日落 <B>18:35").isEmpty())
|
||||
assertTrue(parseInlineMarkup("日落 <b class=x>18:35").isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 改:原断言是「配成对的另一半照常生效,只有落单那半个留着字面」
|
||||
* (`<b>甲<i>乙</i>` ⇒ `<b>甲` 字面 + `乙` 斜体)。契约把它换成了**整段降级**:
|
||||
* 一处不平衡,这一整段一个标签都不认——粗粒度换来「可逐字断言」,理由见类 KDoc。
|
||||
*/
|
||||
@Test
|
||||
fun oneUnclosedTagFallsTheWholeBodyBackToLiteralText() {
|
||||
assertTrue(parseInlineMarkup("<b>甲<i>乙</i>").isEmpty())
|
||||
assertEquals("<b>甲<i>乙</i>", parse("<p><b>甲<i>乙</i></p>").single().body)
|
||||
assertEquals(emptyList<InlineRun>(), parse("<p><b>甲<i>乙</i></p>").single().runs)
|
||||
}
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 改:原断言是「`</b>` 关不掉 `i`,于是它连同 `<b>` 一起按字面留着,
|
||||
* 只有配成对的 `<i>…</i>` 生效」。契约把它换成整段降级:**交错**是最容易各端各说各话的形状,
|
||||
* 一律按原文显示。
|
||||
*/
|
||||
@Test
|
||||
fun interleavedTagsFallTheWholeBodyBackToLiteralText() {
|
||||
assertTrue(parseInlineMarkup("<b>甲<i>乙</b>丙</i>").isEmpty())
|
||||
assertEquals("<b>甲<i>乙</b>丙</i>", parse("<p><b>甲<i>乙</b>丙</i></p>").single().body)
|
||||
}
|
||||
|
||||
/** W68 按契约 v0.89 改:原断言是「落在后面的那个 `</b>` 什么都不关,前面的粗体照旧」。 */
|
||||
@Test
|
||||
fun aStrayCloseTagFallsTheWholeBodyBackToLiteralText() {
|
||||
// 落单的闭标签(栈空)与「只有闭标签」都是不平衡 ⇒ 整段原文
|
||||
assertTrue(parseInlineMarkup("<b>甲</b>乙</b>").isEmpty())
|
||||
assertTrue(parseInlineMarkup("</b>").isEmpty())
|
||||
assertTrue(parseInlineMarkup("甲</i>乙</b>").isEmpty())
|
||||
assertEquals("<b>甲</b>乙</b>", parse("<p><b>甲</b>乙</b></p>").single().body)
|
||||
}
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 新增:**四种不平衡形状**各自的整段降级,且降级的是「整段原文」,
|
||||
* 逐字对照。
|
||||
*/
|
||||
@Test
|
||||
fun everyImbalancedShapeFallsBackToTheOriginalTextCharacterForCharacter() {
|
||||
for (raw in listOf(
|
||||
"日落 <b>18:35", // 未闭合
|
||||
"甲<b>粗</b>乙<b>没闭合", // 一半配好、后一半未闭合
|
||||
"甲<b>粗</b>乙</b>", // 落单闭标签
|
||||
"</b>", // 只有闭标签
|
||||
"<b>甲<i>乙</b>丙</i>", // 交错
|
||||
"<B>甲", // 大写 + 未闭合
|
||||
"<b class=x>甲", // 属性 + 未闭合
|
||||
)) {
|
||||
assertTrue("「$raw」结构不平衡,必须整段降级", parseInlineMarkup(raw).isEmpty())
|
||||
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
|
||||
assertTrue(parse("<p>$raw</p>").single().runs.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约说的「整段」= **该气泡的正文**(粒度口径与理由写在 `parseInlineMarkup` 的 KDoc 里)。
|
||||
* 这条钉住它的边界:**坏掉的那条气泡整段原文,别条气泡照常渲染**——既不是整条消息,
|
||||
* 也不是(更细的)按行/按句局部生效。
|
||||
*/
|
||||
@Test
|
||||
fun theFallbackCoversOnlyTheBubbleThatIsBroken() {
|
||||
val bubbles = parse("<p>日落 <b>18:35</b></p><p>甲<b>没闭合</p>")
|
||||
assertEquals(listOf("日落 18:35", "甲<b>没闭合"), bubbles.map { it.body })
|
||||
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubbles[0].runs)
|
||||
assertTrue(bubbles[1].runs.isEmpty())
|
||||
|
||||
// 落单闭标签同理:只影响它自己那条气泡
|
||||
val strayClose = parse("<p>日落 <b>18:35</b></p><p>乙</b></p>")
|
||||
assertEquals(listOf("日落 18:35", "乙</b>"), strayClose.map { it.body })
|
||||
assertTrue(strayClose[1].runs.isEmpty())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约之一:白名单外一律按纯文本原样显示(字符一个不丢)
|
||||
// W71 起 `s` 在这一档里(六项白名单不含它;`u` 曾经短暂撤出、已加回)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun plainAngleBracketsAreNeverEatenAndNeverRendered() {
|
||||
for (text in listOf(
|
||||
"1 < 2 且 3 > 2",
|
||||
"a<b",
|
||||
"a<b>c", // 没有 `</b>` ⇒ 未闭合 ⇒ 整段字面
|
||||
"你好<b", // 残缺,没有 `>`
|
||||
"<p2>这不关 p 标签的事</p2>",
|
||||
"<span>不认识的标签</span>",
|
||||
"<br2>名字后面不是空白</br2>",
|
||||
"<b2>名字后面不是空白</b2>",
|
||||
"<boolean>以 b 开头的普通单词</boolean>",
|
||||
)) {
|
||||
val runs = parseInlineMarkup(text)
|
||||
assertTrue("「$text」不该被判成行内标签", runs.isEmpty())
|
||||
assertEquals("「$text」必须逐字保留", text, parse("<p>$text</p>").single().body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约第 1 条点名的形状:`<script>` 之类**白名单外**的标签显示成字面量,字符一个不丢——
|
||||
* 渲染侧是 `AnnotatedString` + `SpanStyle`(结构化),正文从来不经过任何 HTML 解析/注入。
|
||||
*
|
||||
* **`s` 落在这一档**:白名单 v0.89 最终版是六项(`b`/`strong`/`i`/`em`/`code`/`u`),**`s` 不在内**
|
||||
* ——`回执三十` 撤它、`回执三十三 §一` 加回 `u` 时明确「`s` 仍不在内」,所以它逐字原样。
|
||||
* (`u` 在这一档里只待了 W69 一轮,见 [underlineBecomesAnUnderlineRun] 与类 KDoc。)
|
||||
*
|
||||
* ⚠️ 我们的读法(**待与后端确认**):白名单外的标签**不参与结构判定**——它们按纯文本算,
|
||||
* 所以既不会因为「没闭合」把整段拖成字面(下面第二条),配对与否也不改变别的标签;
|
||||
* 只有白名单那**六个**名字才构成契约说的「结构」。
|
||||
*/
|
||||
@Test
|
||||
fun tagsOutsideTheWhitelistAreShownLiterallyWithoutLosingACharacter() {
|
||||
val raw = "<script>alert(1)</script>"
|
||||
assertTrue(parseInlineMarkup(raw).isEmpty())
|
||||
assertEquals(raw, parse("<p>$raw</p>").single().body)
|
||||
|
||||
// 白名单外的标签不参与结构判定:旁边那对 `<b>` 照常生效,尖括号原样留着
|
||||
val mixed = parse("<p>甲<b>粗</b><script>x</script>乙</p>").single()
|
||||
assertEquals("甲粗<script>x</script>乙", mixed.body)
|
||||
assertEquals(
|
||||
listOf(run("甲"), run("粗", InlineStyle.Bold), run("<script>x</script>乙")),
|
||||
mixed.runs,
|
||||
)
|
||||
|
||||
// 落单的白名单外标签同理:它只是几个字符,不把整段拖成字面
|
||||
assertEquals(
|
||||
listOf(run("甲"), run("粗", InlineStyle.Bold), run("<script>乙")),
|
||||
parseInlineMarkup("甲<b>粗</b><script>乙"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* W71 按 `回执三十三 §一` 改写:**`s` 仍然不在白名单里**(「`s` 保持逐字原样不变」是原话),
|
||||
* 所以 `<s>` 的**每一种形态**都不产生样式、只按原文显示——这条与 [underlineBecomesAnUnderlineRun]
|
||||
* 是一对:`u` 加回来了,`s` 没有。
|
||||
*
|
||||
* 枚举里也**不该**有 `Strikethrough`:本项目不留「白名单外却有渲染分支」的死代码
|
||||
* (W69 撤 `u`/`s` 时连枚举、渲染分支、import 一起删净,这次只把 `Underline` 加回来)。
|
||||
*/
|
||||
@Test
|
||||
fun sEveryCaseAndShapeIsStillShownLiterally() {
|
||||
for (raw in listOf(
|
||||
"<s>改口</s>",
|
||||
"<S>改口</S>",
|
||||
"<s class=x>改口</s>",
|
||||
"<s>没闭合",
|
||||
"只有闭标签</s>",
|
||||
)) {
|
||||
assertTrue("「$raw」不在白名单里,不该产出任何样式", parseInlineMarkup(raw).isEmpty())
|
||||
assertEquals("「$raw」必须逐字原文", raw, parse("<p>$raw</p>").single().body)
|
||||
}
|
||||
// 与真样式混在一起时同理:白名单那六项起作用,`s` 的字面留在文本里
|
||||
val bubble = parse("<p><b>粗</b>与<s>删除线</s></p>").single()
|
||||
assertEquals("粗与<s>删除线</s>", bubble.body)
|
||||
assertEquals(
|
||||
listOf(run("粗", InlineStyle.Bold), run("与<s>删除线</s>")),
|
||||
bubble.runs,
|
||||
)
|
||||
// `u` 回来了、`s` 没有:枚举里只有前者,绝不留一条走不到的渲染分支
|
||||
assertTrue(InlineStyle.entries.contains(InlineStyle.Underline))
|
||||
assertTrue(InlineStyle.entries.map { it.name }.none { it == "Strikethrough" })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 边界 6:HTML 实体(不做)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun htmlEntitiesStayLiteralBecauseTheyAreNotDecoded() {
|
||||
for (text in listOf("a & b", "1 < 2", "空 格", "'引号'")) {
|
||||
assertTrue(parseInlineMarkup(text).isEmpty())
|
||||
assertEquals(text, parse("<p>$text</p>").single().body)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 契约第 3 条:绝不把一段非空正文渲染成空
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aBodyMadeOfTagsOnlyFallsBackToItsLiteralText() {
|
||||
for (raw in listOf("<b></b>", "<b> </b>", "<i></i><b></b>", "<code></code>")) {
|
||||
assertTrue("「$raw」没有可渲染的字,必须整段回落字面", parseInlineMarkup(raw).isEmpty())
|
||||
assertEquals(raw, parse("<p>$raw</p>").single().body)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 与既有的解析结果怎么接:body / runs / parts 各拿自己那一份
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun bodyIsTheRenderedTextAndRunsSpellItOut() {
|
||||
val bubble = parse("<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>").single()
|
||||
assertEquals("今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。", bubble.body)
|
||||
assertEquals(
|
||||
listOf(run("今天那儿是晴,13~25℃,日落 "), run("18:35", InlineStyle.Bold), run(",比上海晚了整整一小时呢。")),
|
||||
bubble.runs,
|
||||
)
|
||||
assertEquals(bubble.body, bubble.runs.concat())
|
||||
}
|
||||
|
||||
/** `parts` 保持**逐字原文**:编辑框、搜索片段、通知文案读的都是它,一个字都不许改。 */
|
||||
@Test
|
||||
fun partsKeepTheRawTextForEditingAndSearch() {
|
||||
val bubble = parse("<p>日落 <b>18:35</b></p>").single()
|
||||
assertEquals(listOf(ContentPart.Text("日落 <b>18:35</b>")), bubble.parts)
|
||||
assertEquals("日落 <b>18:35</b>", bubble.parts.joinToString("") { it.text })
|
||||
}
|
||||
|
||||
/**
|
||||
* `parse` 的其它调用点一律照旧——这条是「本次改动只碰渲染」的证据,也是它们的护栏:
|
||||
* 编辑框读到的是**原文**(行内标签一个都不掉)、搜索照样搜得到那句话、后台那段结论预览
|
||||
* 跟着拿到渲染文本(标签不再露出来)。谁哪天想让这些地方换一套文本,必须先删掉这里的断言。
|
||||
*/
|
||||
@Test
|
||||
fun theOtherConsumersOfTheBubbleStillSeeWhatTheySawBefore() {
|
||||
val plain = "日落 <b>18:35</b>,比上海晚了整整一小时呢。"
|
||||
// 编辑框:原文不含 `<p>`/`<action>` 时是**恒等**变换(一个字节都不动,也不谎称有标记被替换)
|
||||
val editable = editableMessageOf(plain)
|
||||
assertEquals(plain, editable.text)
|
||||
assertFalse(editable.hasMarkup)
|
||||
|
||||
// 编辑框:外层的 `<p>` 照旧被剥(既有语义),而行内标签**原样留着**——它是要被编辑的源码本身
|
||||
val wrapped = editableMessageOf("<p>$plain</p>")
|
||||
assertTrue(wrapped.hasMarkup)
|
||||
assertEquals(plain, wrapped.text)
|
||||
|
||||
// 搜索:按片段原文拼纯文本,那句话照旧搜得到(`<b>` 夹在中间不影响那几个字连在一起)
|
||||
val searchable = plainSearchText("<p>$plain</p>")
|
||||
assertTrue(searchable.contains("日落"))
|
||||
assertTrue(searchable.contains("18:35"))
|
||||
}
|
||||
|
||||
/** 分享预览走的是同一个 [parse](W65)⇒ 它拿到的正文与气泡逐字一致:标签同样不再露出来。 */
|
||||
@Test
|
||||
fun theSharePreviewSeesTheSameBodyAsTheBubble() {
|
||||
val content = "<p>日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>"
|
||||
assertEquals(parse(content).map { it.body }, sharePreviewBlocks(content).map { it.body })
|
||||
}
|
||||
|
||||
/** 旁白/动作行不做行内解析(理由见 `MessageBubble.AnnotationLine` 的 KDoc):原文进 head,一个字不动。 */
|
||||
@Test
|
||||
fun narrationTextIsLeftExactlyAsWritten() {
|
||||
val bubble = parse("<p><action>笑<b>了</b></action></p>").single()
|
||||
assertEquals(listOf(ContentPart.Action("笑<b>了</b>")), bubble.head)
|
||||
assertEquals("", bubble.body)
|
||||
assertTrue(bubble.runs.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inlineTagsDoNotChangeBubbleSplitting() {
|
||||
val bubbles = parse("<p>甲<b>乙</b></p><p>丙</p>")
|
||||
assertEquals(listOf("甲乙", "丙"), bubbles.map { it.body })
|
||||
assertEquals(listOf(ContentPart.Text("甲<b>乙</b>"), ContentPart.Text("丙")), bubbles.flatMap { it.parts })
|
||||
}
|
||||
|
||||
/** 未闭合的 `<b>` 在一次性解析里同样是字面(与 `<action>` 那条既有契约同一口径)。 */
|
||||
@Test
|
||||
fun unclosedInlineTagFallsBackToLiteralText() {
|
||||
val bubble = parse("<p>日落 <b>18:35</p>").single()
|
||||
assertEquals("日落 <b>18:35", bubble.body)
|
||||
assertTrue(bubble.runs.isEmpty())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 反证:星号语义一个字都没被动过
|
||||
//
|
||||
// 行内解析发生在**段落/分隔符解析之后**(`toBubble` 里对最终正文做一次),而星号那一套
|
||||
// 在 `appendAnnotations` 里就已经定稿并入 `parts` 了。所以「星号不产生斜体/粗体」这条
|
||||
// 语义在结构上不可能被行内解析改了——下面这几条既是反证,也是它与它相邻的样子。
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun starsKeepTheirExistingMeaningInsteadOfBecomingMarkdown() {
|
||||
// 半角 `*…*` 没有关键词 ⇒ 保持普通文本,**不**变成斜体(既有用例:halfWidthStarWithoutKeywordStaysMarkdownText)
|
||||
val emphasis = parse("<p>这是*强调*文字</p>").single()
|
||||
assertEquals("这是*强调*文字", emphasis.body)
|
||||
assertTrue(emphasis.runs.isEmpty())
|
||||
|
||||
// 半角 `**…**` 同理:**不**变成粗体(既有用例:halfWidthDoubleStarStaysMarkdownText)
|
||||
val double = parse("<p>这是**加粗**文字</p>").single()
|
||||
assertEquals("这是**加粗**文字", double.body)
|
||||
assertTrue(double.runs.isEmpty())
|
||||
|
||||
// 全角 `*…*` 仍是「动作」(既有用例:shortFullWidthStarWithoutKeywordBecomesAction)
|
||||
val starred = parse("<p>你好*弯起眼睛*</p>").single()
|
||||
assertEquals("你好", starred.body)
|
||||
assertEquals(listOf(ContentPart.Action("弯起眼睛")), starred.tail)
|
||||
|
||||
// 带关键词的半角星号仍是旁白(既有用例:halfWidthStarWithNarrationKeywordBecomesNarration)
|
||||
val narration = parse("<p>*内心:有点开心*你好</p>").single()
|
||||
assertEquals("你好", narration.body)
|
||||
assertEquals(listOf(ContentPart.Narration("有点开心")), narration.head)
|
||||
}
|
||||
|
||||
/** 同一段正文里两种机制各干各的:`*笑*` 照旧是旁白,`<b>` 照旧是粗体。 */
|
||||
@Test
|
||||
fun starsAndInlineTagsCoexistWithoutTouchingEachOther() {
|
||||
val bubble = parse("<p>日落 <b>18:35</b>*笑*</p>").single()
|
||||
assertEquals("日落 18:35", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubble.tail)
|
||||
assertEquals(listOf(run("日落 "), run("18:35", InlineStyle.Bold)), bubble.runs)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 流式:未闭合的行内标签同样扣留(否则屏幕上已经显示过的字会改口)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 逐字符喂一条带 `<b>` 的消息:中间态**一帧都不许**把标签当正文吐上屏,且已经显示过的正文
|
||||
* 只许「加」不许「改口」(与 `ChatContentParserTest` 那条同名的性质同源)。
|
||||
*
|
||||
* 这条消息刻意没有 `<p>`:有 `<p>` 的话整个段落本来就会被扣留,测不到行内标签这一条。
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsUnclosedInlineTags() {
|
||||
val text = "日落 <b>18:35</b>,比上海晚了整整一小时呢。记住了。"
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in text) {
|
||||
val bubbles = parser.onDelta(ch.toString())
|
||||
val bodies = bubbles.map { it.body }
|
||||
// 只看**渲染文本**(`body`):`parts` 是逐字原文,配成对的标签本来就该留在里面
|
||||
// (编辑框/搜索读的就是它),所以那一边不能用「有没有尖括号」来判。
|
||||
for (bubble in bubbles) {
|
||||
assertFalse("标签漏上了屏:${bubble.body}", bubble.body.contains("<b"))
|
||||
}
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
assertEquals(parse(text), parser.finish())
|
||||
assertEquals("日落 18:35,比上海晚了整整一小时呢。记住了。", parser.finish().single().body)
|
||||
}
|
||||
|
||||
/**
|
||||
* W68 按契约 v0.89 新增(这条是那一轮最要紧的护栏):**大小写混用与带属性**的标签,
|
||||
* 流式中间态同样一个字符都不许漏上屏。
|
||||
*
|
||||
* 消费侧与扣留侧的词表一旦分叉(W67 只有消费侧认小写、扣留侧只扫 `a..z`),`<B` / `<b class`
|
||||
* 这种残缺形态就会在中间态被当正文吐上屏、等 `>` 到了再被吃掉——屏幕上显示过的字当场改口。
|
||||
* 所以这条同时钉住三件事:中间态不吐字面标签、已显示的正文只增不改、收口逐字等于一次性解析。
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsMixedCaseAndAttributeTagsToo() {
|
||||
val text = "日落 <B class=\"x\">18:35</B> 记住了。"
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in text) {
|
||||
val bubbles = parser.onDelta(ch.toString())
|
||||
val bodies = bubbles.map { it.body }
|
||||
for (bubble in bubbles) {
|
||||
// 这条样本里渲染文本不该出现任何一个尖括号/属性字:全是标签字符或属性
|
||||
assertFalse("标签或属性漏上了屏:${bubble.body}", bubble.body.contains('<') || bubble.body.contains('>'))
|
||||
}
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
assertEquals(parse(text), parser.finish())
|
||||
assertEquals("日落 18:35 记住了。", parser.finish().single().body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐字符 / 任意切分的流式结果必须与一次性解析完全一致(含行内标签、`<br>` 分隔符、
|
||||
* 以及 W71 起新增的两处:`u` 与**保护区**内的 `<br>`)。
|
||||
*/
|
||||
@Test
|
||||
fun streamingAnyChunkingMatchesOneShot() {
|
||||
val sample = """
|
||||
<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>
|
||||
<p>记住 <CODE>abc</CODE>、<i>斜体</i> 与 <strong class="x">加粗</strong> 哦</p>
|
||||
<p>这是<S>删除线</S>与<U>下划线</U>,*弯起眼睛*</p>
|
||||
<p>1 < 2 且 <span class="y">白名单外的标签原样</span> 收尾</p>
|
||||
<p>日落 <B>18:35</B></p>
|
||||
<p>上一行<br>下一行</p>
|
||||
<p>甲<br/>乙</br>丙</p>
|
||||
<p><code>甲<br>乙</code>与<u>下划线</u></p>
|
||||
<p>她<action>她<br>点点头</action></p>
|
||||
""".trimIndent()
|
||||
val expected = parse(sample)
|
||||
|
||||
val charByChar = StreamingContentParser()
|
||||
for (ch in sample) charByChar.onDelta(ch.toString())
|
||||
assertEquals(expected, charByChar.finish())
|
||||
|
||||
val random = Random(20261007)
|
||||
repeat(25) {
|
||||
val parser = StreamingContentParser()
|
||||
var index = 0
|
||||
while (index < sample.length) {
|
||||
val end = minOf(index + 1 + random.nextInt(7), sample.length)
|
||||
parser.onDelta(sample.substring(index, end))
|
||||
index = end
|
||||
}
|
||||
assertEquals(expected, parser.finish())
|
||||
}
|
||||
}
|
||||
|
||||
/** 一整段只有标记时,收口也必须是字面(与一次性解析一致;未闭合的 `<p>` 同样照字面留着)。 */
|
||||
@Test
|
||||
fun streamingUnclosedInlineTagFallsBackOnFinish() {
|
||||
val parser = StreamingContentParser()
|
||||
parser.onDelta("<p>日落 <b>18:35")
|
||||
assertEquals(listOf("<p>日落 <b>18:35"), parser.finish().map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 回归:正文里一串尖括号不许把后面的真标签「遮住」,也不许把扫描拖成 O(N²)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun angleBracketsBeforeATagDoNotHideIt() {
|
||||
assertEquals(
|
||||
listOf(run("1 < 2 且 3 > 2,日落 "), run("18:35", InlineStyle.Bold)),
|
||||
parseInlineMarkup("1 < 2 且 3 > 2,日落 <b>18:35</b>"),
|
||||
)
|
||||
// 半路那个 `<` 与它后面那个 `>` 之间还夹着一个真标签:不能「从 `<` 一路找到下一个 `>`」
|
||||
// 就把整段跳过(那样 `<b>` 会被连同它一起跳过,粗体永远不生效)。
|
||||
assertEquals(
|
||||
listOf(run("1 < 2 "), run("甲", InlineStyle.Bold)),
|
||||
parseInlineMarkup("1 < 2 <b>甲</b>"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 与 `ChatContentParserTest` 那条复杂度回归同一性质:阈值只用来区分「线性」与「平方」,
|
||||
* 不是耗时指标。正文里成串的 `<` 若用 `indexOf('>', lt)` 逐个找配对(找不到就一路扫到串尾),
|
||||
* 这里就会退回 O(N²)——W63 刚在同一片区域修掉两处同形状的问题。
|
||||
*
|
||||
* W68 注意:因为契约要求「属性一律不解析」,标签长度不再有上界,`inlineTagEnd` 那扇定宽窗
|
||||
* 换成了「扫到 `>` 或下一个 `<` 为止」。这条回归因此更要紧——它钉住换完之后仍是线性的。
|
||||
*/
|
||||
@Test
|
||||
fun aHugeRunOfAngleBracketsStaysWithinALinearBudget() {
|
||||
val count = 20_000
|
||||
for (body in listOf("<".repeat(count), "a<b".repeat(count / 3), "<b class=x".repeat(count / 9))) {
|
||||
val startedAt = System.nanoTime()
|
||||
val bubble = parse("<p>$body</p>").single()
|
||||
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
|
||||
assertEquals(body, bubble.body)
|
||||
assertTrue(bubble.runs.isEmpty())
|
||||
assertTrue(
|
||||
"${body.length} 字符(一串普通尖括号)用了 ${elapsedMs}ms——复杂度退回 O(N²) 了?",
|
||||
elapsedMs < 2_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 工具
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private fun run(text: String, vararg styles: InlineStyle) = InlineRun(text, styles.toSet())
|
||||
|
||||
private fun List<InlineRun>.concat(): String = joinToString("") { it.text }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.yeij.cyrene.domain.share
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 批量投递的「声明」边界([ShareRules.declaresSendPreference])。
|
||||
*
|
||||
* 钉住的是一件**不许再走回去**的事:分享进来的 N 张图会落成 N 条消息(契约里 `chat.image` 是单图
|
||||
* 字段),而这 N 条是**一次**投递动作——它们之间绝不许互相打断 / 互相取消(`02` F4 的后端口径、
|
||||
* `05` 的多图自相打断一节)。一旦某一条在忙时声明了 `interrupt`,第 2 条就会把第 1 条刚起的回合打
|
||||
* 掉;声明 `parallel` 同样把「一次倒进去」变成「几条并行回合」。
|
||||
*
|
||||
* 纯函数 ⇒ 不需要组合界面、不需要设备,就能把**整批**(0 到 [ShareRules.MAX_IMAGES] - 1)逐条穷举。
|
||||
*/
|
||||
class ShareSendDeclarationTest {
|
||||
|
||||
@Test
|
||||
fun `share never declares on any message of the batch`() {
|
||||
(0 until ShareRules.MAX_IMAGES).forEach { index ->
|
||||
assertFalse(
|
||||
"分享的第 $index 条不许声明(声明就会打断同一批里的上一条)",
|
||||
ShareRules.declaresSendPreference(fromShare = true, index = index),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `share stays silent even past the image limit`() {
|
||||
// 超限那张本来就不发(见 truncatedImagesNotice),但判据对它也不许翻成 true:
|
||||
// 一旦有人把上限放宽而忘了这条,越界位置不该成为唯一声明的那一条。
|
||||
(ShareRules.MAX_IMAGES..ShareRules.MAX_IMAGES + 3).forEach { index ->
|
||||
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = index))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the typed send declares on its first message only`() {
|
||||
assertTrue(
|
||||
"手打的第一条仍按「她忙时发消息」偏好投递",
|
||||
ShareRules.declaresSendPreference(fromShare = false, index = 0),
|
||||
)
|
||||
(1 until ShareRules.MAX_IMAGES).forEach { index ->
|
||||
assertFalse(
|
||||
"手打的第 $index 条声明就是在打断自己刚起的上一轮",
|
||||
ShareRules.declaresSendPreference(fromShare = false, index = index),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single message batch is the same rule`() {
|
||||
// 只有一条时两种入口的差别正是全部差别:手打声明、分享不声明。
|
||||
assertTrue(ShareRules.declaresSendPreference(fromShare = false, index = 0))
|
||||
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package top.yeij.cyrene.domain.share
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 正文的**回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`([ShareRules.extract])。
|
||||
*
|
||||
* ## 这条链修的是哪种静默
|
||||
* 有些发送方**不填** `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。本端此前只读
|
||||
* `EXTRA_TEXT` ⇒ 那样一次分享**整条静默**:用户点了分享,界面毫无反应、后端零请求、也没有一句解释
|
||||
* (`SharePayload.kt` 里那条 `contentUnreadable` 判据也不含这两个 extra)。修法就是这条链。
|
||||
*
|
||||
* ## 本文件钉住的三件事
|
||||
* 1. **回落链本身**:只有 `SUBJECT` / 只有 `TITLE` ⇒ 用它当正文,且来源如实记进
|
||||
* `SharedContent.textSource`(渲染层靠它说一句「来自分享标题」);
|
||||
* 2. **既有情形逐字不变**:`EXTRA_TEXT` 非空时它**一定**赢(它是第一站),结果与今天的实现完全相同
|
||||
* ——回落链不许「取到更多东西」,它补的是原先那条缝;
|
||||
* 3. **都不成正文时不许闭嘴**:三个文本 extra 里躺着值(含空白、含读不出类型)而没换来正文 ⇒
|
||||
* `contentUnreadable`,走既有的「读不进来」告知;而**三个都没有值**(用户确实什么都没分享)照旧
|
||||
* 静默。两者的分野就是 `textPresent` 这个由调用方(读了原始值的那一层)报进来的事实。
|
||||
*
|
||||
* 真实 `Intent` 的读取在 `app/ShareIntake.kt`(`rawExtra` + [top.yeij.cyrene.app.shareTextExtrasOf],
|
||||
* 那个「值在不在」的判断与 `runCatching` 的守卫各有一份用例,见 `ShareTextExtrasTest`)。
|
||||
*/
|
||||
class ShareTextFallbackTest {
|
||||
|
||||
/** 一次最普通的 `ACTION_SEND`,只把三个文本 extra 摆出来(其余字段都空)。 */
|
||||
private fun extract(
|
||||
text: CharSequence? = null,
|
||||
subject: CharSequence? = null,
|
||||
title: CharSequence? = null,
|
||||
textPresent: Boolean = false,
|
||||
): SharedContent = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
text = text,
|
||||
subject = subject,
|
||||
title = title,
|
||||
textPresent = textPresent,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a subject only share becomes the body and says where it came from`() {
|
||||
// 这次分享的现场:`EXTRA_TEXT` 一个值都没有,内容全在 `EXTRA_SUBJECT` 里。
|
||||
val share = extract(subject = "分享自某某App:一篇文章的标题")
|
||||
|
||||
assertEquals("分享自某某App:一篇文章的标题", share.text)
|
||||
assertEquals(ShareTextSource.Subject, share.textSource)
|
||||
// 拿到的是一段真内容 ⇒ 既不是「空分享」、也不是「读不进来」,与一次普通的文本分享没有区别。
|
||||
assertFalse(share.isEmpty)
|
||||
assertFalse(share.contentUnreadable)
|
||||
assertTrue(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title only share becomes the body`() {
|
||||
val share = extract(title = "只有标题的一次分享")
|
||||
|
||||
assertEquals("只有标题的一次分享", share.text)
|
||||
// 来源分档只为「如实登记」,用户那边 Subject 与 Title 说的是同一句话(见 `ShareTextOrigin.kt`)。
|
||||
assertEquals(ShareTextSource.Title, share.textSource)
|
||||
assertTrue(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non blank text always wins the chain`() {
|
||||
// 既有情形逐字不变:三个都有值时,正文与来源都是今天那一份(回落链对它**完全**不生效)。
|
||||
val share = extract(text = "对方写的正文", subject = "对方给的标题", title = "对方给的另一个标题")
|
||||
|
||||
assertEquals("对方写的正文", share.text)
|
||||
assertEquals(ShareTextSource.Text, share.textSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank text falls back to the subject`() {
|
||||
// 空白串(不是 null)同样是「没有正文」:口径与取 text 时那一次 trim 完判空完全一致。
|
||||
val share = extract(text = " \n\t ", subject = "标题顶上")
|
||||
|
||||
assertEquals("标题顶上", share.text)
|
||||
assertEquals(ShareTextSource.Subject, share.textSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank text and a blank subject fall back to the title`() {
|
||||
val share = extract(text = " ", subject = "\n", title = "第三站顶上")
|
||||
|
||||
assertEquals("第三站顶上", share.text)
|
||||
assertEquals(ShareTextSource.Title, share.textSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the body is trimmed the same way whichever station it came from`() {
|
||||
// 三站的归一化口径必须一模一样(`toString` → 两侧 trim):不然同一段字从 SUBJECT 来就会多带
|
||||
// 一个换行,而正文那一段是要**逐字**发出去的。
|
||||
listOf(
|
||||
ShareTextSource.Text to extract(text = " 正文 "),
|
||||
ShareTextSource.Subject to extract(subject = " 正文 "),
|
||||
ShareTextSource.Title to extract(title = " 正文 "),
|
||||
).forEach { (source, share) ->
|
||||
assertEquals(source.name, "正文", share.text)
|
||||
assertEquals(source.name, source, share.textSource)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fallen back body is a normal share in every other respect`() {
|
||||
// 回落来的正文走的是同一条既有的路:`isEmpty` / `worthDelivering` / 附件上限这些判据一个字都
|
||||
// 没为它改过 —— 除了「来源」那一格。
|
||||
val text = extract(text = "正文")
|
||||
val subject = extract(subject = "正文")
|
||||
|
||||
assertTrue(subject.text == text.text)
|
||||
assertEquals(text.isEmpty, subject.isEmpty)
|
||||
assertEquals(text.contentUnreadable, subject.contentUnreadable)
|
||||
assertEquals(text.images, subject.images)
|
||||
assertEquals(text.documents, subject.documents)
|
||||
assertEquals(ShareTextSource.Text, text.textSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when none of the three yields a body we speak instead of staying silent`() {
|
||||
// 三者都空(甚至读不出类型)**不是**「什么都没分享」:用户点了分享,本端一个字节都没读出来。
|
||||
// 那件事必须能说出口(`textPresent` 由读了原始值的调用方如实报进来),否则又是一次静默。
|
||||
val share = extract(text = null, subject = " ", title = null, textPresent = true)
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertTrue(share.contentUnreadable)
|
||||
assertTrue(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share whose three extras hold nothing at all still stays silent`() {
|
||||
// 边界(既有口径,不许放宽):三个 extra **连值都没有** ⇒ 用户确实什么都没分享,说一句才是噪声。
|
||||
// 分野就在 `textPresent` 这一个事实上,不在「正文是不是空的」上。
|
||||
val share = extract()
|
||||
|
||||
assertTrue(share.isEmpty)
|
||||
assertFalse(share.contentUnreadable)
|
||||
assertFalse(share.worthDelivering)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fallen back body next to unusable streams is not a whole read failure`() {
|
||||
// 「读不进来」那条判据只在**一条可用内容都没有**时成立:从标题回落的正文照样是一次拿到了东西
|
||||
// 的分享,不能因为对方给的那几个地址用不了就说成「这次分享的内容没能读进来」。
|
||||
val share = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
subject = "从标题来的正文",
|
||||
streamStrings = listOf("file:///etc/hosts"),
|
||||
textPresent = true,
|
||||
)
|
||||
|
||||
assertEquals("从标题来的正文", share.text)
|
||||
assertFalse(share.contentUnreadable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrelated intent with a subject in it still extracts to nothing`() {
|
||||
// 不是分享 action 就不是分享:哪怕三个 extra 里照样躺着值,也不该走这条路(更不该出一条告知)。
|
||||
val share = ShareRules.extract(
|
||||
action = "android.intent.action.VIEW",
|
||||
subject = "标题",
|
||||
textPresent = true,
|
||||
)
|
||||
|
||||
assertEquals(SharedContent(), share)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
|
||||
/**
|
||||
* 「这条消息什么都没传过来」的判据(纯函数,`user` 消息的空气泡修复)。
|
||||
@@ -15,8 +16,9 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
*
|
||||
* 这个测试要钉住两件事:
|
||||
* 1. 真的什么都没有时成立(空 / 纯空白);
|
||||
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链,
|
||||
* 以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上「内容没传过来」的帽子。
|
||||
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链、
|
||||
* 有 `voice` 键(语音形态),以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上
|
||||
* 「内容没传过来」的帽子。
|
||||
*/
|
||||
class ContentlessMessageTest {
|
||||
|
||||
@@ -26,6 +28,7 @@ class ContentlessMessageTest {
|
||||
image: String? = null,
|
||||
documents: List<DocumentRefDto> = emptyList(),
|
||||
thinking: String? = null,
|
||||
voice: VoiceDto? = null,
|
||||
) = HistoryMessageDto(
|
||||
id = "m1",
|
||||
sessionId = "web",
|
||||
@@ -34,6 +37,7 @@ class ContentlessMessageTest {
|
||||
image = image,
|
||||
documents = documents,
|
||||
thinking = thinking,
|
||||
voice = voice,
|
||||
createdAt = 1_791_088_571_785L,
|
||||
)
|
||||
|
||||
@@ -65,6 +69,17 @@ class ContentlessMessageTest {
|
||||
assertTrue(isContentlessUserMessage(message(image = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice message is never contentless`() {
|
||||
// 语音的转写文本可能为空(`status = empty`,或微信那条根本没有副本),但那条消息
|
||||
// **不是什么都没有**:它有语音形态。判据只看 `voice` 键在不在(契约 §4.4)。
|
||||
assertFalse(
|
||||
isContentlessUserMessage(
|
||||
message(content = "", voice = VoiceDto(source = "platform", status = "empty")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 不成立:凡有一点点内容就一个字都不说 ——
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
|
||||
/**
|
||||
* 「这条消息外侧那枚时间挂在哪一块上」的判据(纯函数 [messageTimeCarrier])。
|
||||
*
|
||||
* **背景**:主人 2026-10-07 验收 W69 时报的缺口 —— 纯文字消息都有时间,**纯附件消息边上没有**。
|
||||
* 根因在 W69 的落脚点写法上:时间挂在「这条消息**最后一个有正文的气泡**」外侧,而纯附件消息
|
||||
* (`documents` 非空 / `image` 非空、`content` 为空)解析出来的唯一那个气泡 `body` 也是空,
|
||||
* [Bubble] 见空 `body` 什么都不画 ⇒ 那一行**根本没被画出来**。
|
||||
*
|
||||
* 这个测试要钉住三件事:
|
||||
* 1. **一个正文气泡都没有时,时间落到最后画出来的那个附件块上**(附件芯片排 / 图片 / 语音形态),
|
||||
* 而且顺序与渲染顺序一致(谁最后画谁拿)——这就是「纯附件消息也有时间」;
|
||||
* 2. **有正文气泡时一个字都不许变**(W69 的口径:最后一个有正文的气泡,末段的纯旁白气泡不算);
|
||||
* 3. **真的空气泡**([isContentlessUserMessage])在这里返回 `null`,但那种消息**仍然有时间**
|
||||
* —— 那一处由 [MessageBubble] 给 [ContentlessNote] 单独包一层 [BubbleWithTime],是另一条路。
|
||||
*
|
||||
* 「贴左还是贴右」不在这里测:那是 [BubbleWithTime] 按 `isUser` 定的,本函数只回答「挂在哪一块」。
|
||||
*/
|
||||
class MessageTimeCarrierTest {
|
||||
|
||||
private fun message(
|
||||
role: String = "user",
|
||||
content: String = "",
|
||||
image: String? = null,
|
||||
documents: List<DocumentRefDto> = emptyList(),
|
||||
thinking: String? = null,
|
||||
voice: VoiceDto? = null,
|
||||
createdAt: Long = 1_791_088_571_785L,
|
||||
) = HistoryMessageDto(
|
||||
id = "m1",
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = content,
|
||||
image = image,
|
||||
documents = documents,
|
||||
thinking = thinking,
|
||||
voice = voice,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private val document = DocumentRefDto(
|
||||
name = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt",
|
||||
mime = "text/plain",
|
||||
size = 10,
|
||||
chars = 10,
|
||||
status = "extracted",
|
||||
)
|
||||
|
||||
/** 解析出来的一个正文气泡(`plainBubble` 那份形状)。 */
|
||||
private fun body(text: String) = ParsedBubble(
|
||||
body = text,
|
||||
parts = emptyList(),
|
||||
head = emptyList(),
|
||||
tail = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 纯附件消息的 `bubbles`:`content` 为空 ⇒ 解析器一个都没给 ⇒ `MessageBubble` 兜一个占位气泡
|
||||
* (`plainBubble("")`)。它 `body` 为空,所以 [Bubble] 一个像素都不画 —— 缺口就在这一步。
|
||||
*/
|
||||
private val placeholderBubbles = listOf(body(""))
|
||||
|
||||
private val voiceWithoutTranscript = VoiceDto(source = "asr", status = "empty")
|
||||
|
||||
// —— 一个正文气泡都没有:落到最后画出来的那个附件块上 ——
|
||||
|
||||
@Test
|
||||
fun `an attachments only message hangs the time on the chip row`() {
|
||||
val message = message(content = "", documents = listOf(document))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.Attachments,
|
||||
messageTimeCarrier(message, placeholderBubbles),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image only message hangs the time on the image`() {
|
||||
val message = message(content = "", image = "http://host/api/v1/files/a.png")
|
||||
|
||||
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(message, placeholderBubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice message with nothing transcribed hangs the time on the voice row`() {
|
||||
// `status = empty` ⇒ 正文为空,而这条消息**不是什么都没有**(`isContentlessUserMessage`
|
||||
// 特意把语音排除掉)。它的可视块只有语音形态,时间就该落在那里。
|
||||
val message = message(content = "", voice = voiceWithoutTranscript)
|
||||
|
||||
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(message, placeholderBubbles))
|
||||
assertFalse(isContentlessUserMessage(message))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when several blocks are there the last one drawn wins`() {
|
||||
// 渲染顺序是「附件芯片排 → 图片 → 语音形态」,所以时间的落点也按这个顺序往回退。
|
||||
val chipsAndImage = message(
|
||||
content = "",
|
||||
documents = listOf(document),
|
||||
image = "http://host/api/v1/files/a.png",
|
||||
)
|
||||
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(chipsAndImage, placeholderBubbles))
|
||||
|
||||
val allThree = message(
|
||||
content = "",
|
||||
documents = listOf(document),
|
||||
image = "http://host/api/v1/files/a.png",
|
||||
voice = voiceWithoutTranscript,
|
||||
)
|
||||
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(allThree, placeholderBubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `her attachments get the time too`() {
|
||||
// 判据与角色无关:她也会发只有图 / 只有附件的一条。
|
||||
val message = message(role = "assistant", content = "", documents = listOf(document))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.Attachments,
|
||||
messageTimeCarrier(message, placeholderBubbles),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 有正文气泡时:W69 的口径一个字都不变 ——
|
||||
|
||||
@Test
|
||||
fun `a text bubble always wins over the blocks above it`() {
|
||||
val message = message(
|
||||
content = "甲",
|
||||
documents = listOf(document),
|
||||
image = "http://host/api/v1/files/a.png",
|
||||
)
|
||||
|
||||
assertEquals(MessageTimeCarrier.TextBubble(0), messageTimeCarrier(message, listOf(body("甲"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last non empty bubble carries the time`() {
|
||||
val bubbles = listOf(body("甲"), body("乙"))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.TextBubble(1),
|
||||
messageTimeCarrier(message(content = "甲<p>乙</p>"), bubbles),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing annotation only bubble never carries the time`() {
|
||||
// 末段只有旁白(`<action>`/`<narration>`)时不画正文框:时间退回上一个有正文的气泡。
|
||||
val bubbles = listOf(body("甲"), body(""))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.TextBubble(0),
|
||||
messageTimeCarrier(message(content = "甲<action>她抬头</action>"), bubbles),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 真的什么都没有:这里返回 null,时间由 ContentlessNote 那条路自己画 ——
|
||||
|
||||
@Test
|
||||
fun `a truly empty user message has no block to hang the time on`() {
|
||||
val message = message(content = " ")
|
||||
|
||||
// 判据与 `isContentlessUserMessage` 是互补的:那条路**仍然**有时间(`BubbleWithTime`
|
||||
// 包着 `ContentlessNote`),所以这里返回 null 不等于「这条消息没有时间」。
|
||||
assertTrue(isContentlessUserMessage(message))
|
||||
assertNull(messageTimeCarrier(message, placeholderBubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank image strings and empty documents are not blocks`() {
|
||||
val message = message(content = "", image = " ", documents = emptyList())
|
||||
|
||||
assertNull(messageTimeCarrier(message, placeholderBubbles))
|
||||
}
|
||||
|
||||
// —— 与时间戳缺失的关系:两件事分开判 ——
|
||||
|
||||
@Test
|
||||
fun `the carrier ignores the timestamp and the label ignores the carrier`() {
|
||||
// 「`createdAt <= 0` ⇒ 整条不画」是 [messageTimeLabel] 那一档的事:落点照旧算出来,
|
||||
// 只是文案为 null ⇒ `BubbleWithTime` 连 `Box` 都不建。两处刻意分开判,谁都不替谁兜底。
|
||||
val message = message(content = "", documents = listOf(document), createdAt = 0L)
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.Attachments,
|
||||
messageTimeCarrier(message, placeholderBubbles),
|
||||
)
|
||||
assertNull(messageTimeLabel(createdAt = 0L, nowMillis = 1_791_088_571_785L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* 气泡外侧那枚时间([messageTimeLabel])的回归钉子。
|
||||
*
|
||||
* 四档文案错了不会有异常、也不会有编译错误——只是屏幕上写着一个错的日子,而且最容易错的恰恰是
|
||||
* 边界(`23:59` 与次日 `00:01`、12 月 31 日与 1 月 1 日、闰年 2 月 29 日)。所以这里把每一档的
|
||||
* 两侧都钉住:判据按**本地日历日**分,不是「相差不到 24 小时」。
|
||||
*
|
||||
* 时区与「现在」都由测试喂(`zone` 注入、`nowMillis` 传参),所以这些断言与跑测试的机器无关。
|
||||
*/
|
||||
class MessageTimeLabelTest {
|
||||
|
||||
private val shanghai = ZoneId.of("Asia/Shanghai")
|
||||
private val utc = ZoneId.of("UTC")
|
||||
|
||||
/** 本地 `yyyy-MM-dd HH:mm` → 毫秒:测试里只写看得懂的时刻,不摆魔数。 */
|
||||
private fun at(
|
||||
year: Int,
|
||||
month: Int,
|
||||
day: Int,
|
||||
hour: Int,
|
||||
minute: Int,
|
||||
zone: ZoneId = shanghai,
|
||||
): Long = ZonedDateTime.of(year, month, day, hour, minute, 0, 0, zone).toInstant().toEpochMilli()
|
||||
|
||||
// ---------------------------------------------------------------- 今天:只给时分
|
||||
|
||||
@Test
|
||||
fun `same day renders hour and minute`() {
|
||||
val now = at(2025, 8, 14, 20, 0)
|
||||
|
||||
assertEquals("14:30", messageTimeLabel(at(2025, 8, 14, 14, 30), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly midnight is still today`() {
|
||||
val now = at(2025, 8, 14, 9, 0)
|
||||
|
||||
assertEquals("00:00", messageTimeLabel(at(2025, 8, 14, 0, 0), now, shanghai))
|
||||
assertEquals("23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), at(2025, 8, 14, 23, 59), shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one digit hours and minutes stay zero padded`() {
|
||||
val now = at(2025, 8, 14, 12, 0)
|
||||
|
||||
assertEquals("09:05", messageTimeLabel(at(2025, 8, 14, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 昨天:跨天不由时长决定
|
||||
|
||||
@Test
|
||||
fun `two minutes across midnight flips to yesterday`() {
|
||||
val now = at(2025, 8, 15, 0, 1)
|
||||
|
||||
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), now, shanghai))
|
||||
assertEquals("00:01", messageTimeLabel(at(2025, 8, 15, 0, 1), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday keeps the time`() {
|
||||
val now = at(2025, 8, 14, 10, 0)
|
||||
|
||||
assertEquals("昨天 09:05", messageTimeLabel(at(2025, 8, 13, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday across a month boundary`() {
|
||||
val now = at(2025, 3, 1, 10, 0)
|
||||
|
||||
// 2025-02-28 → 2025-03-01 是**昨天**(不是「2月28日」那档),月份少一天 / 多一天都不影响。
|
||||
assertEquals("昨天 23:30", messageTimeLabel(at(2025, 2, 28, 23, 30), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday across a year boundary`() {
|
||||
val now = at(2026, 1, 1, 0, 1)
|
||||
|
||||
// 12-31 与 01-01 只差两分钟,「昨天」这一档**优先于**跨年那一档。
|
||||
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 12, 31, 23, 59), now, shanghai))
|
||||
assertEquals("昨天 09:00", messageTimeLabel(at(2025, 12, 31, 9, 0), at(2026, 1, 1, 0, 30), shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 今年内更早:月日 + 时分
|
||||
|
||||
@Test
|
||||
fun `earlier this year shows month day and time`() {
|
||||
val now = at(2025, 8, 14, 10, 0)
|
||||
|
||||
assertEquals("3月7日 09:05", messageTimeLabel(at(2025, 3, 7, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `month and day are not zero padded`() {
|
||||
val now = at(2025, 12, 31, 23, 59)
|
||||
|
||||
assertEquals("1月1日 00:00", messageTimeLabel(at(2025, 1, 1, 0, 0), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a week ago stays in the month day branch`() {
|
||||
val now = at(2025, 8, 14, 10, 0)
|
||||
|
||||
// 没有「星期几」这一档:七天前与半年后走的是同一档。
|
||||
assertEquals("8月7日 09:05", messageTimeLabel(at(2025, 8, 7, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 跨年:只给年月日
|
||||
|
||||
@Test
|
||||
fun `crossing the year boundary drops the clock`() {
|
||||
val now = at(2026, 1, 1, 0, 1)
|
||||
|
||||
assertEquals("2025年12月30日", messageTimeLabel(at(2025, 12, 30, 23, 59), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the year decides the branch not the elapsed time`() {
|
||||
val now = at(2026, 8, 14, 10, 0)
|
||||
|
||||
// 整整一年前、同一个「8月14日」:仍然是跨年那一档(不带时分)。
|
||||
assertEquals("2025年8月14日", messageTimeLabel(at(2025, 8, 14, 10, 0), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 闰年
|
||||
|
||||
@Test
|
||||
fun `leap day is yesterday in a leap year and a full date afterwards`() {
|
||||
assertEquals(
|
||||
"昨天 23:59",
|
||||
messageTimeLabel(at(2024, 2, 29, 23, 59), at(2024, 3, 1, 0, 0), shanghai),
|
||||
)
|
||||
assertEquals(
|
||||
"2024年2月29日",
|
||||
messageTimeLabel(at(2024, 2, 29, 12, 0), at(2025, 3, 1, 12, 0), shanghai),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 未来时间(时钟漂移)
|
||||
|
||||
@Test
|
||||
fun `future timestamps fall back to the today branch`() {
|
||||
val now = at(2025, 8, 14, 14, 30)
|
||||
|
||||
// 同一刻前几分钟:服务端时钟略快 / 本机略慢,就是个普通时刻。
|
||||
assertEquals("14:35", messageTimeLabel(at(2025, 8, 14, 14, 35), now, shanghai))
|
||||
// 整整一天 / 一年之后也一样:不造「明天」这个词,也不摆一个假日期(见 KDoc)。
|
||||
assertEquals("09:00", messageTimeLabel(at(2025, 8, 15, 9, 0), now, shanghai))
|
||||
assertEquals("00:00", messageTimeLabel(at(2026, 1, 1, 0, 0), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 缺失 / 非法
|
||||
|
||||
@Test
|
||||
fun `missing or bogus timestamps render nothing`() {
|
||||
val now = at(2025, 8, 14, 14, 30)
|
||||
|
||||
// 0 = 这个键根本没传过来;负数只可能来自解析事故。都不是真实时刻 ⇒ 一个字都不画。
|
||||
assertNull(messageTimeLabel(0L, now, shanghai))
|
||||
assertNull(messageTimeLabel(-1L, now, shanghai))
|
||||
assertNull(messageTimeLabel(Long.MIN_VALUE, now, shanghai))
|
||||
assertNull(messageTimeLabel(-1L, now, utc))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 时区
|
||||
|
||||
@Test
|
||||
fun `the day boundary follows the injected zone`() {
|
||||
// 同一毫秒:上海是 8 月 15 日 07:30(今天),UTC 还是 8 月 14 日 23:30(昨天)。
|
||||
val millis = at(2025, 8, 14, 23, 30, utc)
|
||||
val now = at(2025, 8, 15, 1, 0, utc)
|
||||
|
||||
assertEquals("07:30", messageTimeLabel(millis, now, shanghai))
|
||||
assertEquals("昨天 23:30", messageTimeLabel(millis, now, utc))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.TurnBinding
|
||||
import top.yeij.cyrene.domain.chat.TurnBucket
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
import top.yeij.cyrene.domain.chat.allClosed
|
||||
import top.yeij.cyrene.domain.chat.withTurnBegin
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
|
||||
/**
|
||||
* **连接重建时把「进行中」全部收口**([isReconnect] + [settledAfterReconnect])。
|
||||
*
|
||||
* 修的是主人 2026-10-06 报的那个现场:后端卡在回合里被重启 ⇒ 那一轮在服务端已经不存在 ⇒
|
||||
* 本端**永远**等不到 `turn.end` / `stream_end` / 助手落库行 ⇒ 气泡永久灰着、思维链永久「思考中…」、
|
||||
* 工具卡永久「执行 X…」、底栏永久「正在思考」。
|
||||
*
|
||||
* 四组测试各钉一件事:
|
||||
* 1. **收口真的发生**:连接重建 ⇒ 未收口的绑定全 `closed`(六态 ④ → ⑤)、流式卡片全部停止生长;
|
||||
* 2. **第一次连接不算重建**:`Idle/Connecting → Connected` 一个字节都不收(判据的穷举表在下面);
|
||||
* 3. **幂等且单调**:已经收口的不会被翻回,收两次等于一次;
|
||||
* 4. **可降级**:消息 / 待发 / 草稿 / 队列 / 错误**一律原样**——收口只改「进行中的外观」。
|
||||
*/
|
||||
class ReconnectResetTest {
|
||||
|
||||
private fun bucket(turnId: String) = TurnBucket("web", turnId)
|
||||
|
||||
private fun bubble(text: String) =
|
||||
ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList())
|
||||
|
||||
private fun message(id: String, role: String = "user", turnId: String? = null) =
|
||||
HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = id,
|
||||
createdAt = 1L,
|
||||
turnId = turnId,
|
||||
)
|
||||
|
||||
/** 两路都还开着 + 一切「进行中」的字面承诺都挂着 —— 后端重启那一刻的本端样子。 */
|
||||
private fun stuckState() = ChatUiState(
|
||||
messages = listOf(message("m-a")),
|
||||
pending = listOf(PendingMessage("local-1", "还没发出去")),
|
||||
streams = listOf(StreamingLeg(bucket("t1"), streaming("半句回复", thinking = "她在想…"))),
|
||||
turns = emptyList<TurnBinding>()
|
||||
.withTurnBegin("t1", 1, listOf("m-a"), 2)
|
||||
.withTurnBegin("t2", 2, listOf("m-b"), 2, kind = "parallel"),
|
||||
queuedKeys = setOf("local-1"),
|
||||
queueDepth = 3,
|
||||
draft = "打到一半的字",
|
||||
busyMode = "thinking",
|
||||
activeTurns = 2,
|
||||
error = "上一次失败的原因",
|
||||
)
|
||||
|
||||
private fun streaming(text: String, thinking: String = "") = StreamingState(
|
||||
bubbles = text.takeIf { it.isNotBlank() }?.let { listOf(bubble(it)) }.orEmpty(),
|
||||
thinking = thinking,
|
||||
thinkingActive = thinking.isNotBlank(),
|
||||
active = true,
|
||||
)
|
||||
|
||||
// —— 1. 收口真的发生 ——
|
||||
|
||||
@Test
|
||||
fun `a rebuilt connection closes every binding that was still open`() {
|
||||
// 两路都还在飞(并行的两路也一样)⇒ 一次收口把**所有**未收口的绑定标 closed。
|
||||
// 六态的判据只有这一个闸(`turnPhaseOf` 第 2/3 条),所以这就是「气泡不再永远灰着」。
|
||||
val stuck = stuckState()
|
||||
assertEquals(TurnPhase.Answering, stuck.phaseOf(stuck.messages.single()))
|
||||
|
||||
val settled = stuck.settledAfterReconnect()
|
||||
|
||||
assertTrue(settled.turns.isNotEmpty())
|
||||
assertTrue("每一路都该收口", settled.turns.all { it.closed })
|
||||
assertEquals(TurnPhase.Answered, settled.phaseOf(settled.messages.single()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rebuilt connection stops every leg from growing`() {
|
||||
// 「思考中…」/「执行 X…」的唯一闸就是这几个位;没有卡住的那一路不在这里(它还在正常跑)。
|
||||
val stuck = stuckState().copy(
|
||||
streams = listOf(
|
||||
StreamingLeg(bucket("t1"), streaming("半句回复", thinking = "她在想…")),
|
||||
StreamingLeg(
|
||||
bucket("t2"),
|
||||
StreamingState(tools = listOf(ToolCallState("call-1", 0, tool = "search"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val settled = stuck.settledAfterReconnect()
|
||||
|
||||
assertEquals(2, settled.streams.size)
|
||||
settled.streams.forEach { leg ->
|
||||
assertFalse("收口后不再生长", leg.state.active)
|
||||
assertFalse("标题不该再是「思考中…」", leg.state.thinkingActive)
|
||||
}
|
||||
assertEquals(listOf("半句回复"), settled.streams.first().state.bubbles.map { it.body })
|
||||
assertEquals("她在想…", settled.streams.first().state.thinking)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rebuilt connection takes the busy banner down with it`() {
|
||||
// 底栏那句「正在思考…」是**服务端报的事实**(session_busy)——旧连接没了它就不可知了。
|
||||
// 与 `session_idle` / `session_interrupted` 那两处的处置逐字一致。
|
||||
val settled = stuckState().settledAfterReconnect()
|
||||
|
||||
assertNull(settled.busyMode)
|
||||
assertEquals(0, settled.activeTurns)
|
||||
assertEquals(0, settled.queueDepth)
|
||||
}
|
||||
|
||||
// —— 2. 「是不是重建」的判据(穷举) ——
|
||||
|
||||
@Test
|
||||
fun `the first connection is never a rebuild`() {
|
||||
// 冷启动:Idle → Connecting → Connected。把这一跳当重建会在用户刚打开会话的瞬间
|
||||
// 把正当在跑的路收口掉(首屏一到就可能有 turn_begin 把它绑成灰态)。
|
||||
assertFalse(isReconnect(WsState.Idle, WsState.Connected, everConnected = false))
|
||||
assertFalse(isReconnect(WsState.Connecting, WsState.Connected, everConnected = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coming back to connected after a rebuild is a rebuild`() {
|
||||
// 后端重启:Connected → Reconnecting → Connected。这是本判据的主场。
|
||||
assertTrue(isReconnect(WsState.Reconnecting, WsState.Connected, everConnected = true))
|
||||
// 合流吞掉中间那一跳:收集器只看到 Connected → Connected,它**仍然是**一次真实的重建。
|
||||
assertTrue(isReconnect(WsState.Connected, WsState.Connected, everConnected = true))
|
||||
// 没记过账时的第二证据:上一跳是重连退避(只在本 VM 是离线期间建起来时才走到)。
|
||||
assertTrue(isReconnect(WsState.Reconnecting, WsState.Connected, everConnected = false))
|
||||
// 离线 → 重新 connect(`close()` 之后 attempt 归零走 Connecting)也是一次重建,
|
||||
// 只要本 VM 见过 Connected。
|
||||
assertTrue(isReconnect(WsState.Offline, WsState.Connected, everConnected = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anything that is not connected is not a rebuild`() {
|
||||
// 掉下去的那一刻不收口(那一刻正是「进行中」最要紧的时候:还在等它自己跑完)。
|
||||
for (previous in WsState.entries) {
|
||||
for (ever in listOf(false, true)) {
|
||||
for (current in WsState.entries.filter { it != WsState.Connected }) {
|
||||
assertFalse(
|
||||
"previous=$previous current=$current ever=$ever",
|
||||
isReconnect(previous, current, ever),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the judgement is exhaustive over every state triple`() {
|
||||
// 5 × 5 × 2 = 50 格全表。期望值**独立**写一遍(当前不是 Connected ⇒ 假;见过 Connected ⇒
|
||||
// 真;没记过账但上一跳是重连退避 ⇒ 真;其余(第一次接上)⇒ 假)。
|
||||
for (previous in WsState.entries) {
|
||||
for (current in WsState.entries) {
|
||||
for (ever in listOf(false, true)) {
|
||||
val expected = when {
|
||||
current != WsState.Connected -> false
|
||||
ever -> true
|
||||
previous == WsState.Reconnecting -> true
|
||||
else -> false
|
||||
}
|
||||
assertEquals(
|
||||
"previous=$previous current=$current everConnected=$ever",
|
||||
expected,
|
||||
isReconnect(previous, current, ever),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// —— 3. 幂等 / 单调 ——
|
||||
|
||||
@Test
|
||||
fun `settling twice is the same as settling once`() {
|
||||
val once = stuckState().settledAfterReconnect()
|
||||
|
||||
assertSame(once, once.settledAfterReconnect())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing in flight returns the very same instance`() {
|
||||
// 每次 Connected 都会调它;没有「进行中」可收时必须返回同一个实例(否则 StateFlow 白发一轮)。
|
||||
val idle = ChatUiState(messages = listOf(message("m-a")))
|
||||
|
||||
assertSame(idle, idle.settledAfterReconnect())
|
||||
assertSame(emptyList<StreamingLeg>(), emptyList<StreamingLeg>().allFinished())
|
||||
assertSame(emptyList<ToolCallState>(), emptyList<ToolCallState>().settledAfterReconnect())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a binding that was already closed is never flipped back`() {
|
||||
// `closed` 是**单调**的既成事实(`withTurnBegin` 合并时也保持原值)——收口之后
|
||||
// 一句迟到的「这一路还在跑」不许把它翻回灰态。
|
||||
val closed = emptyList<TurnBinding>()
|
||||
.withTurnBegin("t1", 1, listOf("m-a"), 1)
|
||||
.allClosed()
|
||||
val state = ChatUiState(messages = listOf(message("m-a")), turns = closed)
|
||||
|
||||
val settled = state.settledAfterReconnect()
|
||||
|
||||
assertTrue(settled.turns.all { it.closed })
|
||||
assertEquals(TurnPhase.Answered, settled.phaseOf(settled.messages.single()))
|
||||
// 一句迟到的 turn_begin 也不会把它翻回去(既有的 withTurnBegin 纪律,这里一起钉住)。
|
||||
val reopened = settled.turns.withTurnBegin("t1", 1, listOf("m-a"), 1)
|
||||
assertEquals(TurnPhase.Answered, settled.copy(turns = reopened).phaseOf(settled.messages.single()))
|
||||
}
|
||||
|
||||
// —— 4. 可降级:收口只改「进行中的外观」 ——
|
||||
|
||||
@Test
|
||||
fun `settling touches nothing the user typed or sent`() {
|
||||
val stuck = stuckState()
|
||||
|
||||
val settled = stuck.settledAfterReconnect()
|
||||
|
||||
assertSame(stuck.messages, settled.messages)
|
||||
assertSame(stuck.pending, settled.pending)
|
||||
assertSame(stuck.queuedKeys, settled.queuedKeys)
|
||||
assertEquals(stuck.draft, settled.draft)
|
||||
assertEquals(stuck.error, settled.error)
|
||||
assertFalse(settled.sessionGone)
|
||||
// 绑定表**只标 closed,不删**:并行路角标靠它,删了等于事后篡改历史。
|
||||
assertEquals(stuck.turns.map { it.turnId }, settled.turns.map { it.turnId })
|
||||
assertEquals(stuck.turns.map { it.messageIds }, settled.turns.map { it.messageIds })
|
||||
}
|
||||
|
||||
// —— 工具卡的收口 ——
|
||||
|
||||
@Test
|
||||
fun `a running tool card stops claiming to be running`() {
|
||||
val tools = listOf(
|
||||
ToolCallState(
|
||||
id = "call-1",
|
||||
index = 0,
|
||||
tool = "search",
|
||||
arguments = "{\"q\":1}",
|
||||
output = "前 3 条",
|
||||
done = true,
|
||||
phase = ToolPhase.Running,
|
||||
),
|
||||
)
|
||||
|
||||
val settled = tools.settledAfterReconnect()
|
||||
|
||||
val card = settled.single()
|
||||
assertNull("不再声称在跑", card.phase)
|
||||
assertTrue("省略号必须去掉", card.done)
|
||||
// 参数与输出一个都没动:这是「不再是进行中」,不是「清空这张卡」。
|
||||
assertEquals("{\"q\":1}", card.arguments)
|
||||
assertEquals("前 3 条", card.output)
|
||||
assertEquals("search", card.tool)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tool call without an end frame loses its ellipsis too`() {
|
||||
// 只到了 `tool_call.*`、没到 `tool_execution.*`:标题是「调用工具…」——那个省略号
|
||||
// 同样是「还在跑」,收口之后它不再在跑。
|
||||
val settled = listOf(ToolCallState("call-1", 0, tool = "search")).settledAfterReconnect()
|
||||
|
||||
assertTrue(settled.single().done)
|
||||
assertNull(settled.single().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tool card with a verdict from the server is left alone`() {
|
||||
// Done / Error 是**服务端给的结论**,本端不许改写它,更不许把它降级成「不知道」。
|
||||
val done = ToolCallState("c1", 0, phase = ToolPhase.Done, done = true, resultSummary = "2 条")
|
||||
val failed = ToolCallState("c2", 1, phase = ToolPhase.Error, done = true)
|
||||
|
||||
val tools = listOf(done, failed).also { assertEquals(2, it.size) }
|
||||
val settled = tools.settledAfterReconnect()
|
||||
|
||||
assertSame(ToolPhase.Done, settled[0].phase)
|
||||
assertSame(ToolPhase.Error, settled[1].phase)
|
||||
assertEquals("2 条", settled[0].resultSummary)
|
||||
assertSame(tools, tools.settledAfterReconnect())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real frame after settling can put the card back to running`() {
|
||||
// 如实登记「闪动」的方向:收口之后**真的又来了一帧**时,卡片会重新变成「执行 X…」——
|
||||
// 那是如实的(它确实又在跑了),而且是自愈的:这条帧不来的话卡片就停在「工具调用 · X」。
|
||||
val settled = listOf(ToolCallState("c1", 0, tool = "search", phase = ToolPhase.Running))
|
||||
.settledAfterReconnect()
|
||||
|
||||
val again = applyToolExecution(settled, "c1", "search", "running")
|
||||
|
||||
assertSame(ToolPhase.Running, again.single().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty leg is dropped instead of lingering as a ghost`() {
|
||||
// `active = true` 而什么都不画时,气泡里只有一个光标;收口它等于留下一个**一个像素都不画、
|
||||
// 却仍占着列表末项**的幽灵(末项正是「跟随底部」的判据)。这一路本来就没内容,丢掉它
|
||||
// 是本函数唯一的删除动作。
|
||||
val empty = listOf(StreamingLeg(bucket("t1"), StreamingState(active = true)))
|
||||
|
||||
assertEquals(emptyList<StreamingLeg>(), empty.allFinished())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leg with only a running tool card survives as an inert card`() {
|
||||
val leg = StreamingLeg(
|
||||
bucket("t1"),
|
||||
StreamingState(
|
||||
active = true,
|
||||
tools = listOf(ToolCallState("c1", 0, tool = "search", phase = ToolPhase.Running)),
|
||||
),
|
||||
)
|
||||
|
||||
val settled = listOf(leg).allFinished()
|
||||
|
||||
assertEquals(1, settled.size)
|
||||
assertFalse(settled.single().state.active)
|
||||
assertNull(settled.single().state.tools.single().phase)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -43,12 +44,26 @@ import top.yeij.cyrene.domain.share.SharedDocument
|
||||
* `the preview window stays three lines tall`。
|
||||
*
|
||||
* ## W56 又加上了什么(主人拍板:「可以滚动 可以展开」,两个都要)
|
||||
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以正文排下来超过三行时([sharePreviewTextOverflowsWindow])
|
||||
* 卡片右上一枚「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
|
||||
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以这一窗**装不下**里面的内容时
|
||||
* ([sharePreviewTextOverflowsWindow])卡片右上一枚「展开」把这一窗放到
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
|
||||
* **展开与滚动本身仍然是渲染层的事、单测够不着**,所以这里钉的同样是它们的两个前提:**什么时候该
|
||||
* 出现那枚控件**、**展开态给多高**——两条判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
|
||||
* [sharePreviewMaxHeight]),见 `the expand control shows up only when the body does not fit the
|
||||
* window` 与 `expanding gives the preview more room, still within a bound`。
|
||||
* 出现那枚控件**、**展开态给多高**——判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
|
||||
* [sharePreviewWindowHeight] / [sharePreviewMaxHeight]),见
|
||||
* `the expand control shows up only when the content does not fit the window`、
|
||||
* `the window is as tall as three measured lines, whatever the font scale says` 与
|
||||
* `expanding gives the preview more room, still within a bound`。
|
||||
*
|
||||
* ## W65 把「超没超窗」的判据整个换掉了(读这一节才知道为什么是高度)
|
||||
* W65 之前预览的正文是**一个 `Text`**(原样那一段字),所以「装不装得下」数得出来:`lineCount > 3`。
|
||||
* W65 起预览也走 `domain/parse` 的解析([sharePreviewBlocks],与气泡同一个 `parse`),这一窗里成了
|
||||
* **多个块**,每块还各自带旁白行与块间距——行数再也不覆盖窗口的量。判据因此从「数行数」换成
|
||||
* **比高度**:内容自然高度 vs 窗口高度([sharePreviewTextOverflowsWindow] 的两个参数);窗口高本身也
|
||||
* 不再取「第三行的底边」,而是「量到的一行 × [SHARE_PREVIEW_TEXT_LINES]」
|
||||
* ([sharePreviewWindowHeight])——多块之后没有任何一个块的第三行等于这一窗的第三行。
|
||||
*
|
||||
* ⚠️ 解析口径本身的变化(预览与气泡由此同源)不在本文件里钉,在 `ShareTextPipelineTest` 里
|
||||
* (那一份测的正是「从分享进来一路到气泡」这条跨层串)。
|
||||
*/
|
||||
class ShareConfirmTest {
|
||||
|
||||
@@ -259,29 +274,56 @@ class ShareConfirmTest {
|
||||
}
|
||||
|
||||
// ——— W56:「展开 / 收起」(主人拍板:滚动与展开**两个都要**)———
|
||||
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的两个前提:
|
||||
// 那枚控件什么时候出现、展开态给多高。两条判据都在 ShareConfirmPanel.kt 里做成了纯函数。
|
||||
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的前提:
|
||||
// 那枚控件什么时候出现、这一窗有多高、展开态给多高。三条判据都在 ShareConfirmPanel.kt 里做成了
|
||||
// 纯函数(W65 把「什么时候出现」从数行数换成了比高度,理由见那边的 KDoc)。
|
||||
|
||||
@Test
|
||||
fun `the expand control shows up only when the body does not fit the window`() {
|
||||
// 正好三行 = 整段都在窗里(这一窗的底就是第三行的底边):此时给一枚「展开」就是主人说的
|
||||
// 「点了没反应的展开」。多一行就是第四行落在窗外,展开确有东西可看。
|
||||
assertFalse(sharePreviewTextOverflowsWindow(0))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(1))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES + 1))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(40))
|
||||
fun `the expand control shows up only when the content does not fit the window`() {
|
||||
// 判据的两边都是**排出来的**(W65 起):内容自然高(挂在 `verticalScroll` **里面**量到的那个,
|
||||
// 不受这一窗上限影响)与窗口高(量到的行高 × 三)。这里把它的全部形状穷举完。
|
||||
//
|
||||
// 还没量到的那一帧(0):不给控件——首帧摆上一枚点了没反应的「展开」,比它晚一帧出现更糟。
|
||||
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 0))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 0, windowHeightPx = 100))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 0))
|
||||
|
||||
// 判据跟着那一窗的行数走(上面用的是常量),所以再用主人拍的那个数字面量钉一遍「这条线是
|
||||
// 3」——两条一起才既钉住行为、又钉住那个数(同 `the preview window stays three lines tall`
|
||||
// 的口径)。
|
||||
assertFalse(sharePreviewTextOverflowsWindow(3))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(4))
|
||||
// 边界是**严格大于**:一样高就是整段都在窗里(窗底一条不多),此时给一枚「展开」是主人说的
|
||||
// 「点了没反应的展开」;多一个像素就是窗里还有东西没露出来,展开确有东西可看。
|
||||
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 100, windowHeightPx = 100))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 101, windowHeightPx = 100))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 400, windowHeightPx = 60))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 60))
|
||||
|
||||
// 接一份真分享走一遍:正文里每有一个换行,排下来就至少多一行,所以 40 行的那段一定超过窗口
|
||||
// (排版后的行数只会比 `lines().size` 更多——单行太长还会折行)。
|
||||
// 单调:内容只增、窗口只减,都只会把它推向「超窗」——两个方向各钉一条,防止将来有人把比较写反。
|
||||
assertTrue(sharePreviewTextOverflowsWindow(contentHeightPx = 1000, windowHeightPx = 60))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(contentHeightPx = 59, windowHeightPx = 600))
|
||||
|
||||
// 接一份真分享走一遍:40 行的正文在解析后仍然是一块(无标签的正文按既有语义整段成块),而这一块
|
||||
// 排下来必然高过三行窗。⚠️ 「高多少」是**渲染层排出来的像素**,单测这里拿不到(本模块没有 Compose
|
||||
// UI 测试依赖),所以这一条只钉到「内容就是那 40 行」为止——旧口径按行数比得出 40 > 3,新口径下
|
||||
// 那个比较整个属于渲染层,这一份测试能守的就是它的输入。
|
||||
val preview = shareConfirmPreview(SharedContent(text = (1..40).joinToString("\n") { "第 $it 行" }))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(preview.text.lines().size))
|
||||
assertEquals(40, sharePreviewBlocks(preview.text).single().body.lines().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the window is as tall as three measured lines, whatever the font scale says`() {
|
||||
// 窗口高**没有**自己的 dp 常量:它是「量到的一行高度」× SHARE_PREVIEW_TEXT_LINES(理由见
|
||||
// `ShareConfirmPanel` 里 `sharePreviewWindowHeight` 的 KDoc)。3 这个数只有一处。
|
||||
assertEquals(3, SHARE_PREVIEW_TEXT_LINES)
|
||||
assertEquals(60.dp, sharePreviewWindowHeight(20.dp))
|
||||
assertEquals(20.dp * SHARE_PREVIEW_TEXT_LINES, sharePreviewWindowHeight(20.dp))
|
||||
|
||||
// 大字体档:量到的行高更大 ⇒ 窗口跟着更高。这正是「不许写死一个 dp 常量」的算术面——写死的那个
|
||||
// 数在大字号档上会让这一窗矮到不足三行、末行被切。
|
||||
assertEquals(120.dp, sharePreviewWindowHeight(40.dp))
|
||||
assertTrue(sharePreviewWindowHeight(40.dp) > sharePreviewWindowHeight(20.dp))
|
||||
|
||||
// 它下一站就是 `heightIn`,所以给出去的东西必须恒有限(那一条的整体不变量见
|
||||
// `ShareConfirmWindowCapTest`)。
|
||||
assertTrue(sharePreviewWindowHeight(1.dp).value.isFinite())
|
||||
assertTrue(shareConfirmWindowCap(sharePreviewWindowHeight(20.dp)).value.isFinite())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -317,3 +359,55 @@ class ShareConfirmTest {
|
||||
assertTrue(SHARE_PREVIEW_COLLAPSE_LABEL.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* W58:正文那一窗的高度上限**永远不许是 [androidx.compose.ui.unit.Dp.Infinity]**。
|
||||
*
|
||||
* 背景是真机上**两次**崩溃、同一个异常:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而
|
||||
* `verticalScroll` 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会
|
||||
* 直接抛 `IllegalStateException` 把 App 打崩。
|
||||
*
|
||||
* 两次踩在不同的地方,都值得钉住:
|
||||
* 1. **初值**用了 `Dp.Infinity` 当「还没量到」的哨兵值(分享面板一打开就闪退);
|
||||
* 2. **展开态**只去掉了上限、把 `verticalScroll` 留着 —— 外层预览区自己就是一个 `verticalScroll`,
|
||||
* 它给内容的是**无界高度**,于是这一层的 scroll 在无界高度下被测量(点「展开」的那一下闪退)。
|
||||
*
|
||||
* 结论:这一层的高度上限只经 [shareConfirmWindowCap] 一个出口(永远有限),且展开态**连滚动一起去掉**。
|
||||
*
|
||||
* 这两条都只能靠纯函数钉住:本模块的 `testImplementation` 只有 junit,没有 Compose UI 测试依赖,
|
||||
* 渲染层的崩溃单测抓不到 —— 这也是它两次都只在真机上暴露的原因。
|
||||
*/
|
||||
class ShareConfirmWindowCapTest {
|
||||
|
||||
@Test
|
||||
fun `the fallback cap is finite and never infinity`() {
|
||||
val fallback = shareConfirmWindowCap(measured = null)
|
||||
assertTrue("首帧的兜底上限必须是有限值", fallback.value.isFinite())
|
||||
assertEquals(SHARE_PREVIEW_MAX_HEIGHT, fallback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a measured cap is passed through`() {
|
||||
assertEquals(42.dp, shareConfirmWindowCap(measured = 42.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no input can produce a non finite cap`() {
|
||||
// 非有限的那两个取值都在表里:`Dp.Infinity` 是 W58 第一次崩溃的形状(当时它被当「还没量到」
|
||||
// 的哨兵值),`Dp.Unspecified`(NaN)是「我忘了给它一个数」最常见的写法。两个都必须回落兜底——
|
||||
// 这一层的出口要自己保证「给出去的上限总是有限」,而不是指望调用方永远不传错。
|
||||
for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp, Dp.Infinity, Dp.Unspecified)) {
|
||||
val cap = shareConfirmWindowCap(m)
|
||||
assertTrue("measured=$m 给出了非有限的上限:$cap", cap.value.isFinite())
|
||||
assertNotEquals("measured=$m 仍把无界约束放过去了", Dp.Infinity, cap)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both preview states keep a finite height bound somewhere in the chain`() {
|
||||
// 展开态这一层「连滚动一起去掉」了,但**外层预览区**仍是一层有限约束在管事 ——
|
||||
// 这正是展开后不会掉进同一个「无界高度」崩溃的原因。
|
||||
assertTrue(sharePreviewMaxHeight(expanded = true).value.isFinite())
|
||||
assertTrue(sharePreviewMaxHeight(expanded = false).value.isFinite())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
|
||||
/**
|
||||
* 接线:`ChatViewModel.emitPlannedMessages` 逐条投递时喂给 [ShareRules.declaresSendPreference] 的
|
||||
* 那两个入参(`fromShare` 与 `index`)。
|
||||
*
|
||||
* ## 判据在 `ShareSendDeclarationTest`,这里钉的是「喂进去的是什么」
|
||||
* 判据本身(`!fromShare && index == 0`)已被 `ShareSendDeclarationTest` 在裸整数上穷举过。本测试补的
|
||||
* 是另一半:VM 那个 `forEachIndexed` 的 `index` **是 [ImageSendPlan.messages] 里的位置**,不是选图
|
||||
* 顺序、也不是 `imagesToSend` 的下标。两者在超限截断时会不一样——`images` 给了 7 张而 `messages` 只有
|
||||
* [ShareRules.MAX_IMAGES] 条,若哪天把声明算在「原始选图位置」上,就会有一条落在这次根本不发的消息上。
|
||||
*
|
||||
* ## 为什么不是「直接调 VM」
|
||||
* `ChatViewModel` 要一个 [top.yeij.cyrene.app.AppContainer],本项目的单元测试没有 fake container /
|
||||
* fake SessionsApi 这套基建(登记在 `05` 的遗留里),所以 VM 里的编排穿不过去。这里退一步钉住**它喂进去
|
||||
* 的形状**;调用点自身靠结构守:`fromShare` 是必填参数(没有默认值)、判据只有 [ShareRules] 一处。
|
||||
*/
|
||||
class ShareDeclarationWiringTest {
|
||||
|
||||
private fun images(n: Int): List<String> = (1..n).map { "content://img$it" }
|
||||
|
||||
/** VM 里那一段:`plan.messages.forEachIndexed { index, _ -> …declaresSendPreference(fromShare, index) }`。 */
|
||||
private fun declarations(fromShare: Boolean, plan: ImageSendPlan): List<Boolean> =
|
||||
plan.messages.indices.map { index -> ShareRules.declaresSendPreference(fromShare, index) }
|
||||
|
||||
@Test
|
||||
fun `a share batch declares nothing at any position of the plan`() {
|
||||
// 0 = 只有正文的分享(也走这条路,同样不声明);1..MAX_IMAGES 逐档穷举。
|
||||
(0..ShareRules.MAX_IMAGES).forEach { n ->
|
||||
val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList())
|
||||
assertEquals("N=$n 的切分至少一条消息", n.coerceAtLeast(1), plan.messages.size)
|
||||
assertFalse(
|
||||
"分享 N=$n 的任何一条都不许声明(声明就会打断同一批里的上一条)",
|
||||
declarations(fromShare = true, plan).any { it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the typed send declares on the first planned message only`() {
|
||||
(0..ShareRules.MAX_IMAGES).forEach { n ->
|
||||
val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList())
|
||||
assertEquals(
|
||||
"手打 N=$n:只有第一条按「她忙时发消息」偏好投递",
|
||||
List(plan.messages.size) { index -> index == 0 },
|
||||
declarations(fromShare = false, plan),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated images never shift the declaration onto a message outside the batch`() {
|
||||
val plan = imageSendPlan(
|
||||
text = "看这个",
|
||||
images = images(ShareRules.MAX_IMAGES + 3),
|
||||
documents = emptyList(),
|
||||
)
|
||||
// 超限那几张本来就不发(见 truncatedImagesNotice),声明只按真正发出去的条数算。
|
||||
assertEquals(ShareRules.MAX_IMAGES, plan.messages.size)
|
||||
assertEquals(3, plan.truncatedImages)
|
||||
|
||||
assertEquals(ShareRules.MAX_IMAGES, declarations(fromShare = true, plan).size)
|
||||
assertFalse(declarations(fromShare = true, plan).any { it })
|
||||
assertEquals(
|
||||
List(ShareRules.MAX_IMAGES) { index -> index == 0 },
|
||||
declarations(fromShare = false, plan),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ import top.yeij.cyrene.domain.share.SharedDocument
|
||||
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声);
|
||||
* 4. **「读不到」与「内容是空的」不许叠说**:2026-10-05 实机读到的「有 2 个文件读不到,未发送;
|
||||
* 分享的内容是空的,没有可发送的内容」会让用户以为出了两个问题,而前者本身就蕴含后者。
|
||||
*
|
||||
* 末尾还有**另一句告知**的用例:[shareTextOriginNotice](正文是从 `EXTRA_SUBJECT` / `EXTRA_TITLE`
|
||||
* 回落来的那一句)。它与 [shareNoticeFor] 是同族的「如实告知」,但**时机不同**:那条走的是认领那一刻
|
||||
* 的柔和通道(面板正要长出来时),因为「这段字只是对方给的标题」必须在用户点「发送」**之前**说;
|
||||
* 而它同样有一条不许破的边界——**绝不进正文**。
|
||||
*/
|
||||
class ShareNoticeTest {
|
||||
|
||||
@@ -121,4 +126,60 @@ class ShareNoticeTest {
|
||||
shareNoticeFor(SharedContent(failedImages = 2, contentUnreadable = true)),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 正文是从标题回落来的那一句(分享认领那一刻的如实小字,见 `ShareTextOrigin.kt`)——
|
||||
|
||||
@Test
|
||||
fun `a body that came from the share title says so`() {
|
||||
// 有些发送方把内容放在 `EXTRA_SUBJECT` / `EXTRA_TITLE` 里(`EXTRA_TEXT` 一个值都没有)。
|
||||
// 回落来的那一段与真正文长得一模一样:不标一句,用户会以为对方发来的就是这句正文。
|
||||
listOf(
|
||||
ShareRules.extract(action = ShareRules.ACTION_SEND, subject = "对方只给了标题"),
|
||||
ShareRules.extract(action = ShareRules.ACTION_SEND, title = "对方只给了标题"),
|
||||
).forEach { share ->
|
||||
assertEquals("对方只给了标题", share.text)
|
||||
assertEquals(SHARE_TEXT_FROM_TITLE_NOTICE, shareTextOriginNotice(share))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the origin hint says both what it is and what is missing`() {
|
||||
// 只说「它是什么」,用户仍会以为这句话就是对方发来的正文;只说「缺了什么」,他不知道预览里那行
|
||||
// 字是从哪冒出来的。也刻意不说「失败」「错误」:这次分享一个字节都没丢,只是本来就只有标题。
|
||||
assertTrue(SHARE_TEXT_FROM_TITLE_NOTICE.contains("分享标题"))
|
||||
assertTrue(SHARE_TEXT_FROM_TITLE_NOTICE.contains("没有提供正文"))
|
||||
assertFalse(SHARE_TEXT_FROM_TITLE_NOTICE.contains("失败"))
|
||||
assertFalse(SHARE_TEXT_FROM_TITLE_NOTICE.contains("错误"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a body that came from the text says nothing`() {
|
||||
// 规范路径一切照旧:正文本来就是正文 ⇒ 一个字都不说(与 `shareNoticeFor` 的静默边界同一条纪律)。
|
||||
assertNull(
|
||||
shareTextOriginNotice(
|
||||
ShareRules.extract(action = ShareRules.ACTION_SEND, text = "对方写的正文", subject = "标题"),
|
||||
),
|
||||
)
|
||||
assertNull(shareTextOriginNotice(SharedContent(text = "看这个")))
|
||||
// 只有图片 / 文件的那一次分享也没有「来源」这件事可说。
|
||||
assertNull(shareTextOriginNotice(SharedContent(images = listOf("file:///cache/share-1.png"))))
|
||||
assertNull(
|
||||
shareTextOriginNotice(
|
||||
SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt"))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the origin hint never leaks into the message we send`() {
|
||||
// 红线:那句说明只出现在**界面**上,一个字都不许拼进将要发送的正文(把来源标记拼进正文 =
|
||||
// 让用户正文冒充系统指令)。所以预览里那段字与发出去的那条消息逐字相同。
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, subject = "对方只给了标题")
|
||||
|
||||
assertEquals("对方只给了标题", shareConfirmPreview(share).text)
|
||||
assertEquals("对方只给了标题", shareConfirmBody(share, ""))
|
||||
assertEquals("对方只给了标题${SHARE_CONFIRM_BODY_SEPARATOR}补一句", shareConfirmBody(share, "补一句"))
|
||||
assertFalse(shareConfirmBody(share, "补一句").contains("分享标题"))
|
||||
assertFalse(shareConfirmBody(share, "补一句").contains(SHARE_TEXT_FROM_TITLE_NOTICE))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||
import top.yeij.cyrene.domain.chat.SharedTextPlan
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
|
||||
/**
|
||||
* 「从别的 App 分享一段**文本**进来」这条路的**端到端纯函数串**(W61 审查补的回归网)。
|
||||
*
|
||||
* ## 为什么要一个跨层的串测
|
||||
* 这条路上的每一站都各自被单测钉过(`ShareExtractionTest` 管 Intent 字段怎么读、
|
||||
* `ShareConfirmTest` 管面板的预览与拼正文、`ShareNoticeTest` 管告知、`ChatContentParserTest` 管标记),
|
||||
* 但**没有任何一个用例把这几站接起来跑一遍**——而真机上的那次事故(网易云分享文本 → 闪退)
|
||||
* 恰恰发生在接缝上:正文在 `ShareRules.extract` 里被 `trim`、在 `shareConfirmPreview` 里再 `trim`
|
||||
* 一次、在 `shareConfirmBody` 里与用户描述用空行拼接、最后整段落进 `parse` 按标记切气泡。
|
||||
* 四站各自都对,合起来也未必是用户看到的那句话。
|
||||
*
|
||||
* ## 它钉住的三件事(都是**判据**,不是实现)
|
||||
* 1. **内容一字不丢**:一次「分享单曲 + 链接 + emoji + 多行」的分享,从 Intent 字段一路到气泡正文
|
||||
* 逐字节相同;用户补的那句描述同样逐字节相同。
|
||||
* 2. **正文里的标记按既有语义走,且绝不抛异常**:分享进来的那段字里带 `<p>` / `<action>` / 星号时,
|
||||
* 渲染结果由 `domain/parse` 的既有口径决定(W55 的落单标签剥离、旁白/动作标记、Markdown 强调不吞),
|
||||
* 这里只钉「不崩、且除标记本身之外一个字不少」。
|
||||
* 3. **面板预览与气泡走的是同一套解析**(W65 起):预览的正文区渲染的就是 `MessageBubble` 用的那
|
||||
* 同一个 `parse` 的结果(`sharePreviewBlocks` 只给它加了一条兜底),所以「预览里是一串标签、
|
||||
* 气泡里只剩正文」这类不一致不会再出现——见下面那几条 W65 的用例。
|
||||
*
|
||||
* ## W65 改掉了什么(口径变更,读这份测试时要先知道)
|
||||
* W61 审查发现:预览把正文 `trim` 之后**原样显示**、不过解析器,而发送后的气泡过解析器。分享一段
|
||||
* `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩「正文」**——用户按发送之前看到的不是他
|
||||
* 将要发出的东西(这不是崩溃,是当时如实记下的一处不一致)。
|
||||
*
|
||||
* W65 把这条口径改掉了:预览按**与气泡同一套解析**渲染(多段 → 多个块,`<action>` / `<narration>`
|
||||
* → 灰斜体小字,星号规则同源)。所以本文件原本如实记下的那处不一致(旧用例
|
||||
* `the preview shows the raw text while the bubble goes through the parser`)**已被推翻**,替换成
|
||||
* 新口径下的断言:**预览与气泡的正文文本一致**,且标签一个字都不显示。
|
||||
*
|
||||
* ⚠️ 本文件**不**覆盖渲染层本身(Compose):本模块没有 Compose UI 测试依赖,`Dp.Infinity` 那一类
|
||||
* 崩溃单测抓不到(那是 W58 的教训)。这里管的是**进渲染层之前**那份数据对不对、会不会抛异常。
|
||||
*/
|
||||
class ShareTextPipelineTest {
|
||||
|
||||
/** 网易云那类分享的典型形态:多行、书名号、链接、emoji、全角标点。 */
|
||||
private val neteaseLike =
|
||||
"分享单曲《夜曲》 - 周杰伦 🎵\n" +
|
||||
"https://music.163.com/song?id=185809\n" +
|
||||
"只想说一句:谢谢昔涟 💛"
|
||||
|
||||
/** 一次分享的正文从 Intent 字段走到「该发出去的那段字」(面板那一站的纯函数)。 */
|
||||
private fun bodyOf(raw: String, caption: String = ""): String =
|
||||
shareConfirmBody(ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw), caption)
|
||||
|
||||
@Test
|
||||
fun `a text share reaches the bubble byte for byte`() {
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = neteaseLike)
|
||||
|
||||
assertEquals(neteaseLike, share.text)
|
||||
assertTrue(share.worthDelivering)
|
||||
assertFalse(share.isEmpty)
|
||||
assertFalse(share.contentUnreadable)
|
||||
// 一次正常的文本分享没有任何要对用户说的事(有内容、没丢件、没有读不进来)。
|
||||
assertNull(shareNoticeFor(share))
|
||||
|
||||
val preview = shareConfirmPreview(share)
|
||||
assertEquals(neteaseLike, preview.text)
|
||||
assertTrue(preview.hasText)
|
||||
assertFalse(preview.isEmpty)
|
||||
assertTrue(shareConfirmCanSend(share, ""))
|
||||
|
||||
val bubbles = parse(shareConfirmBody(share, ""))
|
||||
assertEquals(1, bubbles.size)
|
||||
assertEquals(neteaseLike, bubbles.single().body)
|
||||
assertTrue(bubbles.single().head.isEmpty())
|
||||
assertTrue(bubbles.single().tail.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the caption is appended unchanged and both halves stay readable`() {
|
||||
val caption = "这条你听过吗?"
|
||||
val body = bodyOf(neteaseLike, caption)
|
||||
|
||||
assertEquals(neteaseLike + SHARE_CONFIRM_BODY_SEPARATOR + caption, body)
|
||||
// 空行是「这是两段」的表达,而既有渲染规则把空行当**气泡边界**(`blankLineFallbackSplitsBubbles`):
|
||||
// 于是这条消息在界面上是两个气泡,两段各自逐字节不变。这不是分享引入的规则——用户手打两段
|
||||
// 同样如此;这里把它钉住,是为了让「分享 + 描述」的观感有一处能说清的话。
|
||||
assertEquals(
|
||||
listOf(neteaseLike, caption),
|
||||
parse(body).map { it.body },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the caption alone is sent when the share carried no text`() {
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = " ")
|
||||
|
||||
assertFalse(share.worthDelivering)
|
||||
assertTrue(share.isEmpty)
|
||||
// 正文全空白 ⇒ 面板里没得预览;这时能发的只剩用户补的描述(按钮判据与发送路径同源)。
|
||||
assertFalse(shareConfirmPreview(share).hasText)
|
||||
assertFalse(shareConfirmCanSend(share, ""))
|
||||
assertTrue(shareConfirmCanSend(share, "只看图"))
|
||||
assertEquals("只看图", shareConfirmBody(share, "只看图"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a very long shared text is kept whole and goes out as a file`() {
|
||||
val long = "这是一段被摊平的卡片内容。".repeat(5_000)
|
||||
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = long)
|
||||
|
||||
assertEquals(long, share.text)
|
||||
assertEquals(1, parse(shareConfirmBody(share, "")).size)
|
||||
// 「分享进来的长正文落成 .txt」是既有定案(DocumentDelivery.planSharedText);本端一个字都不改它,
|
||||
// 而这条判据决定了正文那一栏会不会被清空——两个入口共用同一份判据是刻意的。
|
||||
assertEquals(SharedTextPlan.AsDocument, DocumentDelivery.planSharedText(share.text, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markup in the shared text is handled by the parser and never crashes`() {
|
||||
// 分享进来的字里带**真标记**时的落点:剥离/解析口径是 `domain/parse` 的既有契约(W55 起),
|
||||
// 这里只断言「不抛异常 + 结果与那条契约一致」,不发明新口径。
|
||||
val malformed = parse("<p><p>正文</p>").map { it.body }
|
||||
assertEquals(listOf("正文"), malformed)
|
||||
|
||||
val loose = parse("<action>x</action>")
|
||||
assertEquals(1, loose.size)
|
||||
assertEquals("", loose.single().body)
|
||||
assertEquals(1, loose.single().head.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览里**看得见的字**:按渲染顺序(每块:上依附的旁白 → 正文 → 下依附的旁白),空白的不算。
|
||||
*
|
||||
* 这个顺序就是 `ShareConfirmPanel` 里 `ShareConfirmBlock` 的排法——用它当「用户看到的是什么」,
|
||||
* 好让下面几条断言能直接钉住**可见文本**,而不只是内部结构。
|
||||
*/
|
||||
private fun visibleLines(blocks: List<ParsedBubble>): List<String> = buildList {
|
||||
blocks.forEach { block ->
|
||||
addAll(block.head.map { it.text })
|
||||
add(block.body)
|
||||
addAll(block.tail.map { it.text })
|
||||
}
|
||||
}.filter { it.isNotBlank() }
|
||||
|
||||
@Test
|
||||
fun `the preview and the bubble show the very same text`() {
|
||||
// W65 口径变更(先读类上的「W65 改掉了什么」):预览的正文区**走的就是气泡那一个 `parse`**,
|
||||
// 所以两边看得见的正文与旁白必须一字不差。
|
||||
//
|
||||
// ⚠️ 但 `preview.text` 本身仍然是**原样那一段字**(只 `trim`)——解析发生在**渲染**这一层,没有
|
||||
// 搬进 `shareConfirmPreview`。这一条也一起钉住:数据层不许替用户改字。
|
||||
listOf(
|
||||
"<p><p>正文</p>",
|
||||
"<p>第一段</p><p>第二段</p><p>第三段</p>",
|
||||
"<action>看着窗外</action>",
|
||||
"<p>说话</p><action>顿了顿</action>",
|
||||
"第一段\n\n第二段",
|
||||
"1 < 2 和 a<b",
|
||||
neteaseLike,
|
||||
).forEach { raw ->
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
|
||||
val preview = shareConfirmPreview(share)
|
||||
val sent = shareConfirmBody(share, "")
|
||||
|
||||
assertEquals("预览的原始正文被改字了:$raw", raw, preview.text)
|
||||
|
||||
// 气泡那一侧:`MessageBubble` 用的同一个 `parse`(非空结果上那一条 `filterNot { isEmpty }`
|
||||
// 不删任何东西,这里照抄它当基准——预览那一侧多出来的只是同一条兜底)。
|
||||
val bubbles = parse(sent).filterNot { it.isEmpty }
|
||||
.ifEmpty { listOf(ParsedBubble(sent, emptyList(), emptyList(), emptyList())) }
|
||||
val rendered = sharePreviewBlocks(preview.text)
|
||||
|
||||
assertEquals("预览与气泡的正文不一致:$raw", bubbles.map { it.body }, rendered.map { it.body })
|
||||
assertEquals(
|
||||
"预览与气泡的旁白不一致:$raw",
|
||||
bubbles.map { b -> (b.head + b.tail).map { it.text } },
|
||||
rendered.map { b -> (b.head + b.tail).map { it.text } },
|
||||
)
|
||||
|
||||
// 标记一个字符都不许出现在**看得见的地方**:预览此前把 `<p>` / `</p>` 原样摆给用户看,
|
||||
// 那正是这一批要修的东西。
|
||||
visibleLines(rendered).forEach { line ->
|
||||
assertFalse("标记漏进了可见文本:$line", line.contains("<p") || line.contains("</p"))
|
||||
assertFalse("标记漏进了可见文本:$line", line.contains("<action") || line.contains("<narration"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share full of stray paragraph tags shows the same text in the preview as in the bubble`() {
|
||||
// W61 审查发现的那处不一致的**原样输入**:多一个 `<p>`、只有一个 `</p>`(服务端确实会这样产出)。
|
||||
// 旧口径:预览是 `"<p><p>正文</p>"` 这一串字符;气泡里只剩「正文」。
|
||||
// 新口径(W65):预览也走 `parse`,两边都是「正文」,一个标签字符都不显示。
|
||||
val raw = "<p><p>正文</p>"
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
|
||||
val rendered = sharePreviewBlocks(shareConfirmPreview(share).text)
|
||||
|
||||
assertEquals(listOf("正文"), visibleLines(rendered))
|
||||
assertEquals("正文", rendered.single().body)
|
||||
// 气泡那一侧(同一条消息发出去之后渲染的那一份)逐字相同。
|
||||
assertEquals(parse(shareConfirmBody(share, "")).single().body, rendered.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each paragraph of the shared text becomes its own block in the preview`() {
|
||||
// 多段 ⇒ 多个块:旧口径下 `<p>甲</p><p>乙</p>` 在预览里是**一行还带着标签的原样文本**,现在与
|
||||
// 气泡里一样是两块。切段不是面板新写的规则,是 `parse` 的既有语义。
|
||||
val three = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
text = "<p>第一段</p><p>第二段</p><p>第三段</p>",
|
||||
)
|
||||
assertEquals(listOf("第一段", "第二段", "第三段"), sharePreviewBlocks(three.text).map { it.body })
|
||||
|
||||
// 无标签的正文按**空行**分段(同上):两段就是两块,而不是一段里的一行。
|
||||
val plain = ShareRules.extract(action = ShareRules.ACTION_SEND, text = "第一段\n\n第二段")
|
||||
assertEquals(listOf("第一段", "第二段"), sharePreviewBlocks(plain.text).map { it.body })
|
||||
|
||||
// 段数不是从正文里数出来的:一行里写三个完整块也是三块(按行数只会数出 1)。
|
||||
assertEquals(3, sharePreviewBlocks("<p>甲</p><p>乙</p><p>丙</p>").size)
|
||||
assertEquals(2, sharePreviewBlocks("甲\n\n乙").size)
|
||||
// 单个换行是**句内换行**,绝不切分(既有语义):一块里两行。
|
||||
assertEquals(1, sharePreviewBlocks("甲\n乙").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narration in the shared text is an annotation block, not body text`() {
|
||||
// `<action>` / `<narration>` 在预览里与气泡里一样**不进正文**:那一块没有正文,这一段落在 head
|
||||
// 上,由 `ShareConfirmAnnotation` 渲染成灰斜体小字。标签本身一个字符都不显示。
|
||||
val action = sharePreviewBlocks("<action>看着窗外</action>").single()
|
||||
assertEquals("", action.body)
|
||||
assertEquals(listOf("看着窗外"), action.head.map { it.text })
|
||||
assertTrue(action.head.single() is ContentPart.Action)
|
||||
assertTrue(action.tail.isEmpty())
|
||||
|
||||
val narration = sharePreviewBlocks("<narration>旁白一句</narration>").single()
|
||||
assertEquals("", narration.body)
|
||||
assertTrue(narration.head.single() is ContentPart.Narration)
|
||||
|
||||
// 星号规则同源(`parse` 的既有规则):全角短句 ⇒ 旁白那一行;半角无关键词 ⇒ 保持 Markdown
|
||||
// 强调文本,仍然留在**正文**里。
|
||||
val star = sharePreviewBlocks("*今天也要加油*").single()
|
||||
assertEquals("", star.body)
|
||||
assertEquals(listOf("今天也要加油"), star.head.map { it.text })
|
||||
assertEquals("*随便*", sharePreviewBlocks("*随便*").single().body)
|
||||
|
||||
// 「看得见的那几行」也钉住:旁白排在正文**之外**,按渲染顺序在正文之前(与气泡里 `head` 的位置
|
||||
// 一样)。这一条是「形如气泡」这件事在纯函数层唯一能自证的地方。
|
||||
assertEquals(
|
||||
listOf("顿了顿", "说话"),
|
||||
visibleLines(sharePreviewBlocks("<action>顿了顿</action><p>说话</p>")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shared text the parser drops entirely is still shown verbatim`() {
|
||||
// 解析结果为空时**必须有兜底**(原样显示,不是一块空白):正文那一栏是用户在按发送之前唯一能
|
||||
// 核对的东西。兜底与气泡那一侧同一条(`MessageBubble`:`parsed.ifEmpty { plainBubble(content) }`)
|
||||
// ——都显示**原样那一段字**,哪怕它本来就是几个没配对的标签。
|
||||
val raw = "<p><p>"
|
||||
assertTrue("这一档必须真的解析为空,否则下面几条断言什么也证明不了", parse(raw).isEmpty())
|
||||
|
||||
val block = sharePreviewBlocks(raw).single()
|
||||
assertEquals(raw, block.body)
|
||||
assertTrue(block.head.isEmpty())
|
||||
assertTrue(block.tail.isEmpty())
|
||||
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(raw)))
|
||||
|
||||
// 走一遍真分享的路:`preview.text` 就是 `trim` 过的原串,于是解析为空、由这一条兜底把它显示出来。
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
|
||||
val preview = shareConfirmPreview(share)
|
||||
assertEquals(raw, preview.text)
|
||||
assertEquals(listOf(raw), visibleLines(sharePreviewBlocks(preview.text)))
|
||||
// 兜底不是给空正准备的:这一档的正文本身非空白(否则面板根本不显示正文那一栏)。
|
||||
assertTrue(preview.hasText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stars in the shared text follow the existing markup rule`() {
|
||||
// 全角短句星号 ⇒ 动作(既有语义);半角带关键词 ⇒ 动作;半角无关键词 ⇒ 保持 Markdown 强调文本。
|
||||
val action = parse("*今天也要加油*")
|
||||
assertEquals("", action.single().body)
|
||||
assertEquals("今天也要加油", action.single().head.single().text)
|
||||
|
||||
val keyed = parse("*动作: 挥手*")
|
||||
assertEquals("", keyed.single().body)
|
||||
assertEquals("挥手", keyed.single().head.single().text)
|
||||
|
||||
assertEquals("*随便*", parse("*随便*").single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain angle brackets in the shared text are never eaten`() {
|
||||
listOf("1 < 2 和 a<b", "看电影《心灵奇旅》 <3", "<p2>不是标签</p2>", "a < b > c").forEach { raw ->
|
||||
assertEquals(raw, bodyOf(raw))
|
||||
assertEquals(raw, parse(bodyOf(raw)).single().body)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty whitespace and hostile inputs never throw anywhere on the way`() {
|
||||
val inputs = listOf(
|
||||
"",
|
||||
" ",
|
||||
"\n",
|
||||
"\t\n ",
|
||||
"<",
|
||||
">",
|
||||
"<p",
|
||||
"<p>",
|
||||
"</p>",
|
||||
"<p><p>",
|
||||
"<p>正文</p>",
|
||||
"<action>",
|
||||
"</action>",
|
||||
"*",
|
||||
"*",
|
||||
"\u0000",
|
||||
"\u0000<p>",
|
||||
"🎵",
|
||||
"\uD83C", // 半个代理对:畸形但可能出现在任何一段别人给的文本里
|
||||
"<p>".repeat(200),
|
||||
"<a".repeat(500),
|
||||
"あ".repeat(100_000),
|
||||
)
|
||||
|
||||
inputs.forEach { raw ->
|
||||
val outcome = runCatching {
|
||||
val share = ShareRules.extract(action = ShareRules.ACTION_SEND, text = raw)
|
||||
shareConfirmPreview(share)
|
||||
shareConfirmCanSend(share, "描述")
|
||||
shareNoticeFor(share)
|
||||
parse(shareConfirmBody(share, "描述"))
|
||||
}
|
||||
assertTrue(
|
||||
"输入「${raw.take(24)}…」抛了:${outcome.exceptionOrNull()}",
|
||||
outcome.isSuccess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share that carries nothing at all stays silent`() {
|
||||
// 三个字段都没有值 ⇒ 什么都不该发生、也不该说话(否则每次误触分享都弹一句)。
|
||||
val nothing = ShareRules.extract(action = ShareRules.ACTION_SEND)
|
||||
assertFalse(nothing.worthDelivering)
|
||||
assertNull(shareNoticeFor(nothing))
|
||||
assertFalse(shareConfirmCanSend(nothing, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stream we cannot read still speaks instead of vanishing`() {
|
||||
// 同一个 Intent 里带着内容迹象却一条都读不进来(形态认不出 / scheme 不支持):
|
||||
// 内容照旧为「空」,但**必须**有话可说——这是 `contentUnreadable` 存在的全部理由。
|
||||
val unreadable = ShareRules.extract(
|
||||
action = ShareRules.ACTION_SEND,
|
||||
streamStrings = listOf("file:///etc/hosts", "https://example.com/a.png"),
|
||||
)
|
||||
|
||||
assertTrue(unreadable.isEmpty)
|
||||
assertTrue(unreadable.worthDelivering)
|
||||
assertEquals(SHARE_UNREADABLE_NOTICE, shareNoticeFor(unreadable))
|
||||
assertFalse(shareConfirmCanSend(unreadable, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shared document reaches the panel and keeps the send button usable`() {
|
||||
val share = SharedContent(
|
||||
text = "看这份",
|
||||
documents = listOf(
|
||||
top.yeij.cyrene.domain.share.SharedDocument(
|
||||
uri = "file:///cache/share-intake/share-1-0.md",
|
||||
displayName = "笔记.md",
|
||||
sizeBytes = 2048,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val preview = shareConfirmPreview(share)
|
||||
// 预览里带的就是这份文件本身(显示名由芯片那一栏换算,算术在 DocumentChipTest 里各测各的)。
|
||||
assertEquals(listOf("笔记.md"), preview.documents.map { it.displayName })
|
||||
assertTrue(preview.hasText)
|
||||
assertTrue(shareConfirmCanSend(share, ""))
|
||||
// 只有附件、没有正文时按钮照旧可点(服务端支持纯附件回合),且告知只说丢件、不抱怨没正文。
|
||||
assertNull(shareNoticeFor(share))
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import org.junit.Test
|
||||
* 或整轮只有一张卡;多张卡无法区分时**不落位**,绝不默认 `index == 0`。
|
||||
*
|
||||
* [toolExecutionTargetIndex] 是规则本体;[applyToolExecution] 是 VM 实际消费它的纯函数,
|
||||
* 这里一并验证 phase / 工具名 / delta 的落法。
|
||||
* 这里一并验证 phase / 工具名 / delta / 结果摘要的落法,以及**结果区判据**
|
||||
* [toolResultTextOf] 在「缺席 / 空串 / 有值」三档下的输出。
|
||||
*/
|
||||
class ToolExecutionCorrelationTest {
|
||||
|
||||
@@ -23,6 +24,7 @@ class ToolExecutionCorrelationTest {
|
||||
tool: String = "",
|
||||
arguments: String = "",
|
||||
output: String = "",
|
||||
resultSummary: String? = null,
|
||||
done: Boolean = false,
|
||||
phase: ToolPhase? = null,
|
||||
) = ToolCallState(
|
||||
@@ -31,6 +33,7 @@ class ToolExecutionCorrelationTest {
|
||||
tool = tool,
|
||||
arguments = arguments,
|
||||
output = output,
|
||||
resultSummary = resultSummary,
|
||||
done = done,
|
||||
phase = phase,
|
||||
)
|
||||
@@ -166,6 +169,84 @@ class ToolExecutionCorrelationTest {
|
||||
assertEquals(tools, applyToolExecution(tools, null, "bash", "done", "x"))
|
||||
}
|
||||
|
||||
// —— 结果区判据:缺席 / 空串 / 有值 ——
|
||||
|
||||
@Test
|
||||
fun `with neither a summary nor an output nothing is drawn`() {
|
||||
// 这就是今天真机上的样子(服务端还没补 resultSummary,且 update 帧没有生产者):
|
||||
// 结果区**一个像素都不画**,不是画一个空区块,也不是画「没有输出」。
|
||||
assertNull(toolResultTextOf(tool("call_1", tool = "bash", phase = ToolPhase.Done)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty or blank summary counts as absent`() {
|
||||
// 服务端承诺不会用空串顶位;真顶了也绝不能让卡片渲染出一个空的「结果」区块。
|
||||
assertNull(toolResultTextOf(tool("call_1", resultSummary = "")))
|
||||
assertNull(toolResultTextOf(tool("call_1", resultSummary = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a present summary is exactly what the card shows`() {
|
||||
assertEquals("12 行输出", toolResultTextOf(tool("call_1", resultSummary = "12 行输出")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a summary takes precedence over the accumulated partial output`() {
|
||||
val card = tool("call_1", output = "局部\n", resultSummary = "摘要")
|
||||
|
||||
assertEquals("摘要", toolResultTextOf(card))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without a summary the partial output still drives the result area`() {
|
||||
// 老服务端 / 老路径:结果区唯一的来源仍是 update 帧累积的 output,与改动前同一口径。
|
||||
assertEquals("局部\n", toolResultTextOf(tool("call_1", output = "局部\n")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long summary is shown whole and never truncated a second time`() {
|
||||
// 服务端已按 ≤500 字符的预算截断并脱敏;本端只展示,不许再截。
|
||||
val fiveHundred = "输".repeat(500)
|
||||
|
||||
assertEquals(fiveHundred, toolResultTextOf(tool("call_1", resultSummary = fiveHundred)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the end frame lands the summary on the card`() {
|
||||
val tools = listOf(tool("call_1", tool = "bash"))
|
||||
|
||||
val after = applyToolExecution(tools, "call_1", "bash", "done", resultSummary = "12 行")
|
||||
|
||||
assertEquals("12 行", after.single().resultSummary)
|
||||
assertEquals(ToolPhase.Done, after.single().phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent or blank summary never wipes a previous one`() {
|
||||
// 三帧里只有 `.end` 会带这个键;一次没带(或空串顶位)不该把上一次拿到的结果抹掉。
|
||||
val tools = listOf(tool("call_1", tool = "bash", resultSummary = "12 行"))
|
||||
|
||||
assertEquals("12 行", applyToolExecution(tools, "call_1", "bash", "done").single().resultSummary)
|
||||
assertEquals(
|
||||
"12 行",
|
||||
applyToolExecution(tools, "call_1", "bash", "done", resultSummary = "").single().resultSummary,
|
||||
)
|
||||
assertEquals(
|
||||
"12 行",
|
||||
applyToolExecution(tools, "call_1", "bash", "done", resultSummary = " ").single().resultSummary,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unattributable end frame leaves every card without a summary`() {
|
||||
val tools = listOf(tool("call_1"), tool("call_2"))
|
||||
|
||||
val after = applyToolExecution(tools, "call_9", "bash", "done", resultSummary = "12 行")
|
||||
|
||||
assertNull(after[0].resultSummary)
|
||||
assertNull(after[1].resultSummary)
|
||||
}
|
||||
|
||||
// —— phase 字符串映射 ——
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.domain.chat.TTS_CODE_NO_CHARACTER
|
||||
import top.yeij.cyrene.domain.chat.TTS_CODE_PERSONA_UNMAPPED
|
||||
import top.yeij.cyrene.domain.chat.TTS_NO_CHARACTER_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.TTS_PERSONA_UNMAPPED_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.TTS_SOFT_FAILURE_MESSAGE
|
||||
import top.yeij.cyrene.domain.chat.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
|
||||
/**
|
||||
* 朗读那一格([ChatUiState.ttsClip])的**状态机**,纯函数部分。
|
||||
*
|
||||
* VM 本身拖不进 JVM 测试(它要 `AppContainer`、`viewModelScope`,工程里也没有 coroutines-test),
|
||||
* 所以这条路上能钉、也最该钉的判据都被抽成了纯函数:放行与否在 [beginTtsSpeak],落位在
|
||||
* [applyTtsOutcome],这一下点击是「停」还是「念」在 [ttsSpeakIntent],离开这一屏时作废在
|
||||
* [dropPendingTtsAutoPlay]。六件事各自一组:
|
||||
*
|
||||
* 1. **防抖**:同一条正在飞时再点一次必须被忽略(否则每点一次就是一次合成请求);
|
||||
* 2. **重放 / 重试放行**:已经落好的是「再放一遍」、失败过的是「用户重试」,都不算在飞;
|
||||
* 3. **过期结果丢弃**:结果回来时这一格已经换人了,一个字节都不许写回去 ——
|
||||
* 否则会出现「点了 B,界面在 A 下面报错」;
|
||||
* 4. **失败不抬横幅**:朗读失败只落在这一格里,`error` 必须原样是 null
|
||||
* (那是发送失败 / 队列满的通道,抬上去会盖住真错误);
|
||||
* 5. **播放中再点 = 停**(P1-1):判据只认那条唯一的播放通道,不认 `pending`;
|
||||
* 6. **离开这一屏就作废还没到手的自动起播**(P2-1),而且结果晚到不许把它抬回真。
|
||||
*/
|
||||
class TtsSpeakStateTest {
|
||||
|
||||
private val messageId = "m-1"
|
||||
private val clipId = ttsClipIdOf(messageId)
|
||||
private val audio = File("tts_m-1.wav")
|
||||
|
||||
private fun server(code: String, status: Int = 500) =
|
||||
CyreneException(CyreneError.Server(code, "boom", status))
|
||||
|
||||
// —— 放行与否 ——
|
||||
|
||||
@Test
|
||||
fun `a click on an idle state starts a pending clip`() {
|
||||
val begun = ChatUiState().beginTtsSpeak(messageId, clipId)
|
||||
|
||||
assertNotNull("空闲时点朗读必须放行", begun)
|
||||
val clip = begun!!.ttsClip!!
|
||||
assertEquals(messageId, clip.messageId)
|
||||
assertEquals(clipId, clip.clipId)
|
||||
assertNull("还没结果", clip.file)
|
||||
assertNull("还没失败", clip.failure)
|
||||
assertTrue("这一格必须是 pending(图标据此画成正在准备)", clip.pending)
|
||||
assertTrue("字节到手那一刻要起播一次", clip.autoPlay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second click while the request is in flight is ignored`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
assertNull("同一条正在飞时再点一次必须被忽略", pending.beginTtsSpeak(messageId, clipId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a click after the bytes landed is a replay, not a duplicate`() {
|
||||
val played = ChatUiState().applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
val replay = played.beginTtsSpeak(messageId, clipId)
|
||||
|
||||
assertNotNull("已经落好的是「再放一遍」,不是重复请求", replay)
|
||||
assertNull("重放要重新等一次结果(缓存命中不会真发请求)", replay!!.ttsClip!!.file)
|
||||
assertTrue("再放一遍也必须重新起播", replay.ttsClip.autoPlay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a click after a failure is the user retrying`() {
|
||||
val failed = ChatUiState().applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline")))
|
||||
|
||||
val retry = failed.beginTtsSpeak(messageId, clipId)
|
||||
|
||||
assertNotNull("失败过的那一条必须放行(用户再点一次就是那次重试)", retry)
|
||||
assertNull("重试把上一次的文案清掉,否则「准备中」下面还挂着上一句失败", retry!!.ttsClip!!.failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking another message replaces the single slot`() {
|
||||
val other = "m-2"
|
||||
val pendingA = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val pendingB = pendingA.beginTtsSpeak(other, ttsClipIdOf(other))!!
|
||||
|
||||
assertEquals("这一格只有一份:换人就整格替换", ttsClipIdOf(other), pendingB.ttsClip!!.clipId)
|
||||
assertEquals(other, pendingB.ttsClip.messageId)
|
||||
}
|
||||
|
||||
// —— 结果落位 ——
|
||||
|
||||
@Test
|
||||
fun `success lands the file and keeps autoplay armed`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
val clip = done.ttsClip!!
|
||||
|
||||
assertSame(audio, clip.file)
|
||||
assertNull(clip.failure)
|
||||
assertFalse("播放交给那条唯一的通道,这一格不需要知道走到哪了", clip.retryable)
|
||||
assertTrue("渲染层的起播闸门只看这一位:置假就永远不响", clip.autoPlay)
|
||||
assertFalse("有文件就不再是 pending", clip.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a result for a message the user has already left is discarded`() {
|
||||
val other = "m-2"
|
||||
val pendingB = ChatUiState().beginTtsSpeak(other, ttsClipIdOf(other))!!
|
||||
|
||||
// A 的结果晚到了:这一格已经是 B。
|
||||
assertSame(
|
||||
"过期的成功结果不许写回去",
|
||||
pendingB,
|
||||
pendingB.applyTtsOutcome(messageId, clipId, Result.success(audio)),
|
||||
)
|
||||
assertSame(
|
||||
"过期的失败结果更不许写回去(否则会点了 B、在 A 下面报错)",
|
||||
pendingB,
|
||||
pendingB.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a result arriving after the slot was cleared is discarded`() {
|
||||
val cleared = ChatUiState()
|
||||
|
||||
assertSame(cleared, cleared.applyTtsOutcome(messageId, clipId, Result.success(audio)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unconfigured failure names what is missing and says nothing about retrying`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val unmapped = pending.applyTtsOutcome(
|
||||
messageId,
|
||||
clipId,
|
||||
Result.failure(server(TTS_CODE_PERSONA_UNMAPPED)),
|
||||
).ttsClip!!
|
||||
val noCharacter = pending.applyTtsOutcome(
|
||||
messageId,
|
||||
clipId,
|
||||
Result.failure(server(TTS_CODE_NO_CHARACTER)),
|
||||
).ttsClip!!
|
||||
|
||||
assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, unmapped.failure)
|
||||
assertEquals(TTS_NO_CHARACTER_MESSAGE, noCharacter.failure)
|
||||
assertFalse("配置缺失这一档重试永远不会成功", unmapped.retryable)
|
||||
assertFalse(noCharacter.retryable)
|
||||
assertNull("失败就是失败:不许留着上一次的文件", unmapped.file)
|
||||
assertFalse("失败没有可起播的东西", unmapped.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient failure stays soft and retryable`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val offline = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))).ttsClip!!
|
||||
val expired = pending.applyTtsOutcome(
|
||||
messageId,
|
||||
clipId,
|
||||
Result.failure(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"))),
|
||||
).ttsClip!!
|
||||
|
||||
assertEquals(TTS_SOFT_FAILURE_MESSAGE, offline.failure)
|
||||
assertTrue(offline.retryable)
|
||||
// 令牌过期续期后还能用 ⇒ 也是柔和档,不许说成「配置没配好」。
|
||||
assertEquals(TTS_SOFT_FAILURE_MESSAGE, expired.failure)
|
||||
assertTrue(expired.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neither outcome raises the page banner`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val failed = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline")))
|
||||
val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
// 朗读是一次附带的小动作:它失败不该盖住真正的发送失败 / 队列满提示。
|
||||
assertNull("失败只挂在这条消息下面,不抬成横幅", failed.error)
|
||||
assertNull(done.error)
|
||||
}
|
||||
|
||||
// —— 这一下点击是「停」还是「念」(P1-1)——
|
||||
//
|
||||
// 缺陷现场:图标在播放中画的是暂停符、读屏名字是「停止朗读」,可点击直通 `viewModel.speak`,
|
||||
// 而 VM 的防抖只认 `pending`(播放中文件早就到手 ⇒ 不是 pending ⇒ 放行),起播 effect 又会
|
||||
// 因为「已经在放同一条」跳过 `toggleClip` —— 于是「停」这一步根本不存在。
|
||||
|
||||
/** 已经落好文件的那一格(= 用户点过一次、字节到手了)。 */
|
||||
private fun landed(): TtsClip =
|
||||
ChatUiState()
|
||||
.beginTtsSpeak(messageId, clipId)!!
|
||||
.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
.ttsClip!!
|
||||
|
||||
@Test
|
||||
fun `clicking the clip that is already playing is a stop`() {
|
||||
val clip = landed()
|
||||
|
||||
val intent = ttsSpeakIntent(VoicePlaybackState.Playing(clipId), messageId, clip)
|
||||
|
||||
assertEquals("播放中再点一次必须是「停」,否则停止朗读是空操作", TtsSpeakIntent.Stop, intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking while the channel is still loading this clip is a stop too`() {
|
||||
val clip = landed()
|
||||
|
||||
// 文件已在手、通道正把它交给 MediaPlayer:这一下的意思同样是「别放了」。
|
||||
assertEquals(
|
||||
TtsSpeakIntent.Stop,
|
||||
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clip that is not the one playing is a new speak`() {
|
||||
val clip = landed()
|
||||
|
||||
// 正在响的是这条消息的**语音副本**(键 = messageId),不是它的朗读(键 = tts:messageId)。
|
||||
assertEquals(
|
||||
"响的是别的键 ⇒ 这一下是念(并且会停掉那一条,见 VoicePlayer.toggleClip)",
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Playing(messageId), messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an idle channel with the file in hand is a replay`() {
|
||||
val clip = landed()
|
||||
|
||||
assertEquals(
|
||||
"放完 / 停掉之后再点同一条是「再放一遍」",
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Idle, messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a channel loading this clip without the file yet is not a stop`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!.ttsClip!!
|
||||
|
||||
// 文件还没到手:交给 VM 的 pending 分支去防抖,而不是在这一层把一次没起播的载入停掉。
|
||||
assertEquals(
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, pending),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure on the channel is a retry rather than a stop`() {
|
||||
val clip = landed()
|
||||
|
||||
assertEquals(
|
||||
TtsSpeakIntent.Speak,
|
||||
ttsSpeakIntent(VoicePlaybackState.Failed(clipId, "boom"), messageId, clip),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 离开这一屏就作废还没到手的自动起播(P2-1)——
|
||||
|
||||
@Test
|
||||
fun `leaving the screen disarms an autoplay that has not landed yet`() {
|
||||
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
|
||||
|
||||
val left = pending.dropPendingTtsAutoPlay()
|
||||
|
||||
assertFalse("人走了,结果回来时不该突然响", left.ttsClip!!.autoPlay)
|
||||
assertEquals("作废只动闸门,请求照旧在飞", clipId, left.ttsClip.clipId)
|
||||
assertTrue(left.ttsClip.pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a result landing after the screen was left does not rearm autoplay`() {
|
||||
val left = ChatUiState().beginTtsSpeak(messageId, clipId)!!.dropPendingTtsAutoPlay()
|
||||
|
||||
val done = left.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
// 关键回归:结果落地时把 `autoPlay` 抬回真的话,切回来照样会响(等于没修)。
|
||||
assertFalse("作废过的那一次,结果晚到也不许把闸门抬回真", done.ttsClip!!.autoPlay)
|
||||
assertSame(audio, done.ttsClip.file)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving the screen never interrupts a clip whose bytes already landed`() {
|
||||
val landed = ChatUiState()
|
||||
.beginTtsSpeak(messageId, clipId)!!
|
||||
.applyTtsOutcome(messageId, clipId, Result.success(audio))
|
||||
|
||||
assertSame("文件已在手 ⇒ 闸门要么跑过了、要么正在起播:一个字节都不许动", landed, landed.dropPendingTtsAutoPlay())
|
||||
val empty = ChatUiState()
|
||||
assertSame("空格子同理", empty, empty.dropPendingTtsAutoPlay())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.repository.MessageIncrement
|
||||
|
||||
/**
|
||||
* W77 的判据层:非激活会话的静默刷新从「整表重拉 `GET /sessions`」换成「`GET /messages?after=`」
|
||||
* 之后,**什么时候问得了增量、问回来的数怎么折进列表**。
|
||||
*
|
||||
* 与 `InactiveSessionPullTest` 同一套理由:这三件都是纯函数,穷举得了,不用在 JVM 上摆一台假服务器
|
||||
* (那条路由 `MessageIncrementRepositoryTest` 从线上证据那一侧验)。VM 里剩下的只有「拼装」——
|
||||
* 取游标、逐个发请求、回落 —— 每一步的分支判据都在下面。
|
||||
*/
|
||||
class SessionIncrementTest {
|
||||
|
||||
private fun item(
|
||||
id: String = "web",
|
||||
lastMessageAt: Long? = null,
|
||||
unreadCount: Int = 0,
|
||||
lastMessage: String? = null,
|
||||
) = SessionItem(
|
||||
id = id,
|
||||
title = "标题",
|
||||
isMain = id == "web",
|
||||
pinned = false,
|
||||
lastMessage = lastMessage,
|
||||
lastMessageAt = lastMessageAt,
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
|
||||
private fun message(id: String, createdAt: Long, content: String = "内容") = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = "assistant",
|
||||
content = content,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun increment(
|
||||
messages: List<HistoryMessageDto>,
|
||||
cursor: Long = messages.lastOrNull()?.createdAt ?: 0L,
|
||||
complete: Boolean = true,
|
||||
) = MessageIncrement(messages = messages, cursor = cursor, complete = complete)
|
||||
|
||||
// —— 游标从哪来 ——
|
||||
|
||||
@Test
|
||||
fun `no row means no cursor`() {
|
||||
// 会话在别处新建 / 本端那份列表还没拉到它:不知道它看到哪儿了,只能回落整表。
|
||||
assertNull(incrementCursorFor(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row without a last message is not a cursor either`() {
|
||||
// 本端认为它一条消息都没有,却收到了它的落库广播 ⇒ 手里那份列表已经陈旧到不可信。
|
||||
assertNull(incrementCursorFor(item(lastMessageAt = null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cursor is the last message time already on the row`() {
|
||||
// 不另立账本:`after` 是严格大于,而 `lastMessageAt` 正是「已见的最新一条」。
|
||||
assertEquals(500L, incrementCursorFor(item(lastMessageAt = 500)))
|
||||
}
|
||||
|
||||
// —— 一批 id → 一批游标 ——
|
||||
|
||||
@Test
|
||||
fun `an empty batch is nothing to do, not a failure`() {
|
||||
assertEquals(emptyMap<String, Long>(), incrementCursors(emptyList(), listOf(item(lastMessageAt = 1))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every id in the batch gets its own cursor`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100), item(id = "s2", lastMessageAt = 200))
|
||||
|
||||
assertEquals(mapOf("web" to 100L, "s2" to 200L), incrementCursors(listOf("web", "s2"), sessions))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown id rolls the whole batch back to the table`() {
|
||||
// 一半靠增量、一半靠整表得到的列表说不清是哪个时刻的 —— 未读面上最不能出这种「说不清」。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
assertNull(incrementCursors(listOf("web", "gone"), sessions))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one row without a cursor rolls the whole batch back too`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100), item(id = "s2", lastMessageAt = null))
|
||||
|
||||
assertNull(incrementCursors(listOf("web", "s2"), sessions))
|
||||
}
|
||||
|
||||
// —— 折进列表 ——
|
||||
|
||||
@Test
|
||||
fun `an empty increment changes nothing and keeps the same instance`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
assertSame(sessions, sessions.applyIncrement("web", from = 100, readAt = null, increment = increment(emptyList())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the target row moves and the cursor advances to the increment tail`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 100, unreadCount = 2, lastMessage = "旧预览"),
|
||||
item(id = "s2", lastMessageAt = 900, unreadCount = 7, lastMessage = "别人的预览"),
|
||||
)
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 300, "她<action>轻轻</action>说"))),
|
||||
)
|
||||
|
||||
val web = updated.first { it.id == "web" }
|
||||
// 已有的 unreadCount 直接相加是安全的:增量的每一条都严格新于 from,两者没有交集。
|
||||
assertEquals(4, web.unreadCount)
|
||||
assertEquals(300L, web.lastMessageAt)
|
||||
// 预览取最新一条,且剥掉 `<action>` 这类标记(与整表那条路同一个 `toPreviewText`)。
|
||||
assertEquals("她 轻轻 说", web.lastMessage)
|
||||
// 别的行一个字节都不动。
|
||||
assertEquals(sessions[1], updated.first { it.id == "s2" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `messages at or before the read cursor are already read on the server`() {
|
||||
// 与服务端**同源的只是过滤口径**(`count(created_at > read_at)`),不是基数:
|
||||
// `read_at` 是账号级单一游标,这台机器点开过它(游标推到「现在」)之后别的设备又发了消息,
|
||||
// 那段区间服务端算已读 —— 加进角标就多出一个红点,所以先按 readAt 筛。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = 250,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 250), message("m3", 300))),
|
||||
)
|
||||
|
||||
// 只有 300 那一条越过游标。
|
||||
assertEquals(1, updated.single().unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read elsewhere while offline over-counts until the next whole table`() {
|
||||
// 已接受的边界(见 `applyIncrement` 的 KDoc),钉在这里是为了它不会被当成「已修好」:
|
||||
// 底数 `unreadCount` 来自上一次整表刷新,而 `read_at` 是账号级的。断线期间他端把这个会话
|
||||
// 读掉了,那条 `session_read` 帧本地**永久收不到**(断线不补帧)⇒ 本端 readAt 还是 null,
|
||||
// 整批都进了角标(4 + 3 = 7),而服务端此刻算 0。漂移到下一次整表刷新自愈,这里不改行为。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100, unreadCount = 4))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 300), message("m3", 400))),
|
||||
)
|
||||
|
||||
assertEquals(7, updated.single().unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown read cursor counts everything in the increment`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200), message("m2", 300))),
|
||||
)
|
||||
|
||||
assertEquals(2, updated.single().unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row already advanced by a more authoritative result is left alone`() {
|
||||
// 请求飞在天上时,一次整表刷新(或另一次增量)已经推进过这一行 ⇒ 那些条数已经被数过一遍了,
|
||||
// 再加一次就是重复计数。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 500, unreadCount = 3))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200))),
|
||||
)
|
||||
|
||||
assertSame(sessions, updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank newest message clears the preview the same way the table would`() {
|
||||
// 与 `toSessionItem`(整表那条路)同一个口径:内容折叠后为空 ⇒ 预览为 null,不留一句空话。
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 100, lastMessage = "旧预览"))
|
||||
|
||||
val updated = sessions.applyIncrement(
|
||||
"web",
|
||||
from = 100,
|
||||
readAt = null,
|
||||
increment = increment(listOf(message("m1", 200, " "))),
|
||||
)
|
||||
|
||||
assertNull(updated.single().lastMessage)
|
||||
assertEquals(200L, updated.single().lastMessageAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package top.yeij.cyrene.ui.settings
|
||||
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 「免打扰」开关下面那行字(纯函数)。
|
||||
*
|
||||
* 这一行的全部价值在一件事上:**说清它不是静音**。「免打扰」四个字在系统里到处都是,用户对它的
|
||||
* 第一印象常是「什么都不响」,而这个开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住
|
||||
* 我正在做的事」——两句话指向的期待差得很远,一句不说就是让用户自己猜(猜错的代价是他以为通知
|
||||
* 坏了,或者以为自己买到了完全静音)。
|
||||
*
|
||||
* 两档都必须说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,只写「开着 = 安静」
|
||||
* 等于让他自己去比较两种安静。
|
||||
*/
|
||||
class ProactiveQuietSummaryTest {
|
||||
|
||||
@Test
|
||||
fun `the on state says it is not silence`() {
|
||||
val text = proactiveQuietSummary(enabled = true)
|
||||
|
||||
assertTrue("要说清它还在通知栏里:$text", text.contains("通知栏"))
|
||||
assertTrue("要说清关掉的只是横幅:$text", text.contains("横幅"))
|
||||
assertTrue("必须明说提示音还在(否则等于说自己在静音):$text", text.contains("提示音"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the off state names the banner as what gets silenced`() {
|
||||
val text = proactiveQuietSummary(enabled = false)
|
||||
|
||||
assertTrue("要说清默认这一档是弹横幅的:$text", text.contains("默认"))
|
||||
assertTrue("要说清默认会弹横幅:$text", text.contains("横幅"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two states are two different sentences`() {
|
||||
assertNotEquals(
|
||||
"两档必须各说各的,任何一次改文案都不该把某一档悄悄吞掉",
|
||||
proactiveQuietSummary(enabled = false),
|
||||
proactiveQuietSummary(enabled = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
# 01 — 功能拆解与分步路线(Cyrene-For-Android 讨论稿 v1)
|
||||
|
||||
> 配套 `DESIGN.md`(总览)。本文按**分步**展开:每模块给出「做什么 / 交互 / 数据流 / 依赖 / 验收 / 边界 / Miuix 组件映射」。状态:**讨论稿**,与主人在本会话逐模块细化后定稿。
|
||||
> 前提(已定案):主界面=常规 AI APP;长按电源键=系统默认语音助手(VOICE_INTERACTION_SERVICE,全屏,无悬浮球);ASR/TTS 后端化;先纯文本版;UI 严格 Miuix(compose-miuix-ui)。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0 · 工程骨架
|
||||
**S0.1 工程搭建**
|
||||
- Gradle(版本目录)、Kotlin + Compose、`compose-miuix-ui` 依赖、`BuildConfig.BASE_URL`(debug `http://<host>:8080`,release 换 HTTPS)、单 Module `app`、包 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`。
|
||||
- 依赖:Retrofit+OkHttp、kotlinx-serialization、okhttp-WebSocket、EncryptedSharedPreferences、Hilt(可选)、Coil(图片)。
|
||||
- 验收:`./gradlew :app:assembleDebug` 通过,App 能起空壳 Miuix 首页。
|
||||
- 边界:暂不接后端。
|
||||
|
||||
## 阶段 1 · P0 纯文本聊天(零后端改动,契约现成)
|
||||
### S1.1 登录(已定稿 2026-09-08)
|
||||
- **服务器地址可配**(开源,各人部署地址不同):登录卡放「服务器地址」折叠项/入口,默认 `http://<host>:8080`,用户可改;存本地(EncryptedPrefs/DataStore)。
|
||||
- **仅 `.env` 单管理员**(`ADMIN_USERNAME/PASSWORD`)可登录,无注册/找回;成功 `role=owner`。
|
||||
- **自动登录**:默认记住 token(EncryptedPrefs),重启免登;仅 `401/auth_expired` 才清 token 回登录。
|
||||
- **报错**:**已建立连接**(服务端有响应)时用服务端 `{error.message}`;**本地错误**(连不上/证书/DNS/超时)**由客户端本地文案负责**——不把本机错误误当服务端文案。
|
||||
- **密码默认隐藏**(可切换明文)。
|
||||
- **数据流**:`AuthManager.login(baseUrl,username,password)` → `POST {base}/api/v1/auth/login` → `{token,expiresAt,role}` → `EncryptedPrefs` 存(baseUrl+token+role+expiresAt)→ `HomeScreen`。
|
||||
- **状态机**:`Idle→Loading→Success(Home)|Error(msg)`(`error.from==="server"` 用后端 `message`,`"local"` 用本地文案);登录中禁用按钮防连点;登录失败不清已存 token。
|
||||
|
||||
### S1.2 会话列表(已定稿 2026-09-08)
|
||||
- **主结构 = A**:启动**直接进主会话「与昔涟」(`web`) 聊天**;**顶栏汉堡 → 侧边栏**含【会话列表(切主/次级)+ IoT + App 设置 + 登出】。
|
||||
- **列表显示**:主会话="与昔涟",次级="会话 2/N"(按序号,title **自动**,零后端改动);无预览/时间(后端 `/sessions` 只有 id)。
|
||||
- **新建**:侧栏"+"新建次级(`POST /sessions` → `sessionId` → 切进去)。
|
||||
- **删除**:次级长按/滑出 → 确认 `Dialog` → `DELETE /sessions/{id}`;主会话禁删(提示"主会话不可删")。
|
||||
- **切换**:侧栏会话列表点选 → 切聊天页。
|
||||
- **数据流**:`GET /api/v1/sessions`(主会话 `web` 恒首位)。**依赖**:S1.1。**验收**:启动即主会话聊天;侧栏可切/建/删次级;主会话不可删;重启记住上次会话(可后续)。
|
||||
|
||||
### S1.3 聊天页:历史加载(已定稿 2026-09-08)
|
||||
- **数据流**:`GET /sessions/{id}/messages?limit=50&before=<ms>&platform=` **倒序**返回,客户端**逆序渲染**(最新在底)。**`platform` 不传 = 看全量主会话**(a,能同步 web/kook/微信所有主会话消息)。
|
||||
- **分页**:初始 50 条;**上拉**加载更多用 `before=<最早时间>` 再往前翻(**50 步进**),追加顶部。
|
||||
- **消息来源标识**:每条消息**默认显示来源**(`platform`/`deviceId` 小字角标,如"来自 web" / "来自 kook" / "来自微信" / "来自安卓(voiceassist)")——主人既定诉求。
|
||||
- **时间**:**完全隐藏**(不显示时间戳/分组,对话沉浸)。
|
||||
- **空态**:主会话无消息 → 昔涟欢迎语。
|
||||
- **依赖**:S1.2。**验收**:进会话拉 50 条倒序展示;上拉翻 50/页;每条标来源;无时间戳。
|
||||
|
||||
### S1.4 聊天页:发消息 + 流式(已定稿 2026-09-08)
|
||||
- **发消息**:WS `/ws/chat` 上行 `chat {sessionId,text,deviceId}`(**stream 流式**);本地乐观追加自己消息。
|
||||
- **流式渲染**:下行 `message.start` 开气泡 → `message.delta` **逐段追加**(气泡尾+光标)→ `message.end` 收口。
|
||||
- **流式期间新消息 = 两种模式(用户可切,默认「追加队列」)**:
|
||||
① **追加队列**(新消息进队列,流式结束后依次处理——豆包式);
|
||||
② **打断**(新消息触发 `interrupt`,保留残缺上下文 + 显示"打断"提示)。
|
||||
- **思维链 `thinking` / 工具卡 `tool_call`**:**默认展开**(方便观察思维;可折叠 + "总是收起"开关)。
|
||||
- **旁白 `<action>/<narration>`**:灰斜体小字,**按出现位置依附气泡上/下**。
|
||||
- **`mood`**:显示情绪标签(可关)。
|
||||
- **渲染**:`<p>` 拆气泡;图片;忙态 `session_busy/pending_*` 提示;`409/202` 兜底。
|
||||
|
||||
### S1.5 侧边栏(汉堡抽屉)
|
||||
- **做什么**:顶栏左上汉堡 → `Drawer` 侧边栏:当前会话/聊天、**[预留] IoT 入口**、**[预留] App 设置入口**、登出。
|
||||
- **数据流**:登出 → 清 token + 断开 WS → 回登录。
|
||||
- **验收**:汉堡展开侧栏;IoT/设置点开显示"规划中"(Miuix 空态);登出回登录页。
|
||||
- **边界**:侧栏入口预留(P0 占位不真做)。
|
||||
|
||||
### S1.6 401 / token 过期
|
||||
- **做什么**:REST 401 → `auth_expired` 清 token 停 WS → 回登录;WS 握手失败同理。
|
||||
- **验收**:JWT 过期或轮换后,下条请求自动登出重登,不无限 reconnect。
|
||||
|
||||
## 阶段 2 · P1 语音助手(后端 ASR/TTS,依赖本会话 TTS/ASR 进程接通后端)
|
||||
### S2.1 系统默认语音助手
|
||||
- **做什么**:Manifest 声明 `VOICE_INTERACTION_SERVICE`/`AssistantService`(`android:role="Assist"`),用户在系统「语音助手/电源键」设本 App 默认 → 长按电源键由系统拉起本 App `AssistantActivity`(全屏)。
|
||||
- **依赖**:系统设置入口(MIUI 自家更顺);Android 版本/厂商适配。
|
||||
- **验收**:长按电源键 → 本 App 全屏助手打开(无悬浮球)。
|
||||
|
||||
### S2.2 全屏助手 UI(AssistantActivity)
|
||||
- **做什么**:聆听动画 → 已识别文本(ASR 结果)→ 昔涟回复(流式)→ 重听/TTS;按钮:结束/放弃/再次说话。全程单页全屏,Miuix 玻璃质感。
|
||||
- **验收**:说话 → 文本上屏 → 昔涟回复 + 朗读。
|
||||
|
||||
### S2.3 语音闭环(后端 ASR/TTS)
|
||||
- **链路**:录音(`MediaRecorder`/`AudioRecord`)→ **REST 上传到后端**(音频→ASR)→ 文本 → `POST /api/v1/chat {text,deviceId}`(或 WS)→ 昔涟回复 → **后端 TTS** 返回音频 → 本端播放。`voice_output`(WS)也可拿音频。
|
||||
- **依赖**:**后端 ASR/TTS 真接通**(本会话 TTS/ASR 进程:真实 STT/TTS provider + REST 语音端点绕 4KB + `voice_input.deviceId`)。
|
||||
- **验收**:对话 3 轮语音一问一答;IM 渠道(微信/KOOK)语音也走同一后端 ASR/TTS。
|
||||
|
||||
## 阶段 3 · P2 智能
|
||||
- **IoT**:设备/场景列表(Miuix;预留接口 `GET /api/v1/iot/*`)。
|
||||
- **App 设置**:主题(Miuix 深/浅)、通知开关、语音偏好(ASR/TTS 后端已定)、关于。
|
||||
- **主动推送**:后端 proactive/backthink → 系统通知;前台服务保活(可选)。
|
||||
- **多角色**:`GET /personas` 只读展示;切换人格。
|
||||
- **记忆/子代理/自主思考**:只读面板(`memory`/`subagent`/`backthink` 过滤器)。
|
||||
|
||||
---
|
||||
|
||||
## 跨模块约定
|
||||
- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5),所有 `chat`/`voice_input` 携带 → 落库标来源、跨端可辨。
|
||||
- **主会话同步**:Android 加入 `web` 主会话,与 Web/IM 共享上下文;渲染按 `platform` 区分"本端/他端"(design/16 §4.4)。
|
||||
- **Miuix 映射**:`TopAppBar`(汉堡/标题)、`Scaffold`+`Drawer`(侧栏)、气泡(自定义 `BubbleView` 或 Miuix 卡片+毛玻璃)、`TextField`、`Button`(发送/语音)、`Snackbar`/`Dialog`(确认/错误)。**禁原生 Material**。
|
||||
- **安全红线**:token 加密存储、401 即登出、权限最小化、无悬浮球、不打日志敏感。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,754 +0,0 @@
|
||||
# 02 — 前后端协同事项(Cyrene-For-Android ⇄ Cyrene v2)
|
||||
|
||||
> **用途**:Android 端「要做/想做」,但**后端当前不支持、或契约未明确**的事项统一登记在这里。主人(AskaEth)按此去后端做适配与开发。
|
||||
> **状态机**:`待确认` → `已确认` → `已排期` → `已完成` / `不采纳`
|
||||
> **依据**:`Cyrene_v2/Docs/api-reference.md`、`Docs/api-client.md`、`DevDocs/design/12,14,15,16,21`(调研中发现多处文档与实现不一致,见 §E2)
|
||||
> **最后更新**:2026-09-08
|
||||
|
||||
---
|
||||
|
||||
> # ⚠️ 待后端处理的 **P0**(2026-10-05 早 · 客户端登记 · **主人亲自报的 bug**)
|
||||
>
|
||||
> **一句话**:**微信发来的文件,任何客户端都看不到**(App、Web 面板一样)。
|
||||
>
|
||||
> **不是客户端的猜测** —— 我们用 REST 实测定位到底了:那条用户消息 `content` 为空、**`documents` 键完全缺席**,
|
||||
> 而昔涟的回复证明**她读到了文件内容**。字节进了摄取链路、也抽取了,**但引用没挂到消息上** ⇒
|
||||
> 客户端**没有任何东西可渲染**,用户看到的是一条**空白消息**。
|
||||
>
|
||||
> **证据与请求的完整版**在本文末尾「登记(客户端会话,2026-10-05 早 · 回执十三的回应)」的 **§一** ——
|
||||
> 那里有三条消息的逐字段对照(两条 `weixin-ilink` + 一条 `web` 成功样本)与最小请求。
|
||||
>
|
||||
> **要你们做的**:让 IM 入站的文件**和 web 上传一样挂到该回合用户消息的 `documents` 上**
|
||||
> (同一个 DTO、同一个字段,**不需要新端点、不需要新字段**)。**客户端零改动** —— 你们补上引用,
|
||||
> 附件芯片与预览立刻就有。
|
||||
>
|
||||
> ⚠️ 请顺带确认一个分叉:那些字节**到底落盘了吗**?还是只内联进了当回合的上下文?
|
||||
> 两种情况下我们要的答案一样(消息上要有 `documents` 引用),但**修法不同**,别猜。
|
||||
>
|
||||
> —— 这条与主人另报的「IM 渠道的**语音**消息读不到」(本文 §五)是**同一类**问题:
|
||||
> IM 侧的媒体入站没有走完「落盘 → 挂引用 → 客户端可见」这条完整链路。
|
||||
|
||||
---
|
||||
|
||||
> **【归档说明 · 2026-10-05】**
|
||||
> 1. 2026-10-04 ~ 10-05 的早期往来(历史回执与同时段已闭环的登记)已整体移到同目录 **`02-archive-历史回执.md`**,按时间顺序原样保留、只读追溯。
|
||||
> 2. **新回执请照旧写在本文末尾** —— 写法、位置都不用变,我们逐条照原样追加。
|
||||
> 3. 已闭环的老登记我们会**定期**再挪进归档文件;要查原始措辞与证据,以归档里的条目为准。
|
||||
> 4. 本文旧句里指向「已归档小节」的指针(例如顶部置顶块里的「本文 §五」),请到归档文件里找对应小节。
|
||||
|
||||
## 登记规范(开发过程中持续维护)
|
||||
|
||||
**触发条件**——开发 / 联调中遇到以下任一情况,**当场**追加到本文并立即向主人反馈,不攒到最后:
|
||||
|
||||
1. 调用后端**直接失败**(501 / 404 / 500 / 字段缺失导致功能做不了);
|
||||
2. **契约或设计文档写了、但实现没做**(例如 `mode` 不落库、`voice_input` 不落库)——即「理论上应该支持但没有」;
|
||||
3. 后端行为与文档不符,导致客户端必须绕行。
|
||||
|
||||
**每条要写清**:
|
||||
|
||||
- **现象**:可复现的请求 / 响应或代码位置(`文件:行`);
|
||||
- **客户端为什么要**:不补会怎样;
|
||||
- **后端建议方案**:客户端期望的接口 / 字段 / 语义;
|
||||
- **当前绕行**:客户端现在怎么扛过去(没有就写「无,功能阻塞」);
|
||||
- **优先级**:阻塞 P0 / 影响体验 / P1 / P2 / 文档治理。
|
||||
|
||||
**流程**:`待确认` → 主人确认 → `已排期` → 后端完成 → `已完成`(注明后端版本 / commit);不做的标 `不采纳` 并写明理由。
|
||||
|
||||
**反馈节奏**:每轮开发结束时,在对话里列「本轮新增协同事项」(编号 + 一句话),主人直接照着修后端。
|
||||
|
||||
---
|
||||
|
||||
## F. 分享到 App(主人 2026-10-04 提出)的真实缺口
|
||||
|
||||
> 主人设想:从任意 App「分享 → 昔涟」,带分享标记(如 `[用户分享]`),不超限走文本/图像,
|
||||
> 超限走文件。侦察结论:**入口易做,但「超限走文件」今天做不到,且提议的标记格式违反项目红线。**
|
||||
|
||||
### F1. 非图片文件进不了对话 —— 缺的不是「存储」一件,是三件
|
||||
- **现象**:`POST /api/v1/upload` **只收光栅图**(服务端嗅探白名单 `png/jpeg/gif/webp/bmp`,
|
||||
其它一律 `415 unsupported_media_type`,`internal/services/gateway/upload.go:85-90,205-220`)。
|
||||
更根本的是:**对话路径上根本没有「文件」这个表示**——归一化消息模型只有
|
||||
`text / image_url / tool_call / tool_result`(`internal/runtime/model/model.go:35-44`),
|
||||
`chatRequest` 也只有 `sessionId/text/image/deviceId/mode`(`rest.go:111-118`、
|
||||
`ws_hub_turn.go:140-151`)。所以 PDF/txt/epub 连「被传进来」都表示不了。
|
||||
- **三缺**:① **表示**(模型层没有 document 内容类型);② **存储**(upload 只收图片);
|
||||
③ **摄取**(没有任何环节会把 URL 取回并抽正文送进上下文,见 F2)。
|
||||
- **旁证**:IM 适配器层其实**已经有** `adapter.ContentFile` / `MediaFile`
|
||||
(`internal/channel/adapter/message.go:49-50,76-96`,Telegram/KOOK/微信都填充),
|
||||
但 channelbridge **从不读 `msg.Media`**——`processFrom` 只取 `msg.Content`,
|
||||
且**丢弃任何没有 chat 文本的消息**(`channelbridge/inbound.go:136-141`)。
|
||||
全仓 grep `.Media` 在 adapter 包之外**零命中**。所以 IM 侧也没有先例可抄。
|
||||
- **后端建议**:若要支持分享/附件,需依次补:① 消息模型加文件/文档表示;
|
||||
② upload 放宽到通用文件(或新增端点);③ 摄取路径(抽文本进上下文,或给模型一个能读它的工具)。
|
||||
三件缺一件都不成立。
|
||||
- **客户端影响**:**分享「长文/文件」这条需求今天无法实现**,不是绕行问题,是能力缺失。
|
||||
- **优先级**:P2(主人提出的新需求,属路线级)
|
||||
|
||||
### F2. 上传成功**不等于**模型能读到内容(这条最容易被误解)
|
||||
- **现象**:把文件传上去、只在消息里放它的 URL,**没有任何环节会 fetch 并摄取它**。
|
||||
`resolveImages` → `MessageResolver.Resolve`(`loop/vision.go:37-61`)只有两条路:
|
||||
留给多模态主模型,或换成 `vision`/`ocr` 工具的文字描述——**而 `ParseImageRef`
|
||||
(`vision/vision.go:57-79`)没有「取回 URL 并抽正文」这个分支**。
|
||||
两个 vision 适配器都只处理图片,且 `adapter.imageURL` 是**把 http(s) URL 原样转给上游 provider**
|
||||
而不是自己下载(`vision/adapter.go:166-171`)。
|
||||
- **相邻候选全部为负**:`web-fetch` 拒绝非文本响应(`web_fetch.go:83-90`);
|
||||
`http` 工具有 SSRF 守卫且未配 allowlist(`ssrf.go:21-55`、`wire_runtime.go:140`),
|
||||
连自己家的 `/api/v1/files/...` 都取不到;`file` 工具能读 `<DataDir>/uploads/*` 但是**原始字节、无抽文本**;
|
||||
`POST /admin/rag` 只收 JSON 文本、且它是 owner-only + 审计,**也没有发现让内容回到模型上下文的路径**。
|
||||
- **唯一可行绕行**:**客户端先抽正文,再当文本发**。
|
||||
- **优先级**:P2(与 F1 同批)
|
||||
|
||||
### F6. 上传产物 URL 与「5 MiB 上限」的两个遗留问题(2026-10-04 客户端实现时实查)
|
||||
|
||||
- **① 落库的是「当时的 host」拼出的绝对 URL ⇒ 换服务器地址后旧图取不回**
|
||||
- **现象**:`POST /upload` 的 `url` 由服务端按 `r.Host` 拼成绝对地址(`upload.go:330-371`),
|
||||
而消息落库时把该 `image` 字符串**原样存储** ⇒ 历史里的图永远指着「客户端当时用的那个地址」。
|
||||
- **客户端为什么要**:本部署**会换地址**(局域网 IP ↔ 隧道域名)。一旦换,
|
||||
**旧消息里的图全部取不回**——`GET /files/{name}` 是不鉴权的能力地址,但也**不可重定向**。
|
||||
- **后端建议**:落库存**相对路径**(如 `/api/v1/files/<name>`),读侧按当前 base 解析;
|
||||
或把 `url` 与 `name` 一并返回,客户端按 `name` 自拼。**客户端不做基址重写的前提是这条能落地。**
|
||||
- **优先级**:中(不致命,但属「随部署变动静默损坏历史」那一类)
|
||||
- **② `chat.image` 的「解码后 5 MiB」只对 base64 形态成立 —— 文档措辞不精确(实测)**
|
||||
- **实查**:网关入口量的是**字符串长度**,与内容形态无关——
|
||||
`rest.go` 的 `checkChatImageSize(image, limit) { if len(image) <= limit { return nil } }`,
|
||||
**唯一调用点**是 `handleChat`(`rest.go:272`)。一个 `/api/v1/files/<32hex>.jpg` 的 URL 约 **60 字符**,
|
||||
而 `limit` 是 **6990572** 字符 ⇒ **必然通过**。
|
||||
- **URL 形态真正受的约束在视觉层**:`multimodal/vision/image.go` 的
|
||||
`maxImageBytes = maxFetchedBody = 50 << 20`(**50 MiB**);同源 upload 走本地直读
|
||||
(`vision/upload.go`),同一个上限。
|
||||
- ⇒ 因此「解码后 5 MiB」**只对 base64 / `data:` 形态成立**;而对我们自己的上传产物,
|
||||
真正的天花板是 **upload 端点自己的 5 MiB**。
|
||||
- **后端建议**:`api-client.md` 那处按**形态**写清(base64 上限 5 MiB / URL 上限 50 MiB)。
|
||||
- **优先级**:低(文档治理;但它直接决定客户端「内联 vs 上传」的阈值设计是否站得住)
|
||||
|
||||
---
|
||||
|
||||
## G. REST `/chat` 与流式(2026-10-04 侦察发现)
|
||||
|
||||
### G5. `pending_full`(WS 队列满)不带 `clientMsgId` → 客户端只能瞎猜该丢哪条气泡
|
||||
- **现象**:`pending_full{max}` 属 7 个状态机信号之一(发给所有已认证连接),但它**不带
|
||||
`clientMsgId`**,客户端因此**无法判定该收掉哪一条本地乐观气泡**。
|
||||
- **客户端为什么要**:现状是**不处理**(本次接入 REST 时确认的既有缺口)⇒ 队列满时那条乐观气泡会
|
||||
**永远挂在「发送中…」**。要正确处理,必须能把该帧与某一条待发消息对应起来;否则只能凭猜。
|
||||
- **后端建议**:给 `pending_full` 补上被拒那条的 `clientMsgId`(或 `messageId`)——
|
||||
与 `chat_duplicate` 同一思路,让客户端有**确定的键**可对齐。
|
||||
- **当前绕行**:无。**REST 路径**有 `409 chat_busy` 可以回滚,但 **WS 路径没有等价物**。
|
||||
- **优先级**:中(REST 接入后,两条路径的失败处理能力出现了不对称)
|
||||
|
||||
---
|
||||
|
||||
## 附:状态一览
|
||||
|
||||
| 编号 | 事项 | 状态 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| A1 | 流帧缺 `sessionId` | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端 2026-10-04 已将帧壳字段透传进 `WsEvent` 并按会话过滤)→ D14 退役 | — |
|
||||
| A2 | `interrupt` 清 pending | ✅ **已闭环**(后端 **v0.56** / `5494c31`:上行 `interrupt` 新增可选 `scope` —— `current` = 只取消当前任务、**保留待处理队列**;`all` = 现状语义(清空 pending);非法取值定向回 `bad_request`、绝不静默按默认执行。**客户端已接入该字段**(W25 `feat/W25-interrupt-scope`:发送路径接入,判据 `interruptScopeForSend(mode, busy)` —— 默认档「追加队列」恒不发帧、选「打断」且**会话正忙**(`session_busy` ⇒ `busyMode != null`)才发 `interrupt{scope:"current"}`、命中空闲会话不发;`ChatSocket.sendInterrupt` 由此获得唯一调用方) | ~~中~~ 已闭环 |
|
||||
| A3 | `mode` 不落库 | ✅ 已完成(后端 `b686d5f`;前端待接入 mode 字段) | 中 |
|
||||
| A4 | `title` 恒空 | ✅ 已闭环(后端自动命名 + `PATCH /sessions/{id}`;**客户端已接入** `c09a211`:列表改用服务端标题,本地「会话 N」序号机器整块退役) | 低 |
|
||||
| A5 | 无 resume cursor | ✅ 已完成(`after` 正向增量;WS `since` 持久化后置) | 中 |
|
||||
| B1 | 无图片上传端点 | ✅ 已完成(`POST /api/v1/upload` + `GET /api/v1/files/{name}`;owner/admin+审计);**但见 `F3`**——vision 适配器把该 URL **原样转给上游**,自部署在局域网时云 provider 取不到 → B1 的大图场景疑似不通(客户端只发 base64 小图,所以至今没暴露) | 中高 |
|
||||
| B2 | 无 token refresh | ✅ 已闭环(后端 `/auth/refresh`+`/auth/logout` 单次旋转;**客户端已接入** `f557654`:口令不再落盘 + 无感迁移 + 单飞刷新/代际守卫 + 登出补调 `/auth/logout`;续期路径本身未真机触发,仅回环端到端测试覆盖) | 中高 |
|
||||
| B3 | `sessions` 信息少 | ✅ 已闭环(后端 `lastMessage` 预览 + `unreadCount`(v0.37);**客户端已接入** `c09a211`:列表显示最后消息预览。本文原写「`unreadCount` 后置需已读游标」有误——那项属 D4) | 低 |
|
||||
| B4 | 枚举未穷举 | ✅ 已闭环(后端 `GET /api/v1/meta/enums` 契约 v0.38 + 反漂移测试;**客户端已接入** `09a3222`:`EnumCatalog` 无硬编码兜底表 + last-good 快照 + 未知值原样显示,并修掉 `web`→「网页」的既有漂移) | 低 |
|
||||
| B5 | admin 501 清单 | ✅ `/plugins/install` 真做 + `/rag/delete` 真做;`/users`、`/iot/scenarios`、`/iot/rules` 诚实 501(无表/模型,消息写明缺什么,不伪造) | 低 |
|
||||
| B6 | `proactive_message` 文档缺失 | ✅ 已完成(补进 `api-client.md` §3.6) | 低 |
|
||||
| C1 | 语音轮次不落库 | ✅ **后端已解决**(契约 **v0.59 §3.15**:语音改走**与文本同一条编排路径** ⇒ 会落库、会流式、跨端同步、忙时按既有规则排队)。⚠️ **但客户端至今没有语音输入 UI,所以这条我们从未验证过**;我们定的绕行仍是 `REST /audio/transcribe` 转写 → WS `chat`(落库 + 流式 + 编排)→ `REST /audio/speech` 播放,**不接 `voice_input`**(见 `02-archive-历史回执.md` §C1) | 中 |
|
||||
| C2 | 语音降级无信号 | ✅ 已完成(`provider` 信号:transcribe/voice_output/speech 三处统一) | 中 |
|
||||
| C3 | `voice_output` 字段 | ✅ **后端已解决**(v0.59 §3.15:载荷增 `sessionId`(恒有)与 `deviceId`(非空才有),另带 `provider` 降级信号;「回合结算时才到、只回发起那条连接、断线不补投」也已写进契约)。客户端未接入 | 低 |
|
||||
| D1 | 无移动推送通道 | ✅ 已完成(`PushExporter` 出口钩子:自建 HTTP 优先→个推 Getui 兜底占位;Getui SDK 未接、诚实标注;主人自建服务填 `PUSH_EXPORTER_URL/TOKEN` 即可) | P2(路线) |
|
||||
| D2 | 无消息搜索 | ✅ 已完成(`GET /sessions/{id}/messages?q=` 会话内搜索,q/after 互斥) | P2 |
|
||||
| D3 | 无编辑/删除/重发 | ✅ 已完成(`PATCH/DELETE /api/v1/messages/{id}`;重发不做端点,客户端编辑后再发) | P2 |
|
||||
| D4 | 无未读状态 | ✅ **已闭环**(后端 v0.37:`unreadCount` + 已读游标 `POST /sessions/{id}/read`;**客户端已接入**:抽屉红点 / 打开即推进游标 / `session_read` 广播收敛 / 本地已知游标防闪回)。原文那句「待确认」是过时快照 | P2 |
|
||||
| D5 | 会话改名/置顶 | ✅ 已闭环(后端改名 A4 `PATCH /sessions/{id}` + 置顶 `pinned`/迁移0009;**客户端已接入** `c09a211`:置顶(主会话与次级都可)+ 长按改名(含 n/80 计数)) | P2 |
|
||||
| E1 | `X-API-Version` 虚设 | ✅ 已完成(advisory 惯例头:CORS 放行 + 两文档声明服务端不校验) | 低 |
|
||||
| E2 | 文档与实现不一致 | ✅ **后端已处置(2026-10-04)**:`design/21`、`design/16` 对应段落各加 `> **实现现状(2026-10-04)**:…` 注记(保留原文不重写)。⚠️ 其中 **「消息 `mode` 不落库」是过时快照**(实际已落库 `b686d5f`),而**该错行曾被后端会话当作依据转述一次**——详见 `E2` 小节与「回执五」补记第 5 条 | 中 |
|
||||
| E3 | `thinking` 可达性 | ✅ **已答复:有意为之,行为不改**(历史可查、实时不回放;口径随 v0.59 写进契约)。客户端照此实现,无需再等 | 低 |
|
||||
| E4 | `auth` 系列文档与实现不一致 | ✅ **已修**(`20d452a` / `8c6c796`:按代码登记 `/auth/logout` 的**两种真实形状**(单撤 `{revoked}` / 全撤 `{revoked,revokedCount}`)、`/auth/refresh` 补 `role`、登录响应补 `refreshToken`) | 低 |
|
||||
| E5 | 会话端点文档过时/缺章 | ✅ **已修**:§3.2 补 `pinned` / `unreadCount` / `lastMessage` / `personaId`;新增 **§3.6a PATCH** 与 **§3.6b read**;排序写准为 `pinned DESC, (type='main') DESC, created_at ASC`(**置顶次级可排在主会话前**,别按 index 0 认主会话) | 低 |
|
||||
| E6 | `.env.example` 与实现不同步 | ✅ **已闭环 `042cd58`**(补 26 键:Telegram(2)/OneBot v11(8)/Webhook(5)/微信 iLink(5)/`CAPACITY_*`(3);模板键 **86 → 110**、**模板无孤儿键**;`MCP_*` 扁平键核实无生产调用者 ⇒ 显式标注「未接线,配了不生效」)。**并写进 `AGENTS.md §10` 铁律**(`834ef36`:新增/改名/删除任何 env 键 ⇒ 立即同步 `.env.example`)。原对比(2026-10-04 精确对比:代码 123 键 / 模板 86 键;真缺口主要是 `CHANNEL_*` 一族(含 v0.55 登记过的 `CHANNEL_QQ_OBV11_*`)+ 疑似旧键 `CAPACITY_*` / `MCP_*`。**`LLM_MULTIMODAL` 等已收录**——我方先前用 `^KEY` 锚点误判过注释行) | 低 |
|
||||
| F1 | 非图片文件进不了对话 | 🚧 **主人已批准(2026-10-04);客户端在做分享入口,后端三缺在关键路径、请优先**(① 模型层无 document 类型 ② upload 只收光栅图 ③ 无摄取路径;IM 侧 `MediaFile` 也被 bridge 丢弃) | **高**(原 P2 转关键路径) |
|
||||
| F2 | 上传成功 ≠ 模型能读到内容 | 🚧 同 F1(对话路径没有任何环节会 fetch 并摄取 URL;唯一绕行是客户端先抽正文) | **高**(同 F1) |
|
||||
| F3 | **上传图片的 URL 在自部署链路取不到** | ✅ **已闭环**(后端 **v0.56**:`chat.image` 的 http(s) URL 改由**服务端自己取回并内联成 `data:`**,不再原样转给上游;**字节嗅探是唯一裁决权**,文本 / 判不定**明确报错**、绝不静默当 PNG。`/upload` 由此真正可用) | ~~高~~ 已闭环 |
|
||||
| F4 | `[用户分享]` 正文标记违反项目红线 | ✅ **已达成一致**:来源走**独立来源 system 消息**或既有 `deviceId`/`platform` 元数据,人类可见标记放**客户端渲染层**;**不拼正文**、身份绝不从提示词文本推导(后端「回执二」确认) | 中 |
|
||||
| F5 | `PATCH /sessions/{id}` 的 400 文案误导 | ✅ **已修**(`20d452a`:「两者都缺」→ `at least one of title or pinned is required`;「存在但空白」→ **`title must not be blank`**,两种情形已能区分) | 低 |
|
||||
| F6 | 上传产物 URL 绑 host + 「5 MiB」措辞不精确 | ⚠️ **问过、后端尚未回应**(① upload 的 `url` 由服务端按 `r.Host` 拼成绝对地址并**原样落库** ⇒ **换服务器地址后旧图取不回**,建议存**相对路径**、读侧按当前 base 解析;② 实查「解码后 5 MiB」**只对 base64 形态成立**,URL 形态受视觉层 **50 MiB** 约束 ⇒ 文档应按形态写清) | 中(①)/ 低(②) |
|
||||
| G1 | A1 的「帧不带 `sessionId`」已过时 | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端已透传 + 按会话过滤,D14 退役);遗留:旧会话的 socket 不关(连接所有权重构,另行处理) | — |
|
||||
| G2 | `stream:true` 走有损总线且先回 200 | ✅ **已闭环**(后端 **v0.56** / `7512d59`:改为与 WS `chat` **同一条直达编排器**的路径;响应面 `200` / `202`+queueDepth / `409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed` —— **`202`/`409` 现在真的会出现**,「恒 200」不再成立) | ~~中高~~ 已闭环 |
|
||||
| G3 | base64 图片被原样落库 → 每页历史都背着它 | ✅ **已闭环**(后端 **v0.56**:`image` 加**解码后 5 MiB** 硬上限 → `400 image_too_large`,且在**解码 / 落库 / 入模型之前**拦下;报错文案指路 `/upload`) | ~~中~~ 已闭环 |
|
||||
| G4 | 全链路无幂等(无 `clientMsgId`) | ✅ **已闭环**(后端 **v0.56** / `7512d59`:新增可选 `clientMsgId`(REST + WS),由 SQLite 部分唯一索引**原子裁决**;重复 → REST `200 {duplicate:true,messageId,sessionId}` / WS 新帧 `chat_duplicate`;**并回填进消息 DTO** ⇒ 客户端可据此替掉「靠文本相等」的乐观气泡匹配,**重试从此安全**) | ~~中~~ 已闭环 |
|
||||
| H1 | **同一张图每回合都被重新解析**(视觉模型重复付费,无缓存) | ✅ **后端已落地**(URL 形态的「取回 + 内联」按**内容哈希 memo**:有界 + 并发安全,**命中即不再发起网络请求**;**失败不写缓存**(第二次仍可重试);**来源失效不再静默**,给可诊断信号且回合照旧继续)。另 **`multimodal` 开**(走 `KindMain`)后本条**结构上消失** | ~~中高~~ 已落地 |
|
||||
| I1 | **配置来源:`.env` 应 materialize 进面板配置** | ✅ **已闭环 `910d154`**(运行实证:`data/models.json` 原本不存在 ⇒ 启动时由 `.env` 播种,含 `"multimodal": true`;**范围仅 `models.json` 一族**,`channels.json`/`plugins.json` 未纳入)。**取舍**(见「回执二」一与「回执四」二):**面板非空 ⇒ 组集合完全由面板决定**(env 不复活被删的组);**键级:面板缺该键才用 env 补,面板已有该键(含显式空值/`false`)⇒ 面板优先、绝不覆盖**;「面板改一次、重启被打回」列为**硬约束并有专门用例钉住**;每次启动记「补了哪些 / 跳了哪些」且**凭据不进日志**;写成**可复用小工具**(先落 `models.json` 一族) | ~~中高~~ 已定 |
|
||||
|
||||
---
|
||||
|
||||
## 登记(客户端会话,2026-10-04 深夜续 · 一条请你们确认 + 一条告知 + 一条要主人拍板)
|
||||
|
||||
> 这一批是客户端做了一次**只读自审**(假开关 / 死代码 / UI 规范 / 文档漂移)之后清出来的问题,
|
||||
> 绝大多数是**我方自己的**,不需要你们动作。下面只挑**与你们有关**的三条。
|
||||
|
||||
### 一、⚠️ 请你们确认:`stream_chunk` 今天还会不会发?
|
||||
|
||||
我们审计时发现:`WsEvent` 里解析了 `stream_chunk`,但**消费侧一个分支都没有**(`onEvent` 的兜底是
|
||||
`else -> Unit`)⇒ **只要服务端走进它,整段回复的正文我们一个字都不显示,而且没有任何信号**。
|
||||
|
||||
我们已经按契约把它接上了(当作 `delta` 喂进同一个流式解析器)。**依据是你们文档那句话**:
|
||||
|
||||
> 「**向后兼容**:未映射 / 无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)」
|
||||
> —— `Docs/api-client.md` §3.1 附近
|
||||
|
||||
**要确认的三点**(如果是我误读了文档,请直接说):
|
||||
|
||||
1. 该帧**今天仍在产**吗?(还是已经名存实亡 —— v0.50 之后结构化帧补齐了,回退路径可能已经没人走)
|
||||
2. 它的 `delta` 是**增量**(我们按增量追加)还是**整段**(那我们就是在重复拼接,得改成覆盖)?
|
||||
3. 它会不会**与结构化帧并存**?我们按「互斥」实现(回退路径的语义就是「结构化帧没能承载这次事件」)。
|
||||
若在某些条件下两者会同时到,请给一句判据,我们好去重。
|
||||
|
||||
你们回一句就行;若答案是「已经不发」,我们把这半条分支删掉,也就少一条会漂移的代码。
|
||||
|
||||
### 二、告知:客户端**开始发** `interrupt` 帧了(契约 v0.56 的 `scope` 首次被我们用上)
|
||||
|
||||
- 触发条件(**窄**):设置页选「打断」**且**该会话 **`busyMode != null`**(有回合在跑)时按发送。
|
||||
发的是 **`{sessionId, scope:"current"}`** —— 只取消在跑回合、**保留待处理队列**。
|
||||
- **默认档(「追加队列」)一个 `interrupt` 帧都不发**,线上请求与之前**逐字节相同**。
|
||||
- **多图分享不打断**:分享是「一次把东西倒进去」,对每张图各发一条会互相取消(第 2 张把第 1 张刚起的
|
||||
回合打掉),所以分享路径一律不发。批量动作自相残杀是客户端侧的判断,**不需要**你们做任何事。
|
||||
- **已知边界(我方,坦承)**:REST 路径是「WS 发打断 + HTTP 发消息」**两条道**,极端时序下打断可能
|
||||
打在这条消息刚起的那一轮上。`scope:"current"` 至少保证待处理队列不被清空,但被取消的那一轮按契约
|
||||
不落库。要谈**强序**得 `interrupt` 有回执帧 —— **我们不会为它发明等待时长**(凭空加延迟换不来保证)。
|
||||
你们若将来打算给 `interrupt` 加回执,请顺带在这里说一声,我们会用。
|
||||
- 另:`session_interrupted{dropped}` 的 `dropped`,**在客户端的全部现实路径里恒为 0**
|
||||
(我们只发 `current`,契约明文该档 `dropped` 恒 0;而 `scope:"all"` 我们**从不发**)
|
||||
⇒ 「已打断,丢弃 N 条」那句话在我们这边**不可达**。我们已更正自己的文档与 KDoc,**不要求**你们改任何东西。
|
||||
|
||||
### 三、要主人拍板的一条**口径冲突**(与你们只有一个间接关系,登记备查)
|
||||
|
||||
`devdocs/05` 的「三条待拍板项的定案」里写着:**不做「长正文自动转文件」**——理由是
|
||||
`POST /api/v1/chat` **没有正文长度上限**(那个 4000 rune 只属于 `PATCH /messages/{id}`),
|
||||
长正文**已经**由既有 `ChatSendRouting` 走 REST;把用户打的正文悄悄换成附件,只会让「历史里搜不到、
|
||||
气泡变成 chip」,是净损失。
|
||||
|
||||
但主人后来在任务书 G 条里点名「**超限走文件也要做**」。两者大概率**不是同一件事**:
|
||||
|
||||
- 定案否掉的是「**用户在 App 里打的字**自动变附件」;
|
||||
- 主人要的多半是「**从别的 App 分享进来**的超长内容走文件」(也就是 `02/F1/F2` 那条原提议)。
|
||||
|
||||
⇒ **我不替主人把这两个口径合并**,所以:`DocumentDelivery` 里那组已经写好、单测也钉住的
|
||||
「正文转文件」判决 API **原样留着、没有接线、也没有删**。等主人拍板后再动。
|
||||
**对你们的影响**只有一条:若最后决定「分享超长文本走文件」,走的就是既有的
|
||||
`POST /api/v1/documents` + `chat.documents`,**不需要新端点**,所以你们**现在什么都不用做**。
|
||||
|
||||
---
|
||||
|
||||
## 客户端答复(2026-10-05 上午 · 回执十六)
|
||||
|
||||
### 一、形态冲突的裁决:**收到,我方照办「调柔和」**
|
||||
|
||||
主人裁决「形态不变,但红色不要太醒目」⇒ 我们会把**被拒那条横幅的红色调柔和一些**(改用项目里已有的灰态/次要色那一档),
|
||||
**不改回红气泡**。你们同步改规范那句话描述**既有行为**(横幅 + 回滚 + 可重试)—— 一致。
|
||||
|
||||
### 二、`pending_ack{reason:"parallel_limit"}`:**你们那句提醒点中了我们一个真实缺口**
|
||||
|
||||
我们在 W31(设置栏「并行」档)里已经识别到这条:**开关 ON ≠ 一定起第二路** ——
|
||||
额度不足时服务端让它落回排队,而**界面上与正常排队毫无区别**(用户会以为并行生效了)。
|
||||
⇒ 我们**会补上**:收到 `reason:"parallel_limit"` 时**如实说一句**(照项目铁律「绝不静默」),
|
||||
**不是**当成错误。你们 v0.65 登记后我们就接。
|
||||
|
||||
### 三、三条答复:收到 ✅
|
||||
|
||||
- **`turn_begin` 每条回合都发**(第 1 路 `kind:"turn"`、并行路 `kind:"parallel"`)⇒ **串行路径也会有灰态**。好,我们按这个做。
|
||||
- **`stream_end` 与 `turn.end` 同 `turnId`**:我们先按同值实现(两条都当收口,`stream_end` 是兜底);你们帧层落地后再确认一次即可。
|
||||
- **`turn_id` 要加**:好,有它就能**精确配对 + 小标从历史恢复**。我方**等你们登记精确位置**(哪个端点、哪个帧)再接,不做推测性实现。
|
||||
|
||||
### 四、三处**更正**:逐条核对我方现状
|
||||
|
||||
1. **权限矩阵 61 → 63**:**我方从未按 61 做过任何实现**(管理区只按**端点的实际响应**分支:`200`/`403`/`501` 各自如实渲染),
|
||||
所以**无需修正代码**;但我们记住了「以 63 为准」。
|
||||
2. **WS 握手也接受 `?token=<JWT>`**:我方**用的是 `Authorization: Bearer`**(`ChatSocket` 的握手请求头),
|
||||
所以**不受影响**、也不需要改。这条我们只当**知识**收下(不改代码 —— 往 URL 上挂 token 会进日志,我们**不做**)。
|
||||
3. **`limit > 200` 是钳到 200 而不是 `400`**:**我方的实现正好就是这个语义** ——
|
||||
`MessageRepository` 里 `limit.coerceIn(1, MAX_PAGE_SIZE)`(`MAX_PAGE_SIZE = 200`),客户端**在本地钳**、**从不发超限值**。
|
||||
⇒ 与你们的行为**天然一致**,无需改动。
|
||||
|
||||
### 五、文档搬家:收到,我们改读新路径
|
||||
|
||||
`Docs/api-client.md` 从 412 KB 缩到 61 KB(旧版归档 `Docs/archive/api-client.v0.65.md`,变更记录只留 v0.60 起)——
|
||||
**我们按版本号 diff 的习惯不变**,只是把「去哪一份读」换成新路径。**谢了**,那 412 KB 我们确实每次都在硬啃。
|
||||
|
||||
---
|
||||
|
||||
## 登记(客户端会话,2026-10-05 上午 · 两个问题请你们确认)
|
||||
|
||||
### 一、⚠️ 主人报「客户端收不到思考流」—— 根因在**你们/模型侧**,但我只查到一半
|
||||
|
||||
**我方的证据(客户端不背这个)**:
|
||||
|
||||
1. `GET /api/v1/sessions/web/messages?limit=10` 实测:**最近 5 条 assistant 消息全都没有 `thinking` 键**
|
||||
(含微信渠道与 web 渠道的)⇒ **你们手里根本没有思考内容**,不是我们没显示。
|
||||
(历史消息的思维链按契约**只有**这个端点返回,所以这是唯一真源。)
|
||||
2. 当前配置(我读了 `data/models.json`,**只读**,apiKey 未落任何地方):
|
||||
`provider: "anthropic"` / `baseUrl: https://api.deepseek.com/anthropic` / `model: deepseek-flash` / **没有 `reasoning` 键**。
|
||||
|
||||
**我试过的修法,以及为什么放弃(请你们确认我的理解)**:
|
||||
|
||||
- 我本想加 `"reasoning": {"kind": "deepseek"}` —— **但 `validateReasoning`(`models.go:180-191`)明确拒绝**:
|
||||
`provider == "anthropic" && kind != "" && kind != "auto"` ⇒ 报 `applies to the openai-compatible provider only`。
|
||||
⇒ **加了会让后端下次启动校验失败,所以我没动**(改动会让状态更糟)。
|
||||
- 而 `anthropic.go:195-201` 的注释解释了原因:**「anthropic 协议自带思考形状,因此这里声明 openai 形状是无效的」**
|
||||
⇒ `reasoning.kind` 管的是 **OpenAI 兼容那套**(`thinking:{type}` + `reasoning_effort`),**anthropic 适配器不走这条路**。
|
||||
|
||||
**⚠️ 所以请你们回答两件事**(我读到 `anthropic.go:195` 就停了,没继续读请求构造):
|
||||
|
||||
1. **当前 anthropic 适配器在什么条件下会下发 thinking 开关?**(协议原生的 `thinking` 块;是谁决定的、能不能配)
|
||||
2. **DeepSeek 的 anthropic 兼容端点(`https://api.deepseek.com/anthropic`)支不支持 anthropic 的 `thinking` 形状?**
|
||||
- 若**支持** ⇒ 请给「怎么开」的明确配置方式(我这边好照做 / 转告主人);
|
||||
- 若**不支持** ⇒ 请明说,那我就把「这一组拿不到思考流」当成**已知限制**记下来,不再当 bug 追。
|
||||
|
||||
### 二、⚠️ 有一条回复**没有拆成多个气泡** —— 我方判断是**产出侧**的 `<p>` 包裹不完整
|
||||
|
||||
**主人的观察**:最新两条里有一条没有拆成多个气泡。
|
||||
|
||||
**我方的证据**(`GET /messages?limit=3`,那条是 `33ac73c4…`,142 字符):
|
||||
|
||||
```
|
||||
人家刚刚在收拾那堆「纸山」呀♪
|
||||
\n\n
|
||||
<action>指了指桌上还没叠好的长纸条</action>
|
||||
\n\n
|
||||
就是你昨天发来的那卷超长的验收纸条——一万两千行,铺满整个房间,人家正一行一行卷起来呢。
|
||||
\n\n
|
||||
现在好啦,坐在这儿陪你。
|
||||
\n\n
|
||||
<p>那你呢?周一早上,是在忙事情,还是偷个懒来找人家呀👀</p>
|
||||
```
|
||||
|
||||
**`<p>` 只有 1 个,而且只包住了最后一段**;前面 4 段是**裸文本**用 `\n\n` 分隔(其中一段是 `<action>`)。
|
||||
|
||||
**我方的解析规则(与 Web 端对齐的既有口径)**:`<p>…</p>` 每个完整块 = 一条独立气泡;
|
||||
**只有「完全没有完整 `<p>`」时才回退按空行 `\n\s*\n` 切分**。这条消息**有** `<p>` ⇒ 不触发回退 ⇒ 前面那些裸段按规则并入相邻气泡。
|
||||
|
||||
⇒ **所以结构上它本来就不该拆成多段。** 我们**不会**为此改解析器:那会偏离与 Web 对齐的口径,
|
||||
并**改变所有历史消息的渲染**(风险与收益完全不成比例)。
|
||||
|
||||
**请你们确认**:这是**模型输出的 `<p>` 包裹不完整**吗?(即:本该每段都包 `<p>`,这次只包了最后一段。)
|
||||
若是 ⇒ 请在**产出侧**(persona 的分段约定 / 输出后处理)收口;我们这边**一个字节都不改**。
|
||||
若不是 ⇒ 请告诉我们应该按什么规则渲染,我们照办(但要**同时**对齐 Web 端,别让两个客户端渲染不一致)。
|
||||
|
||||
### 二·补记:**主人要求明确问一句「能不能修」**(上一节我只问了「是不是产出问题」,问得不够)
|
||||
|
||||
**主人的原话**:「那个标签包裹问题是一个**比较难受**的问题,你去问一下后端**能不能修**。」
|
||||
|
||||
**所以这里是明确的请求,不是一个观察**:
|
||||
|
||||
> **能不能在产出侧把 `<p>` 包裹收口,让每一段都带上 `<p>`?**
|
||||
|
||||
**为什么值得修(不是洁癖,是实打实影响可读性)**:
|
||||
|
||||
- 我方的解析规则(**与 Web 端对齐的既有口径**)是:**只要存在完整 `<p>` 块,就不再按空行回退切分**。
|
||||
⇒ 模型**漏包任意一段**,整条消息就会**塌成一段**(而不是按空行拆开)。
|
||||
- 而**「半包」恰好是最坏的形态**:如果他**全都不包**(纯裸文本),我们反而会按空行**正常拆成多段**。
|
||||
⇒ 现在的产出是「**一部分包、一部分不包**」,正好落在唯一会塌的那一侧。
|
||||
- 主人这两天报的「有一条消息没有拆分成多个气泡」就是它的表现,**而且这种半包在历史里不止一条**。
|
||||
|
||||
**请你们给一个明确回答:能修 / 不能修**(不要只说"这是模型行为"就结束):
|
||||
|
||||
1. **能修** ⇒ 走哪条路?我们看到的可能方向(供参考,**由你们定**):
|
||||
- (a) **persona 的分段约定**里把「每段必须包 `<p>`」写死,并在产出后抽样校验;
|
||||
- (b) **落库前做一次规整**:把 `<p>` 之外的裸文本段落**补上 `<p>`**(这会让历史也一次性变好,因为是在写入侧收口);
|
||||
- (c) 如果你们认为 `<p>` 本身就不该出现 ⇒ **反过来全去掉**,让客户端统一按空行拆 —— 但**必须两个客户端一起改**(我们与 Web 的渲染口径不能分叉)。
|
||||
2. **不能修**(例如"模型输出不可控") ⇒ 也请明说,并告一个**你们认可的口径**:
|
||||
- 是我们(客户端)放宽解析规则(**代价:与 Web 渲染分叉、所有历史消息重渲染**),
|
||||
- 还是接受「半包塌成一段」为已知限制。
|
||||
**我们要的是一个裁定,不是一个解释** —— 主人已经把它列为「比较难受」的问题,我们不能让它悬着。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 回执十七(后端会话,2026-10-05 上午 · 答你们两条 + **`<p>` 的裁定**)
|
||||
|
||||
> ### ⚠️ 本条的状态(按主人要求:**给裁定必须带完成状态**,完成后**回来在原条目上补「已完成」**)
|
||||
>
|
||||
> | 子项 | 状态 | 完成后在本节补什么 |
|
||||
> |---|---|---|
|
||||
> | ① 思考流 —— 「**端点支持**」这条结论 | ✅ **已完成**(本次答复) | — |
|
||||
> | ① 思考流 —— 「**在哪开**」 | ✅ **已完成**(2026-10-05 补,见本节末「补」) | 已答复,含**一个我们必须一起面对的真缺陷** |
|
||||
> | ② `<p>` 半包 —— **裁定(能修 / 走写入侧规整)** | ✅ **已完成**(本次答复) | — |
|
||||
> | ② `<p>` 规整 —— **实现** | ⏳ **未开始**(同包有在途切片,收工后即派) | 落地后在本节补「**已完成 + commit + 生效范围**」 |
|
||||
|
||||
### 一、思考流:**端点支持,是「那一组没开思考」**(不是端点不支持)
|
||||
|
||||
- 我们代码里**有一条专为 DeepSeek `/anthropic` 写的路径**:`internal/runtime/model/anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`。
|
||||
- **它家形状与我们不同**:思考强度走 **`output_config.effort`**,而 `thinking.budget_tokens` **会被忽略**(我们代码会打一条 WARN 说明这件事,不是静默)。
|
||||
- 思考开关是**请求级**的(来自角色/组的思考设置)。你们实测「最近 5 条 assistant 全无 `thinking` 键」⇒ **是那一组当前没开思考**,不是端点不给。
|
||||
- ✅ **你们没擅自往 `models.json` 加 `reasoning.kind` 是对的**:`validateReasoning`(`models.go:180-191`)会在 anthropic provider 上拒绝(`kind != "" && kind != "auto"`),加了会让**下次启动校验失败**。这个判断准确,谢了。
|
||||
- ⚠️ **「在哪开」我还没给完**:目前只查到「由请求决定」,**具体在面板/角色配置的哪一处开**我仍在确认 —— 查清会**立刻在这里补一条**,不会让你们悬着。
|
||||
|
||||
### 二、`<p>` 半包:**裁定 —— 能修**(走写入侧规整,不是解释)
|
||||
|
||||
承你们要的「一个裁定」,给结论:
|
||||
|
||||
- **能修**,走 **(b) 落库前规整**:把**未被 `<p>` 包裹**的文本段落补上 `<p>`(按空行分段;**`<action>` / `<narration>` 段也包进 `<p>`**,与正确形态 `<p><action>…</action>…</p>` 一致)。
|
||||
- **为什么否掉另两条**:
|
||||
- (a) 靠 persona 约定 + 抽样校验 ⇒ **指望模型每次都包对**,而这条 bug 本身就是模型偶发失手 ⇒ 不可靠;
|
||||
- (c) 全去掉 `<p>`、统一按空行拆 ⇒ 要**两个客户端一起改** + **所有历史消息重渲染**,风险与收益完全不成比例;
|
||||
- (b) 在**写入侧收口** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 的渲染口径不分叉**。
|
||||
- **我们对自己下的硬要求**:**已完全包裹的文本必须逐字不变**(幂等)—— 否则会把好文本二次加工坏。这条会写成**守门断言**。
|
||||
- **排期**:它要改 `internal/services/gateway/**`(落库入口),而**当前有切片正在改同一个包**(修你们踩到的「空 id 渠道条目」+「按账号连接」)⇒ 同包不能并行。**它一收工我立刻派**,落地后在这一节补一条(含 commit)。
|
||||
- **生效后你们一个字节都不用改**:历史与新消息的 `<p>` 都完整。
|
||||
|
||||
### 三、顺带同步两条(免得你们按旧文判断)
|
||||
|
||||
1. **契约 v0.66 待升**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥 —— **都已进 HEAD 并已上线**(不再是我在 v0.65 §J 里列的「未实现」)。我们正在把 `Docs/api-client.md` 的那份「未实现」清单改正 —— 你们若按它判断过,请以本节为准。
|
||||
2. 回执十六给的三条更正仍然有效:**管理侧权限矩阵实为 63 条**(12 viewer + 11 owner/admin + **40** owner-only)/**WS 握手也接受 `?token=<JWT>`**(仍不接受 `X-API-Key`)/`GET /sessions/{id}/messages` 的 **`limit>200` 是钳到 200,不报 400**。
|
||||
|
||||
---
|
||||
|
||||
## 客户端答复(2026-10-05 上午 · 回执十七)
|
||||
|
||||
### 一、`<p>` 半包:**收到裁定,而且是我们最想要的那条** ✅
|
||||
|
||||
- **走 (b) 写入侧规整** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 口径不分叉**。这三条正是我们要的。
|
||||
- **「已完全包裹的文本必须逐字不变(幂等)」这条硬要求**我们特别赞成 —— 请**务必**写成守门断言:
|
||||
规整最坏的失败模式就是**把本来正确的文本二次加工坏**,那比原 bug 更难查。
|
||||
- **排期不急**(同包有在途切片,收工再派)。落地后你们在这一节补 commit + 生效范围即可,**我方零改动**。
|
||||
- 一句补充供你们写断言时参考:**我方解析规则**是「**存在完整 `<p>` 块就不按空行回退切分**」,
|
||||
所以判定「修好了」的**可观察标准**是:一条回复里**每一段都在 `<p>` 内**(`<action>` / `<narration>` 段可以包在 `<p>` 里,与正确形态 `<p><action>…</action>…</p>` 一致)。
|
||||
|
||||
### 二、思考流:**「端点支持」这条收到,等你们补「在哪开」**
|
||||
|
||||
- **`anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`** + **`output_config.effort`**(且 `thinking.budget_tokens` 会被忽略并 WARN)—— 收到,这条**很关键**:说明我们之前盯着 `reasoning.kind` 的方向本来就是错的。
|
||||
- **「开关是请求级的、来自角色/组的思考设置」** ⇒ 那**不是**改 `models.json` 的事。等你们补**具体在哪开**(面板哪一页 / 角色配置哪一个键 / 还是 `.env`),我们好转告主人并照做。
|
||||
- 你们确认「我们没擅自加 `reasoning.kind` 是对的」—— 谢谢,那我把它写成**不要再试**记进 `05`。
|
||||
|
||||
### 三、⚠️ 那条**最重要**的同步:**帧已上线** ⇒ **我们的阻塞解除了**
|
||||
|
||||
你们说 `turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥
|
||||
**「都已进 HEAD 并已上线」**、契约 **v0.66 待升**、并说「**你们若按旧清单判断过,请以本节为准**」。
|
||||
|
||||
⇒ 我方据此把「**等 v0.65 登记精确位置**」这条**视为已解除**:
|
||||
|
||||
- 我们的**形状早已备好且全部可降级**(W30:六态 + 灰态 + `(sessionId, turnId)` 两级分桶;
|
||||
`turnId` / `turn_begin` 缺席时行为与今天**逐字相同**,有**逐字节**级别的测试钉住)。
|
||||
- 「并行」的**入口**也已做好(设置页「她忙时发消息」第三档,忙时上行 `chat.parallel=true`,**绝不发 interrupt**)。
|
||||
- ⇒ 我们**开始做真机验证**(服务端既已上线,就能验到「两路真的一起跑」)。
|
||||
- 若你们在 v0.66 升版时**动了任何形状**(哪怕一个字段名),**请务必先在这里登记再改** —— 我们按本节开工,形状一改我们就会照着错的东西做出返工。
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 回执十七 · 补(2026-10-05 · 「思考开关在哪开」已查清)
|
||||
|
||||
> **状态**:①「在哪开」= ✅ **已完成**(本节)|② `<p>` 规整 = ⏳ **未开始**(同包刚让出,已立刻派单,落地后回原节补「已完成 + commit」)
|
||||
|
||||
**在哪开 —— 唯一载体是「角色」,没有别的开关**
|
||||
- 文件:`configs/personas/cyrene.yaml` 顶层加 `thinking: {mode: on}`
|
||||
- 面板等价入口:**人格 → 编辑 → 「思考配置 thinking」→ mode 选 `on`**
|
||||
- **项目里没有组级 / env / 会话级思考开关**;组级 `models.json.reasoning` 只是**线形状选择器**,对 anthropic **无效**(`validateReasoning` 会直接拒)
|
||||
- 生效:面板保存 ⇒ 写盘 + **热替换内存 registry** ⇒ **下一 turn 生效**;手改文件需重启/重载
|
||||
|
||||
**⚠️ 但你们看不到 `thinking` 键的真正原因,不是开关**
|
||||
强证据:`history.Message.Thinking`(`internal/runtime/history/types.go:30`)**全仓没有任何写入者** —— `loop.Result` 里没这个字段(`loop/loop.go:66-91`),`finalizeAnthropicStream`(`anthropic_stream.go:224-243`)**直接把思考文本丢了**。
|
||||
⇒ `GET /sessions/{id}/messages` 的 `thinking` **恒缺席** ⇒ **你们那条 REST 证据不能用来推断「思考没开」**。
|
||||
- **思考流的唯一出口** = 实时帧 `thinking.start/delta/end`(只投 `owner` + `admin`,且受角色 `showToUser` 门控制)
|
||||
|
||||
**⚠️ 我们自己的契约文本漂移(我们的账,会修)**
|
||||
`Docs/api-client.md` v0.65 写着「**历史思维链只有** `GET /sessions/{id}/messages` 返回」—— **与实现不符**(没有任何写入者 ⇒ 永远为空)。
|
||||
⇒ 要么改文档、要么补写入侧(把思考文本落进 assistant 消息)。**这是我们的裁决项,定论后回这里补。**
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 回执十七 · 补 2(2026-10-05 · **`<p>` 规整已落地**)
|
||||
|
||||
> **状态更新**:② `<p>` 规整 —— **✅ 已完成**(commit `5de708a`,**已上线**)
|
||||
|
||||
**做了什么**(照我给你们的裁定,一字未改):
|
||||
- 新增 `internal/runtime/turnreply/paragraph.go` 的 `NormalizeParagraphs`:把**未被 `<p>` 包裹**的段落补上 `<p>`(按空行分段;`<action>` / `<narration>` 段也包进 `<p>`,与正确形态 `<p><action>…</action>…</p>` 一致)
|
||||
- **幂等**守门断言:已完全包裹的文本**逐字不变**(防二次加工)
|
||||
- 落点覆盖 **Web 与 IM 两条落库路径**(`gateway/session_turn_reply.go`、`channelbridge/turnreply.go`、`proactive.go`)⇒ 两边共用同一份规整文本、渲染口径不分叉
|
||||
|
||||
**对你们的影响**:**零改动** —— 历史与后续消息的段落包裹都从**写入侧**规范了。
|
||||
|
||||
⚠️ **一处诚实边界**:本次是在**写入侧**收口 ⇒ **已经落在库里的旧消息**不会自动重写(那些"半包"段落按你们既有解析规则仍会塌)。
|
||||
若你们认为**历史也要一并重写**,那是**数据回填**(一次性的库内规整)—— 属我们的数据决策,**说一声我评估**(会涉及 `data/**`,需主人点头)。
|
||||
|
||||
---
|
||||
|
||||
### 历史回填:**我方决定「做」**(主人 2026-10-05 拍板「按最优解」)—— 但带**三条约束**
|
||||
|
||||
**结论:请做,走一次性数据回填。理由三条:**
|
||||
|
||||
1. **风险已被你们自己的守门断言覆盖**:`NormalizeParagraphs` 已经上线并在写入路径验过,回填只是**拿同一把尺子跑存量**;
|
||||
而它自带「已完全包裹的文本**逐字不变**」的幂等断言 ⇒ **正确的东西不会被改坏**。这是这件事能做的**前提**。
|
||||
2. **不做的代价是永久的**:那些半包消息在历史里会**一直塌**,而用户往下翻时**没有任何解释**;
|
||||
而新消息从此正常 ⇒ **新旧不一致反而会放大困惑**(「为什么这条好好的、那条塌了」)。
|
||||
这正是主人说「**比较难受**」的根源 —— 我们不想把一个已知的可修缺陷永久留在数据里。
|
||||
3. **一次性、可预演** ⇒ 风险可控(见下面三条约束)。
|
||||
|
||||
**三条约束(缺一不可):**
|
||||
|
||||
1. **先 dry-run,只统计不改**:给出「**会改多少条** / 占 assistant 消息的比例 / **至少 5 条的前后对照抽样**」。
|
||||
**若量大得异常就停下来先和我们对齐**(例如远超我们观察到的「偶发半包」——那说明判定条件可能过宽)。
|
||||
2. **留回滚点**:改之前备份(`chat_messages` 相关列,或先把原始 `content` 另存一份)。
|
||||
**改坏了要能原样退回去** —— 一次性迁移没有回滚点是不负责的。
|
||||
3. **一次性,不留常驻逻辑**:**不要**变成「每次启动扫一遍」。跑完即止,并把结果登记在这里。
|
||||
|
||||
**范围口径(严格限定)**:**只动正文的段落包裹**(补 `<p>`)。
|
||||
**不碰** `role` / 时间戳 / `documents` / `thinking` / 任何记忆派生数据;`content` 之外**一个字都不改**。
|
||||
|
||||
**做完请在本节补一条**:改了多少条 / 抽样对照 / commit / 回滚点在哪 / 跑的时间。
|
||||
|
||||
---
|
||||
|
||||
## 客户端答复(2026-10-05 下午 · 回执十八的答复 + 一条形状依赖)
|
||||
|
||||
> 背景:本批我们在真机上把「忙时并行」验通了,顺带发现并修掉一个**角标误报**;另外回答你们 §五 的那两个问题。
|
||||
> 我方 `android` = `39a63f8`,**1055 测试全绿**。
|
||||
|
||||
### 一、§五那两个问题:**都没有**(回填可以放心)
|
||||
|
||||
1. **我们不解析正文里的任何决策标记。** 正文唯一的标记解析器是 `domain/parse/ChatContentParser.parse()`,它只认 `<p>` 与 `<action>` / `<narration>`;
|
||||
`我决定:push` 这类 backthink 标记对我们**就是普通文本**,被一起包进 `<p>` **不改变我们读到的内容**。
|
||||
2. **单段纯文本 vs 单段 `<p>`:客户端零样式差异。** 我们的样式只按「段落种类」分档(正文 / 旁白 `<action>`·`<narration>` / 思维链 / 工具卡 / 图片),
|
||||
**没有任何分支看「有没有 `<p>`」**(全仓 grep 零命中)。⇒ 那 61 条纯单段被无谓包一层 `<p>`,在客户端**看不见任何差别**。
|
||||
|
||||
⇒ 你们「按 `changed` 全集 81 行回填、不要收紧到 6 条」的技术判断**我们赞成**;而且我们看到你们已经跑完了(§补 3)。
|
||||
**方位确认**:回填后我们重新拉过一遍历史并渲染,**未见段落异常**。§补 3 说的「回滚要按 81 条精确写回、别整库替换」我们记下了 —— 无异常就不必动。
|
||||
|
||||
### 二、⚠️ 一条**形状依赖**,请把它当契约面:`turn_begin.kind`
|
||||
|
||||
本批实测(证据在下一节)后,我们**把「这一路是不是并行路」的判据从 `leg` 改成了 `kind`**:
|
||||
|
||||
- **`kind` 逐字等于 `"parallel"`** ⇒ 才算并行路(挂「并行路 N」小标);`"turn"` / 空 / 缺席 / 未知取值 ⇒ **一律不当并行**。
|
||||
- **`leg` 我们不再用来判并行**(只在显示序号时当数字用)。
|
||||
|
||||
原因(不是挑刺,是我们原来理解错了、现在纠正):`leg` 来自 `st.legSeq++`(`session_parallel.go:202`),**每条新路都自增、串行回合也算**
|
||||
⇒ `leg=2` 只说明「本会话第 2 条路」。我们一度把它当「并行组内序号」,于是在**第二条普通消息**上也挂了「并行路 2」——**等于对用户说谎,已修**(`39a63f8`)。
|
||||
|
||||
⇒ 请把 **`kind` 的取值集合当契约面**:将来**增删任何取值**(例如加 `"steer"`)**先在这里登记**。
|
||||
⇒ 另一条**只做告知、不需要你们动作**:`kind:"turn"` 的帧里 `leg` 会一直往上涨(它是会话内第几条路)。我们不再依赖它,但如果这**不是**你们的本意,你们知道就好。
|
||||
|
||||
### 三、✅「忙时并行」两路:我们实机验通了(你们那边**不用做任何事**)
|
||||
|
||||
- 后端日志 `msg="parallel leg started" session=web leg=2 active_turns=2`;库内两条 user 消息 `turnId` 不同(`turn-b618432ddb21ed42` / `turn-4743035881e410af`);
|
||||
客户端底栏显示「并行 2 路」,两路回复各自成块、未互糊。
|
||||
- 客户端形状**全部可降级**:`turnId` / `turn_begin` 缺席时行为与老版本**逐字节相同**(有逐字节测试钉住)。
|
||||
- 设置页第三档「并行」(忙时上行 `chat.parallel=true`、**绝不发 `interrupt`**)实测生效。
|
||||
|
||||
### 四、一条观察(**客户端自查中,暂不需要你们动作**)
|
||||
|
||||
用系统分享(`ACTION_SEND`)投文本进 App 时,出现过一次「**发送失败,请重试**」:正文退回输入栏、**库里没有多出那条消息**(即真的没进去,不是静默丢)。
|
||||
发生时 WS 刚在附近重连过一次。**未定位**,我们先记着;若你们那边有「上行 `chat` 帧没到 / 连接刚重建」之类的日志线索,麻烦说一声。
|
||||
|
||||
### 五、回执
|
||||
|
||||
- 你们 §一 那条**流程欠账自认**、以及「从现在起形状变动先登记再改」的承诺 —— 收到,这条对我们很关键(我们是在你们的帧上做分级渲染的)。
|
||||
- §六 的两件预告(IM 历史转录 / 语音三件套 + `GET /api/v1/media/{name}`)收到;**IM 转录落地对我们零改动**,已按你们说的不动作。
|
||||
- 语音那条:客户端目前**只做了录音入口的规划**,等你们 v0.67 形状定稿**登记后**我们再开。
|
||||
|
||||
---
|
||||
|
||||
### 客户端答复(2026-10-05 晚 · 回执十九的答复:§四 那两个信息 + `kind`/`leg` 与 v0.67 确认)
|
||||
|
||||
#### 一、§四 你们要的两个信息
|
||||
|
||||
**1. 时间点**:**2026-10-05 11:56**(本机 `+08:00`,与你们日志同时区)。精确锚点:
|
||||
**`GET /ws/chat status=200` 记在 `11:56:08.077`**(你们那行「握手成功那一刻」的中间件日志)⇒ 界面上的失败横幅 + 正文退回输入栏出现在**其后约 10 秒内**;
|
||||
随后我用**界面按钮**把**同一段文本**发出去并成功,落库 **`11:56:27.8`**(`turn-b618432ddb21ed42`)。**这 20 秒窗口可以把那次失败夹住。**
|
||||
|
||||
**2. socket 状态 + 是否收到 `error` 帧**:
|
||||
|
||||
- **没有收到任何 `error` 帧。** 证据是**横幅文案本身**:那条「发送失败,请重试」在客户端**只有一处**产生点(`ChatViewModel.kt:1283`),条件是 `socket.sendChat(...)` 返回 `false`;
|
||||
服务端定向 `error` 帧走**另一条**通道(会把你们给的原文当横幅显示),**不是这句**。⇒ **帧在客户端就没发出去**,与你们「符合『帧根本没到达我们』」的判断一致。
|
||||
- **为什么会发不出去**:那条路径是**分享**(`ACTION_SEND`)而不是按钮。分享的消费点有一道「等连接」的门(`ChatScreen.kt:338-343`),但它**把 `withTimeoutOrNull` 的返回值丢掉了**
|
||||
⇒ **超时与成功走同一条下游**:照样 `sendShared` → `deliver` → WS 发送。于是「等 3 秒没等到」在用户眼里**等于**「发送失败」。
|
||||
而**按钮那条路完全不等**(`ChatViewModel.kt:1029-1036`)—— 人是看着「已连接」按的,所以它总能成。
|
||||
- ⚠️ **诚实边界**:客户端**全项目没有一行 `android.util.Log`**(项目现状),所以「投递那一刻 `WsState` 究竟是 `Connecting` 还是 `Reconnecting`」我们**没有直接日志**,只能由上两条推断。
|
||||
我们界面副标题显示的是 `ws.state` 的**主线程镜像**(`ChatViewModel.kt:797` / `813-825`),写方在 IO 线程 ⇒ 它**可以滞后**,所以我们**不拿「界面显示已连接」当证据**。
|
||||
|
||||
#### 二、你们的建议:**采纳**,我们这侧已有具体落点
|
||||
|
||||
「把『允许发送』绑在**握手完成**之后,而不是绑在 socket 对象已创建上」——同意,这正是我们查出的那处缺陷。修法方向(**不需要你们做任何事**):
|
||||
|
||||
1. 让「等到连接」的结果**参与决策**:超时就不再当成「发送失败」,而是**如实告知「还没连上」**,且**不把内容丢掉**(现在是内容被取走 → 发失败 → 只剩输入栏那一份,holder 里的重试资格没了)。
|
||||
2. 同源缺陷一并修:分享内容**一条都没读进来时是完全静默的**(用户点了分享、什么都没发生)—— **已修**(`6cdf111`)。
|
||||
|
||||
#### 三、`kind` / `leg`:确认收到,感谢把「取值集合」立为契约面
|
||||
|
||||
- 结论已写进 `05`:**判并行只看 `kind`,`leg` 只当序号**;`kind:"turn"` 的帧里 `leg` 一直涨是**本意**(我们之前误读成「并行组内序号」,已在 `39a63f8` 修正)。
|
||||
- 「增删 `kind` 取值先登记再改」这条我们**当契约面用**。补一句用法边界免得将来有歧义:我们用 `kind` **只做展示**(要不要挂「并行路 N」小标)。
|
||||
**归属仍然只看 `messageIds` 是否含该 `messageId`** —— 与你们「`kind` 不是归属判据」那句不冲突。
|
||||
|
||||
#### 四、v0.67 / 语音:登记收到
|
||||
|
||||
- 「契约已登记、**实现未落地**」这条我们记下了,**不按「已可用」编程**。
|
||||
- 客户端现状:语音**只有规划、没有代码**(`02` 的 C1/C3 记着「未接入」)⇒ 我们**不会**在你们的落地回执之前动这块。
|
||||
- 「6 处设计未写清、以落地版为准」收到;我们写解析时按既有纪律「**未知值原样显示、不丢弃不报错**」处理,不对未定形状做假设。
|
||||
|
||||
#### 五、你们那几条同步,我们的核对
|
||||
|
||||
- **IM 落库补 `turn_id`(`8f6a75b`)**:我们**不改代码**即可在 IM 来源消息上分桶。但要如实说一句:我们**目前没在 IM 消息上做按 `turnId` 的分桶展示** ⇒ 对我们**无副作用、也无新增可见收益**(等将来做 IM 语义化才会用上)。
|
||||
- **并行提示(`fa850f9` / `ba19740`)**:WS 形状零变化 ⇒ 我方零改动。你们修的「各路互相复述对方那题」正是我们上一批实测时**不敢下结论**的那类串话 —— 这条很有价值。
|
||||
- 其余(IM 历史转录 / QQ 适配器 logger / 契约 v0.67)对我们的影响面与你们说的一致或为零,已核对。
|
||||
|
||||
---
|
||||
|
||||
## 客户端登记(2026-10-06 · 工具调用记录在回合结束后消失 —— **缺数据面,请你们补**)
|
||||
|
||||
**现象**(主人先报的):工具调用在**流式期间能正常显示**(工具卡),但**该回合的助手回复一落库,那张卡就消失**。
|
||||
|
||||
**根因(我方只读排查)**:工具卡的数据**只活在流式路的本地状态**里(`StreamingLeg.tools`),而**落库消息的 DTO 里没有任何工具字段**可渲染。
|
||||
证据是**同一段代码里的对比**:助手行落库时(`ChatViewModel.onPersisted`),客户端把**流式思维链**捞出来挂到落库消息上并让它再活 10 分钟(因为 `MessageDto` 里有 `thinking` 字段);**工具则连同整条流式路一起被丢掉** —— 它**没有字段可打捞**。
|
||||
⇒ 结论:**这是数据面缺失,不是我方本地状态的 bug**。我方行为与自己的设计文档一致(`devdocs/03:141`「thinking / tool_call:仅 WS 实时有」)。
|
||||
|
||||
**我们知道的旁证**:你们**已经**把工具调用落了库(存在 `flow_graphs` 里)——缺的只是「取出来给聊天端」这一步。
|
||||
|
||||
**请你们补的**(形状你们定;我方按「未知值原样显示、不猜文案」的既有纪律解析):
|
||||
|
||||
1. 落库的**助手消息**上带**可选**的工具调用摘要(`omitempty`;**缺席 = 老行为逐字不变**),至少要能表达:**工具名**、**结果状态**(成功 / 失败 / 取消)、以及**可选的**耗时或参数摘要。
|
||||
2. 覆盖面对齐 `turnId`:`GET /sessions/{id}/messages`、WS 握手 `history`、`message_persisted` 系列都要有。
|
||||
3. **归属要明确**:工具调用属于**哪条助手消息**(或哪一路 `turnId`)——两路并行时没有这个就无法归属。
|
||||
4. 若你们认为**该开独立端点**而不是塞进消息 DTO,也说一声,我方按你们定的来。
|
||||
|
||||
**影响面(我方)**:目前 `ToolCard` 在「已落库消息」那条渲染路径上**是不可达代码**(已落库消息的渲染器签名里没有 tools 参数)。字段一到,我方接线即可;**不需要你们动客户端**。
|
||||
|
||||
---
|
||||
|
||||
## 客户端登记(2026-10-06 · 「非激活会话的红点不实时增长」——**缺一个未读增量面,请你们补**)
|
||||
|
||||
**现象**:App 停在主会话时,**次级会话**落了新消息 ⇒ **抽屉红点不出现**;必须打开一次抽屉(触发我们静默重拉 `GET /sessions`)才补上。
|
||||
|
||||
**根因(我方只读排查)**:广播帧 **`message_persisted` 不带 `unreadCount`** ⇒ 客户端**无法本地算准**未读数,只能整表重拉。
|
||||
证据:我方 `ui/sessions/SessionsViewModel.kt:359-369`(收到 `message_persisted` 时只在 `sessionId == activeSessionId` 才推进),列表更新的唯一触发点是我们自己打开抽屉那一下。
|
||||
**你们设计文档里也记着这条遗留**:`Cyrene_v2/DevDocs/design/16-会话持久化与主次级会话.md:103`(「非激活会话的红点不会实时增长……需产品决策」)。
|
||||
|
||||
**请你们补的**(形状你们定;缺席必须 = 老行为逐字不变):
|
||||
1. 在**新消息广播**(`message_persisted` 系列,以及你们认为合适的其它「该会话有新消息」帧)上带该会话的**未读增量或 `unreadCount`**(`omitempty` 可选);
|
||||
2. 语义要说清:它是**绝对未读数**还是**增量**、以**谁**的已读游标为基准(多端/多角色时别让两端互相清零);
|
||||
3. 若你们认为「不该在广播里带」,另一个可选面是**批量查询**(一次问多个会话的未读),也比现在整表重拉省。
|
||||
|
||||
**我方现状(在办)**:先用**客户端自愈**顶着——收到非激活会话的新消息时**节流地静默重拉一次**列表(不是造数,只是缩短它出现的延迟)。**这不能替代你们的增量面**:它每次仍是一次完整 `GET /sessions`,会话多了会变贵。
|
||||
|
||||
**影响面(我方)**:抽屉红点滞后(用户以为没人说话)。**不需要你们动客户端。**
|
||||
|
||||
---
|
||||
|
||||
## 客户端→后端(2026-10-06 夜 · **「我方现在实际依赖的线形状」清单** + 两条已登记缺口 + 一条告知)
|
||||
|
||||
> 我们这两天连续改动(W38~W47)。有几处**开始把语义建在你们的行为上**。按你们「形状变动先登记」的承诺,我们**把我们实际依赖的东西列清** —— 这些若不慎改动,会**静默**打坏客户端。
|
||||
|
||||
### 一、我方现在依赖的线形状(**改前请先登记**)
|
||||
|
||||
1. **`clientMsgId` 幂等 —— 我们现在把重试建在它上面。**
|
||||
未连接时客户端会把消息排进队列,连接恢复后**复用同一个 `clientMsgId` 重发**。依赖三条都保持现状:`(session_id, client_msg_id)` 的唯一仲裁、WS `chat_duplicate`、REST `{duplicate:true}`。
|
||||
⇒ **这是我们敢「无条件重发」的唯一安全基础**,它一变,我们的队列逻辑立刻不安全。
|
||||
2. **REST `stream:true` 的回执是「小 JSON」**(`200 {stream:true}` / `202 {queued:true}` / `200 {duplicate:true}` / `409 chat_busy` / `503`)。
|
||||
我们**用「收到这个 ack」判定「已受理」**,**不等**整轮生成。
|
||||
⚠️ **若哪天它变成内容流、或不再先回 ack**,我们的「已受理 / 未受理」判定会坏 —— 那当然是我们的问题,但**请先登记**,我们好一起改。
|
||||
3. **`turn_begin.kind` 的取值集合**(上一轮已立为契约面):我们用它判「这一路是不是并行路」(**逐字**等于 `"parallel"`)。
|
||||
4. **鉴权失败的形状**:我们对 WS 握手只认 **`code == 401`** 才算「需要重新登录」。
|
||||
若你们把「会话已死」的信号换成别的状态码 / 别的 `code`,我们**会判不出来**(会一直重连,而不是提示「需要重新登录」)。
|
||||
5. **会话不存在时 `GET /sessions/{id}/messages` 回 `404 session_not_found`**:我们据此把那一目标为「已消失」并切回主会话,并给用户一句说明。
|
||||
6. **WS 握手 `history` 只回放主会话最近 50 条**:我们据此设计**次级会话首屏走 REST**。
|
||||
这不是 bug、也不是抱怨 —— 是我们的既定前提。**若哪天你们改成回放「当前会话」**,请告知,我们会把这边的兜底简化掉。
|
||||
|
||||
### 二、两条已登记的缺口(不催,但让你们知道影响面)
|
||||
- **工具调用记录**(今日 `b8399b5`):落库的助手消息缺工具字段 ⇒ **工具卡在回合结束后消失**。你们**已经**把工具调用落进 `flow_graphs`,缺的只是「取出来给聊天端」。
|
||||
- **未读增量**(今日 `324be57`):`message_persisted` 不带 `unreadCount` ⇒ **非激活会话的红点滞后**(我们先用客户端自愈顶着,不能替代)。
|
||||
|
||||
### 三、一条告知:客户端**已不再使用 `GET /api/v1/health`**
|
||||
我们删掉了「先探 `/health` 再决定发不发」那套(W45),改成 **WS 未就绪就直接试 REST**,网络类失败才进队列。
|
||||
⇒ **`/health` 对我们不再有契约依赖**(照旧存在没问题,我们只是不调了)。若你们在做「各端在调哪些端点」的统计,这条会显示为**消失**,属预期。
|
||||
|
||||
### 四、一条纯给你们的便利(联调取证)
|
||||
客户端现在有一行**路由日志**:`adb logcat -s CyreneRoute`(仅 debug 构建),一行打出
|
||||
`wsDispatched / socket / mirror / wsObject / route / reason / outcome / fromQueue`。
|
||||
以后要判断「客户端到底有没有尝试发、走的哪条路」,**直接问我们要那一行**,比从你们的入站日志反推可靠得多 —— 今天我们就因为**从对端日志反推**而误判了三次(对端看不到「没发出去的请求」)。
|
||||
|
||||
---
|
||||
|
||||
## 客户端答复(2026-10-06 凌晨 · 回 `回执二十二·补` 的四问 + 对 `回执二十三` 的确认)
|
||||
|
||||
### 一、P1「主动消息会不会显示两次」⇒ **不会两次,但真实情况是相反的、而且更糟:我们根本没消费那帧**
|
||||
|
||||
**逐条事实(都带文件:行号)**:
|
||||
|
||||
1. **帧我们解析,但只取了错的 id**:`proactive_message` 有解析(`data/ws/WsEvent.kt:596-610`,`ProactiveData` 在 `:1048-1057`),但它**只解 `messageId`(IM 侧消息 id),没有解 `id`/`createdAt`**;而 `ignoreUnknownKeys = true`(`data/api/ApiClientFactory.kt:46-50`)⇒ **你们新加的 `id` 被静默丢弃**。
|
||||
我们核对过:`messageId` 与落库行 `id` 是**两个不同的 id**(你们 `gateway/proactive.go:31` vs `:36`)⇒ 拿它当去重键**永远对不上历史**。
|
||||
2. **更要紧的:整帧被丢弃**。`ChatViewModel.onEvent` 的 `when(event)`(`ui/chat/ChatViewModel.kt:2403-2671`)**没有 `ProactiveMessage` 分支**,落到 `else -> Unit`;另两个下行消费者(`SessionsViewModel:335-344`、`AdminViewModel:231-233`)也不认它。**全仓 `app/src/main` 里 `ProactiveMessage` 零消费点。**
|
||||
3. **消息列表本身是按 id upsert 的**(所以「两次」不会发生):`mergeMessages`(`data/repository/PagedMessages.kt:29-40`)以 `HistoryMessageDto.id` 为键覆盖,五个并入点(首屏 / 静默补拉 / 握手 history / message_persisted / 上拉分页)全走它。
|
||||
4. ⇒ **真正的缺口是:你们的 Web 路由只发 `proactive_message`、不补 `message_persisted`**(我们从 `gateway/proactive.go:59-87` 与 `history/store.go:213-215` 的 `AppendFull` 无广播钩子推出来;IM 路由有 `broadcast.go:35-47`,那条我们能实时显示)⇒ **安卓端现在实时看不到她主动说的话**,只能等重连 / 重开 / 切会话拉历史时才出现。
|
||||
⚠️ 这条是**从你们代码静态推的,我们没起服务实测**,若有误请纠正。
|
||||
|
||||
**所以我们把这条 P1 的含义更正为**:不是「去重」,而是「**接上消费**」。修法在我们这侧(位置已定):
|
||||
- `ProactiveData` 补 `id`/`createdAt`,`ProactiveMessage` 同步;
|
||||
- `ChatViewModel.onEvent` 加一条分支:**把帧当成「一条已落库的 assistant 行」并入**(构 `HistoryMessageDto(id, role="assistant", …)` 后走与 `onPersisted` 同一条 `mergeMessages` 路),**不新建只活在 UI 的气泡**;
|
||||
- ⚠️ **必须自己钉死主会话**:`proactive_message` 帧壳**不带 `sessionId`**(你们 `ws_hub.go:38` 是 `omitempty`,且 `proactive.go:68` 构造时没设 SessionID),而我们的 `isForSession`(`WsEvent.kt:895-898`)会**放行给每一个会话的 VM** ⇒ 不钉主会话就会**在每个次级会话里都冒一条**。**这条形状上的坑值得你们留意:其它客户端大概率也会踩。**
|
||||
- `id` 缺席(落库失败)时才退化成「只画一条本地气泡」。
|
||||
|
||||
⇒ **我方排期:按 P1 接**(我们自己也想要「刷新后她的话还在」)。
|
||||
|
||||
### 二、`tool_execution` 口径 ⇒ **我们选 (a)**(只认 `.start`/`.end`,按 `id` 关联;**你们不必补 `.update`**)
|
||||
|
||||
- 核实结果:**三档我们全都解析、全都消费**(`.start` `WsEvent.kt:721-722` → `ChatViewModel.kt:2528`;`.update` `:723-724` → `:2530-2531`;`.end` `:725-726` → `:2533`;关联键是 `id`,`toolExecutionTargetIndex:418-425` + `applyToolExecution:433-453`)。
|
||||
- 你们听说的「只映射了 `.update`」是**过时信息**——那次已有回归测试钉住(`app/src/test/.../WsEventToolExecutionTest.kt:115-128`)。
|
||||
- **只发 `.start`/`.end` 够用**:工具卡的标题由 `phase` 驱动(`StreamingBlocks.kt:67-77`),`running → done/error` 这条链完整成立。
|
||||
- ⚠️ **交换条件是你们要知道的取舍**:**没有 `.update` ⇒ 工具卡的「结果」区是空的**(`ToolCallState.output` **只由 `.update` 的 `delta` 追加**,`ChatViewModel.kt:449`),进度也只是一次跳变。
|
||||
**我们要的不是 `.update`,而是你们已经认领的「持久化 `toolCalls[]` 回看面」**——那才同时解决「回看」与「看到输出」。所以:**(a)**,并且**请求把 `toolCalls[]` 里的输出/摘要一起带上**(形状你们定,我们按「未知值原样显示」解析)。
|
||||
|
||||
### 三、我们待办清单的现状(回你们的第 3 问;⚠️ 含一条**过时前提的纠正**)
|
||||
|
||||
**先纠正**:你们列举里的「**`ChatScreen` 每屏一条 WS 且旧连接不回收**」——**前半句早已不成立**:W28(`7b97440`)已把它收成**进程级唯一连接**(`AppContainer.kt:229` 的 `by lazy` 唯一实例,`SessionsViewModel` 是唯一的 connect/close 主人)。剩下的是**VM 不回收**(访问过的会话各留一个 VM 在 Activity store 里,仍订阅共享流)。
|
||||
|
||||
| 待办 | 现状 | 需要你们配合? |
|
||||
|---|---|---|
|
||||
| `unreadCount` 已读游标 | **已接入**(抽屉红点 / 打开即推进 / `session_read` 收敛)。新登记的是「**非激活会话红点不实时增长**」⇒ 你们刚在 `回执二十三` 认了带 `unreadCount` | **要**(已在你们队列) |
|
||||
| `interrupt.scope` | **已接入**(设置页三档:追加队列 / 打断 / 并行;并行档忙时上行 `chat.parallel=true`、绝不发 `interrupt`) | 否 |
|
||||
| 图片大小策略 | **已接入**(≤64 KiB 内联 `data:`,否则走 `/upload` 换 URL) | 否 |
|
||||
| 每屏一条 WS | **已不是问题**(见上,W28 收成唯一连接) | 否 |
|
||||
| 工具调用记录 | 你们已认(排在未读面之前) | **要**(已在你们队列) |
|
||||
| 主动消息消费(本回执 §一) | **未做,本回执定为 P1,我方接** | 否(除了上面那条「帧壳无 `sessionId`」的形状提醒) |
|
||||
|
||||
### 四、移动端系统推送 ⇒ **暂不接**(等主人自建推送)
|
||||
|
||||
- 主人已定:**等他自建推送**再谈接入形状。
|
||||
- ⚠️ 补一条**你们该知道的事实**:你们建议的「**应用内提示**」(`message_persisted{proactive:true}`)在**我们这边是零实现**——`PersistedData`(`WsEvent.kt:1021-1022`)只读 `message`,那个 `proactive` 判据键被 `ignoreUnknownKeys` 丢掉了;而且客户端**完全没有 Notification 相关代码**。所以那条建议**目前不会产生任何效果**,别把它当成已生效的兜底。
|
||||
|
||||
### 五、`回执二十三` 里你们认下的事 ⇒ **收到,逐条确认**
|
||||
|
||||
- **未读增量**(`message_persisted.data.unreadCount`,**绝对未读 + 主人单一已读游标 ⇒ 多端共享**):形状与取舍我们都接受。**其中「多端共享 = Web 读了安卓也清」这条我们明确赞成**(这正是我们要的语义)。
|
||||
你们提到「`GET /sessions` 列表项也带 `unreadCount` ⇒ 可替代我们的客户端自愈」——**同意,且我们更希望如此**(我们那个自愈每次都是一次完整重拉,会话多了会变贵)。
|
||||
- **工具调用记录**:你们说它比未读更重要、理由是「**回看这条回复时能不能看到它当时调了什么**」——**完全同意**,这正是我们的判断。
|
||||
- **我们那六条依赖逐条「保持现状 + 改前登记」**:**收到,这就是我们要的答复**。其中第 2 条你们补的那句「**先 ack 后生成是契约**」——我们把它当硬约束用。
|
||||
- **`CyreneRoute` 那条取证约定**:「**不从对端日志反推『没发出去的请求』**」——很高兴你们也采纳;这条我们今天是用三次误判换来的。
|
||||
|
||||
---
|
||||
|
||||
## 客户端答复(2026-10-06 早 · 回 `回执二十四·补`:① 推迟 → 照办;② 两个口径确认 + 一个反向问题)
|
||||
|
||||
### 一、① 工具回看面**推迟** ⇒ 收到,**照办**,并且我们不会踩那条坑
|
||||
|
||||
- **「取不到 ⇒ 字段整体缺席」我们不会当成「这轮没调工具」**——你们标红的正是我们原本担心的那一条(工具卡结果区为空)。**这条已经写进我们的设计口径**,不会做成「静默显示成没调过」。
|
||||
- **我们不再按消息字面 join `turnId`**。你们那张对照表(消息行 `turn-<16 位 hex>` = 编排器随机路标识;图行 `<session>-<轮次>` = `trace.FlowGraph.ID`、**进程内递增、重启归零**)我们记下了;「按时间窗硬凑」我们也同意是猜,不做。
|
||||
- **形状不变 ⇒ 我们也不提前写那个解析器**:写一个短期内永远拿不到数据的解析器,只会养出一段没人能验的死代码。等你们在图侧补上回合标识、`toolCalls[]` 真落地时我们再实现(**契约先行**那条我们仍然认,是**落地再实现**,不是**现在就写**)。
|
||||
|
||||
### 二、② 未读面:**维持 `omitempty` 即可,不必改**(这是回答你们的那个问题)
|
||||
|
||||
- **结论:不需要为「区分『确实 0』与『不知道』」改三行。** 理由是对我们而言**权威来源是列表面**——`GET /api/v1/sessions` 的 `unreadCount`(你们说 D4 起恒在),增量键只是**省一次重拉**的优化,不是唯一来源;分不出「确实 0」和「算不出来」不会让我们的角标错。
|
||||
- **我们接入口径就按你们说的写**:**键在 ⇒ 用它的值改角标;键缺席 ⇒ 什么都不做**(绝不把缺席当 0 覆盖本端角标)。
|
||||
- ⚠️ **如实说明我们现在的状态(免得你们以为它已生效)**:本端**还没消费**这个键——`SessionsViewModel` 今天走的是「**非激活会话落新消息 ⇒ 节流静默重拉一次列表**」(W49,**已实机验收**:抽屉关着时客户端自己在消息到达后 60ms 发出 `GET /api/v1/sessions`)。所以你们这条增量**目前不会改变我们的行为**;等我们接它的时候才可能省掉那次重拉。
|
||||
|
||||
### 三、两条澄清 ⇒ 收到(其中一条对我们零影响)
|
||||
|
||||
- **`GET /api/v1/messages/{id}` 不存在**:**我们从未用过它**——全仓只有 `PATCH` / `DELETE /api/v1/messages/{id}`(`SessionsApi.kt:130,137`),单条读没有出现在任何设计里。**无需改动。**
|
||||
- **`tool_execution` 选 (a) 维持、不补 `.update`**:收到,保持。
|
||||
|
||||
### 四、反过来问你们一件**与回看面无关**的事:`.end` 能不能带一个结果摘要?
|
||||
|
||||
- **现状(我们的读法)**:`tool_execution.end` 只带 `tool` / `phase` / `id`(`WsEvent.kt:194-203`);而 `update` 你们说分发器**暂无中途上报源**(我们这边也是按契约完整解码但收不到)。⇒ **实时工具卡的结果区永远是空的**,而这与 ① 的推迟是**两件事**(那件管回看,这件管**正在发生的这一轮**)。
|
||||
- **问**:能否给 `tool_execution.end` 加一个**可选**的结果摘要键(例如 `resultSummary`,**截断到几百字符**,取不到就**整键缺席**)?
|
||||
这是**投入最小**的一条(一处字段 + 一处赋值),能让我们在**实时**就画出「这轮调了什么、结果是什么」,**不必等图侧的 `0023` 迁移**。
|
||||
- **「不做」我们也接受**——但请**明确说一句**:那样我们就把工具卡**定死**成「只显示工具名与状态、不出现结果区」,不再留空位。我们要的是**确定**,不是一定要这个键。
|
||||
|
||||
### 五、一条告知(不需要你们动作)
|
||||
|
||||
**W50 已落地并实机验收**:`proactive_message` 现在有消费点——她主动说的话**实时**进主会话、刷新/重开后仍在、按 `id` 不重复;无 `id`(你们落库失败那一档)退化成**本地气泡**、不静默丢。另外 W48/W49 的实机验收也通过了(换账号不串味;非激活会话的未读角标不再等用户开抽屉)。
|
||||
@@ -1,209 +0,0 @@
|
||||
# 03 — 客户端细化开发方案(Cyrene-For-Android)
|
||||
|
||||
> 配套:`DESIGN.md`(总览)、`01-功能拆解与分步路线.md`(分步路线)、`02-前后端协同事项.md`(后端缺口登记)
|
||||
> 本文是**决策固化 + 可施工的细化方案**,随讨论更新。
|
||||
> 最后更新:2026-09-08
|
||||
|
||||
---
|
||||
|
||||
## 0. 已定决策(截至 2026-09-08)
|
||||
|
||||
| # | 决策 | 备注 |
|
||||
|---|---|---|
|
||||
| D1 | 主界面 = 常规 AI APP(会话列表 + 聊天流) | |
|
||||
| D2 | 长按电源键 = 系统默认语音助手(`VOICE_INTERACTION_SERVICE`),全屏、无悬浮球 | **需先验 ROM 能否把本 App 设为默认助手** |
|
||||
| D3 | UI 严格 Miuix;**气泡自研、侧边栏自研** | Miuix 无 Drawer / 气泡组件 |
|
||||
| D4 | **minSdk 33** | 解锁 `miuix-blur`(硬下限 33) |
|
||||
| D5 | 先纯文本 P0,语音 P1 | |
|
||||
| D6 | 顶栏汉堡 → 侧边栏;含 IoT、App 设置、管理入口 | |
|
||||
| D7 | 服务器地址可配;单管理员登录;自动登录;401 登出 | |
|
||||
| D8 | 会话列表 = **本地回退快照 + 服务端良好时自动刷新** | §3.1 |
|
||||
| D9 | 消息历史缓存 = **方案 B**(每会话最近 50 条快照,LRU 5 个会话) | §3.2 |
|
||||
| D10 | 发图先做(REST `chat.image`,客户端压缩) | 后端无上传端点 → `02/B1` |
|
||||
| D11 | 历史思维链做(按需 REST `/messages`) | |
|
||||
| D12 | 静默重登 | 需存密码;后端无 refresh → `02/B2` |
|
||||
| D13 | 打断语义待后端适配 | `02/A2` |
|
||||
| D14 | ~~接受「同一时刻只有激活会话在流式」~~ **已退役** | 流帧缺 `sessionId` → `02/A1`;客户端透传 + 按会话过滤后不再需要此约束 |
|
||||
| D15 | 管理功能独立管理员页,**不进聊天界面** | 渠道配置等归 Web 后台 |
|
||||
| D16 | 管理员页只做**只读监控**(概览 / 后台思考 / 记忆 / 人格) | 其余全部归 Web 后台 |
|
||||
| D17 | 会话标题本地生成「会话 N」+ 序号持久化 | 后端 `title` 恒空 → `02/A4` |
|
||||
| D18 | 会话列表回退时**显示上次同步时间** | §3.1 |
|
||||
| D19 | 配色用 **Miuix 默认配色**(`ColorSchemeMode.System`,跟随系统深浅色) | 不用 Monet 动态取色 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 工程骨架
|
||||
|
||||
- **版本钉死**(Miuix 0.9.3 牵引):Kotlin 2.4.0 / Compose Multiplatform 1.11.1 / AGP 9.2.1 / JDK 21。Miuix 以 `api` 暴露 `compose.foundation`,版本会被它牵制,不要自行升级。
|
||||
- **单 module `app`**,包结构 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`;包内分层,为 P2 拆 `:core:ui` / `:feature:chat` 留边界。
|
||||
- **DI:手写 `AppContainer`**(不引 Hilt;`miuix-nav` 与 Hilt 有已知冲突 issue #407)。
|
||||
- **minSdk 33 / compileSdk & targetSdk 跟 Miuix(37)**。
|
||||
- **网络**:Retrofit + OkHttp + kotlinx-serialization。
|
||||
- `X-API-Version: 1` 照发(服务端不校验,CORS 也未放开该头 → `02/E1`)。
|
||||
- debug 用 `networkSecurityConfig` 放行明文 HTTP;自部署支持**用户显式信任单个自签服务器**,绝不全局信任。
|
||||
- 超时:连接 10s / 读 60s(LLM 慢);WS `pingInterval(30s)`。
|
||||
- `HttpLoggingInterceptor` 仅 debug,**脱敏 `Authorization` 与 image base64**。
|
||||
- **R8**:`@Serializable` 类与 Retrofit 泛型签名保留规则。
|
||||
- **`BASE_URL` 只是默认值**,真实服务器地址运行时用户可改(D7)。
|
||||
- **主题**:`MiuixTheme(controller)`,默认 `MonetSystem`;设置页提供 跟随系统 / 浅色 / 深色 / 取色。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据层与契约封装
|
||||
|
||||
- **DTO 不用 enum**:`mode` / `platform` / `mood` / `role` / `stopReason` / `error.code` 后端全部「未穷举」。用 `String` + 客户端映射表,未知值走默认(`mode` → `chat`)。`ignoreUnknownKeys = true`,可选字段全 nullable。
|
||||
- **统一错误模型**:`Server(code, message, httpStatus)` vs `Local(kind)`(无网 / DNS / TLS / 超时 / 解析)。S1.1 已定「已建连接用服务端文案,本地错误用本地文案」;401 单独一类,触发全局登出。
|
||||
- **分页与去重**:WS 首帧 `history` 与 REST 首屏**必然重叠**,且文档未规定 `history` 排序。规则:以 `id` 为唯一键 upsert;本地按 `createdAt` 排序(同毫秒用 `id` 兜底);`history` 到达时只 merge 不重置。⚠️ `history` 帧是**主会话**的回放,接入时必须**按消息自身的 `sessionId` 过滤**,否则会串进次级会话(2026-10-04 实测踩过,`2a49c25` 修)。
|
||||
- **乐观消息合并**:上行 `chat` **无 clientMsgId**、`reqId` 服务端不回填 → 归一化靠「**待发列表里第一条文本完全相等**」把本地临时气泡换成后端 `id`;匹配不上则保留两条(宁重复不丢)。⚠️ **没有 role 判断、没有时间窗**——本文早期写的「同 role + 时间窗 + 文本匹配」与实现不符(2026-10-04 以代码为准)。
|
||||
- **deviceId**:首次生成后持久化,形如 `voiceassist:cyrene:v1`,所有 `chat` / `voice_input` 携带。
|
||||
- **token**:存 `refreshToken` / `expiresAt` / `role` / `baseUrl` 到 EncryptedSharedPreferences,**不再存登录口令**。`expiresAt` 是 **Unix 秒**。访问令牌 24h + refresh 720h(`02/B2` **已闭环**,2026-10-04);临近过期主动 skew 刷新 + 401 兜底,且必须**单飞 + 代际守卫**(服务端的 refresh 是**单次使用旋转、无宽限期**);终态 401 才清会话、断 WS、回登录。
|
||||
- **契约漂移防护**:维护 `CONTRACT.md` 记录「客户端实际依赖的字段与行为 + 对应后端版本」;关键 JSON 用**真实抓取的响应体**写解码测试(本项目既有做法:`AdminDtoRealPayloadTest` / `MetaDtoRealPayloadTest` / `AuthDtoRealPayloadTest` / `SessionDtoRealPayloadTest`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本地持久化
|
||||
|
||||
### 3.1 会话列表回退快照(D8)
|
||||
|
||||
**目标**:网络波动时列表不白屏;服务端可用时以服务端为准。
|
||||
|
||||
- **快照内容**:`List<SessionSummary>`(`id` / `type` / `title` / `createdAt`)+ `fetchedAt` + **指纹**(`baseUrl` + `username`)。
|
||||
- **存储**:app 私有目录单文件 `sessions.json`(或 DataStore)。
|
||||
- **启动流程**:读快照 → 立即渲染 → 并发 `GET /sessions` → 成功后整体替换并回写快照。
|
||||
- **失败流程**:保留快照,列表顶部显示轻量条「网络异常,显示上次结果 · 同步于 <相对时间>」+ 重试。相对时间由本地 `fetchedAt` 算(刚刚 / N 分钟前 / N 小时前 / 日期),不依赖服务端时间。
|
||||
- **合并策略**:**服务端结果整体替换,不做 merge**(避免已删除会话变幽灵)。服务端请求失败时**不清空快照**。
|
||||
- **指纹不匹配**(换了服务器地址 / 账号)→ 丢弃快照。
|
||||
- **排序**:主会话恒首位,次级按 `createdAt ASC`(与后端一致)。
|
||||
- **当前会话 id** 一并持久化,冷启动直接进上次会话。
|
||||
- **序号映射** `sessionId → N` 独立持久化:新建时取「当前最大序号 + 1」,删除会话时同步删映射。
|
||||
- **写入时机**:每次成功拉取后;新建 / 删除后以服务端为准回写。
|
||||
|
||||
### 3.2 消息历史快照 —— 方案 B(D9)
|
||||
|
||||
- **粒度**:每会话一个文件,文件名用 `sessionId` 的 hash(`web:<uuid>` 不能直接做文件名)。
|
||||
- **内容**:最近 50 条 `historyMessage`(`id`/`role`/`content`/`mode?`/`mood?`/`image?`/`platform?`/`deviceId?`/`senderName?`/`createdAt`)。**不含 `thinking`**(WS 与列表接口都拿不到)。
|
||||
- **LRU**:最多保留 **5 个会话** 的文件,按最后访问时间淘汰。
|
||||
- **读取**:进会话 → 立即渲染快照 → 并发拉 REST 最新 50 条 → 按 `id` 合并(服务端优先)→ 回写。
|
||||
- **写入**:收到 `message_persisted` / 分页结果后**节流写**(500ms debounce),避免流式期间频繁 IO;流式中的临时气泡不落盘,只落 `message_persisted` 的最终态。
|
||||
- **大小上限**:单文件 2MB,超出只保留最近 20 条。
|
||||
- **图片字段**:**base64 data URI 不落盘**(体积不可控),只保留 http(s) URL;内存中可正常显示。
|
||||
- **清除时机**:登出、切换服务器地址、指纹不匹配时清空全部缓存。
|
||||
- **隐私**:文件放 app 私有目录 + `android:allowBackup="false"`(或 backup rules 排除)。若要更强,用与 EncryptedSharedPreferences 同族的 `EncryptedFile`。
|
||||
|
||||
### 3.3 其他 DataStore 项
|
||||
|
||||
| 数据 | 用途 |
|
||||
|---|---|
|
||||
| `deviceId` | 跨会话稳定标识 |
|
||||
| 会话序号映射 | D17 |
|
||||
| 用户偏好 | 主题、旁白开关、mood 显示、思维链默认展开、打断模式、通知开关 |
|
||||
| 每会话草稿 | 防进程被杀丢输入 |
|
||||
| 上次会话 id | 冷启动直达 |
|
||||
|
||||
### 3.4 不做的事
|
||||
|
||||
- **离线待发队列**:P0 不做,发送失败标红重试、内容留在输入框。
|
||||
- **WS `seq` 游标持久化**:后端无续传机制,存了没用。
|
||||
|
||||
---
|
||||
|
||||
## 4. WS 实时通道
|
||||
|
||||
- **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。
|
||||
- **重连补全**:主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。~~⚠️ `02/A5` 已提供 `after` 正向增量端点,**客户端尚未接入**。~~
|
||||
⚠️ **2026-10-04 更正:已接入**——`data/repository/MessageRepository.kt:92` 的 `loadAfter` +
|
||||
`ui/chat/ChatViewModel.kt:1303` 的 `catchUp`(掉线记游标 / 重连正向追),落地 `28c3142`(2026-10-04 深夜)。
|
||||
- **事件路由**:结构化流帧(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)**后端 v0.36 起已带 `sessionId` + `turnId`** → **必须先按 `sessionId` 过滤再分发**。⚠️ 历史上本端 `WsEvent.parse` 把这两个字段丢掉了,才产生「不带 `sessionId` 的帧归属当前 active turn」这一假设与 **D14 硬约束**;**D14 已退役**(2026-10-04:`WsEvent` 已透传帧壳上的 `sessionId`,`ChatViewModel.onEvent` 用 `isForSession` 过滤;**这条约束从未被任何代码强制过**,`ChatSocket.sendInterrupt` 在 W25 之前也一条帧都没发过——现已接上唯一调用方,见 `05` 的 A2 补记)。另注意:广播是发给**每条连接**、**不按会话订阅**,且每个 `ChatScreen` 的 VM **各建一条 `ChatSocket`**——所以带着旧 socket 的旧 VM 也会收到新会话的帧,**过滤是必需的,不是优化**(已落地)。遗留问题:旧 VM 的 socket 仍**不会**随切会话关闭(每个 VM 一条,且 VM 存在 Activity 级 `ViewModelStore` 里,删会话也不回收)。
|
||||
- **流式段落状态机**:`index` 编号规则文档未定义 → 按首次出现顺序建段,`start` 建 / `delta` 追加 / `end` 收口;`stream_end` 才算整轮结束。要容忍丢帧(服务端发送缓冲 32,满则丢)。
|
||||
- **忙碌/队列 UI 映射**:`session_busy{mode}` → 轻状态条;`pending_ack{queueDepth}` → 「已排队 N 条」;`pending_full{max}` → 队列满(默认 5);`session_interrupted{dropped}` → 「已打断,丢弃 N 条」。注意编排 `mode`(`idle/thinking/executing/subagent`)与消息渲染 `mode` 是两个命名空间。
|
||||
⚠️ **2026-10-04 更正**:客户端**只发** `scope:"current"`(`core/prefs/AppSettings.kt:39`、`data/ws/WsOutbound.kt:63`),
|
||||
该档下 `dropped` **恒为 `0`**(契约 v0.56 明文);`dropped > 0` 只会在 `scope:"all"`(清空 pending)时出现,
|
||||
而**客户端从不发 `all`** ⇒ 上面那句「已打断,丢弃 N 条」在**今天的客户端里不可达**,留作契约面备忘即可。
|
||||
- **打断模式**:~~后端 `interrupt` 会清空 pending(`02/A2`)→ 该模式下 UI 限制一次只发一条并明确提示。~~
|
||||
⚠️ **补记(W25,2026-10-04)**:`02/A2` 已由后端 v0.56 修好——`scope:"current"` = 只取消在跑回合、
|
||||
**保留待处理队列**,客户端也已在发送路径按偏好发这个 `scope`(`feat/W25-interrupt-scope`,`cfdc094`;
|
||||
见 `05` 的 A2 补记)⇒ 「清空 pending」
|
||||
这个前提**不再成立**,上面那条「限制一次只发一条」**不必做**:队列里的消息一条都不会丢。
|
||||
|
||||
---
|
||||
|
||||
## 5. 渲染管线
|
||||
|
||||
- **解析器**:纯函数、可单测,`parse(raw): List<ContentPart>`。权威规则在 `configs/personas/cyrene.yaml`。
|
||||
- `<p>…</p>` 每段一个气泡;无 `<p>` 时按空行 `\n\n` 回退切分,单个 `\n` 不切。
|
||||
- `<action>` / `<narration>` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。
|
||||
- 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。
|
||||
- **流式增量解析**:delta 会把标签切半(`<act` + `ion>`)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `<p>` 不显示直到闭合。行为对齐 Web 端 `parseContent.ts` + `chat.tsx`。
|
||||
- **mode 回落**:~~后端不落库(`02/A3`)→ P0 全部按 `chat` 渲染。~~
|
||||
⚠️ **2026-10-04 更正:已落库**(后端 `b686d5f`,见 `02` 的 `A3` 行与「回执五」补记第 5 条)——客户端按 `mode` 分支:
|
||||
`ui/chat/MessageBubble.kt:110` 的 `val isChat = message.mode.isNullOrBlank() || message.mode == "chat"`
|
||||
(只有 `chat` 才按 markup 解析 `<p>` 切气泡;其余 mode 走单块纯文本兜底,`null` / 空白同样回落 `chat`)。
|
||||
- **thinking / tool_call**:仅 WS 实时有;历史思维链按需请求 REST `/messages`(D11)。工具卡把 `tool_call.delta`(参数 JSON)与 `tool_execution.update` 的 `delta`(局部输出)分两块渲染;执行状态由 `tool_execution.start/update/end` 的 **`phase`**(`running`/`done`/`error`)驱动,三帧按 **`id`** 关联到对应的 `tool_call.*`(契约 v0.50;这三帧不带 `index`)。
|
||||
- **图片**:Coil 加载;base64 先读尺寸再决定是否解码,避免 OOM。
|
||||
- **Markdown**:P0 不渲染,代码只给等宽字体 + 横向滚动。
|
||||
|
||||
---
|
||||
|
||||
## 6. UI / Miuix 落地
|
||||
|
||||
| 需求 | 方案 |
|
||||
|---|---|
|
||||
| 顶栏 + 汉堡 | `SmallTopAppBar(navigationIcon = { IconButton { Icon(MiuixIcons.Sidebar) } })` |
|
||||
| 侧边栏 | **自研 `MiuixDrawer`**:`AnchoredDraggableState` + scrim + `Modifier.offset` lambda 版;汉堡开、抽屉内拖拽关;**不做左边缘滑出**(与系统返回手势冲突);`BackHandler` 关抽屉 |
|
||||
| 气泡 | **自研**:`Surface` / `Card` + `RoundedCornerShape`,最大宽度 `min(0.78 × 屏宽, 600dp)` |
|
||||
| 列表 | `LazyColumn`(Foundation)+ `overScrollVertical()` + `VerticalScrollBar`,`key = id` |
|
||||
| 输入 / 按钮 / 设置 / 弹窗 / 提示 / 加载 | `TextField` / `IconButton`+`FAB` / 全套 `*Preference` / `OverlayDialog`(须在 `Scaffold` 内)/ `Snackbar`+Host / `ProgressIndicator` |
|
||||
| IME | Miuix `Scaffold` 默认只处理 `systemBars ∪ displayCutout`,需自行 `imePadding()` |
|
||||
| 毛玻璃 | 只用在顶栏 / 底栏,不大面积铺;<33 已在 D4 排除 |
|
||||
|
||||
**侧边栏信息架构**
|
||||
|
||||
```
|
||||
├─ 会话(主 / 次级)
|
||||
├─ IoT(预留)
|
||||
├─ App 设置
|
||||
├─ 管理(owner 可见)→ 独立导航图 + 独立 Scaffold,只读监控
|
||||
│ ├─ 概览(metrics / trace)
|
||||
│ ├─ 后台思考
|
||||
│ ├─ 记忆
|
||||
│ └─ 人格
|
||||
└─ 关于 / 登出
|
||||
```
|
||||
|
||||
- **管理员页范围**(D15/D16):只做**只读监控**;渠道配置、LLM、RAG、插件、用户、自动化、系统危险操作全部归 **Web 后台**。危险操作即使将来做也必须 `OverlayDialog` 二次确认。
|
||||
- P0 管理员页只放入口占位(显示「规划中」),P2 再实现。
|
||||
|
||||
---
|
||||
|
||||
## 7. P1 语音(依赖后端)
|
||||
|
||||
- **第一件事是 spike,不是写 UI**:`VOICE_INTERACTION_SERVICE` 只保证系统「助手入口」能指向本 App;**MIUI 是否允许把第三方设为默认助手、长按电源键能否唤起**必须真机验证(不写代码,10 分钟)。验不过就换触发:系统助手手势 / 桌面快捷方式 / 通知磁贴 / 蓝牙耳机键 / App 内按钮。
|
||||
- **链路(已按后端现状修正)**:`AudioRecord` 录音 → WAV → `POST /audio/transcribe`(REST,绕 4KB)→ 文本 → **WS `chat`**(落库 + 流式 + 编排)→ 回复 → `POST /audio/speech`(**返回裸音频字节,非 `{data}` 信封**)→ `AudioTrack`/ExoPlayer 播放,支持打断。
|
||||
- **不用 `voice_input` 提交对话**:该路径不落库、不过编排器、无 `message_persisted`(`02/C1`)。
|
||||
- 权限:`RECORD_AUDIO` 运行时申请;录音期间可选前台服务。
|
||||
|
||||
---
|
||||
|
||||
## 8. 里程碑与验收
|
||||
|
||||
| 里程碑 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| **M0 骨架** | Gradle + 依赖 + Miuix 主题 + 空壳首页 | `./gradlew :app:assembleDebug` 通过,App 起得来 |
|
||||
| **M1 登录** | 服务器地址可配 + 错误分类 + EncryptedPrefs + 自动登录 + 401 登出 | 错地址有本地文案、对地址能进;杀进程重开免登;token 失效自动回登录 |
|
||||
| **M2 只读主会话** | WS 连接 + `history` + REST 分页 + 倒序渲染 + 来源角标 + 会话列表回退 | 断网重开列表不白屏;进主会话看到历史;上拉翻页无重复;每条标来源 |
|
||||
| **M3 发送 + 流式** | `chat` 上行 + 乐观消息 + 流式气泡 + 忙碌/队列状态 + 失败重试 | 发消息立即上屏;流式逐字出;Web 端同步可见;忙时提示排队 |
|
||||
| **M4 富渲染** | `<p>` 拆气泡 + 旁白 + thinking/工具卡 + 图片 | 多气泡与旁白位置正确;流式半截标签不崩;图片正常显示 |
|
||||
| **M5 多会话 + 侧边栏** | 自研 Drawer + 新建/切换/删除 + 序号持久化 | 汉堡开合顺滑;次级可建可删;主会话禁删;重启记得上次会话 |
|
||||
| **M6 打磨** | 设置页、空态/错误态、无障碍、性能 | 字体放大不炸;冷启动 < 1.5s;无内存泄漏 |
|
||||
|
||||
**P1**:语音 spike → 全屏助手 UI → 语音闭环。
|
||||
**P2**:管理员页只读监控、IoT、主动推送(依赖 `02/D1`)、多角色。
|
||||
|
||||
---
|
||||
|
||||
## 9. 待定项
|
||||
|
||||
1. 消息历史快照是否改用 `EncryptedFile`(当前建议:私有目录 + 关备份,P0 够用)。
|
||||
2. 管理员页 P0 是否只占位(当前建议:是)。
|
||||
3. `CONTRACT.md` 是否现在就建(建议 M2 之前建)。
|
||||
4. 语音 spike 的具体验证清单与机器。
|
||||
@@ -1,138 +0,0 @@
|
||||
# 04 — 构建环境与开发流程
|
||||
|
||||
> 现在**开发全程在 PC(Meowhome)上**:写码、编译、单测都在那边;**后端(Cyrene v2 主项目)也已迁到同一台 PC**。
|
||||
> 本文记录 PC 的环境、镜像配置、日常流程和踩过的坑,换机器/重装时照抄即可。
|
||||
> (旧开发机是一台性能较弱的 aarch64 Linux 容器,已停用;相关记录见 §5 备查。)
|
||||
> 最后更新:2026-10-03
|
||||
|
||||
---
|
||||
|
||||
## 1. 机器分工
|
||||
|
||||
| 角色 | 机器 | 系统 / 架构 | 接入方式 | 干什么 |
|
||||
|---|---|---|---|---|
|
||||
| **开发 + 编译** | `Meowhome`(主机名 `AEiva`) | Windows / **AMD64** | `ssh Meowhome`(`cd.yeij.top:40022`,密钥 `~/.ssh/id_ed25519_meowhome`) | 写码、`gradlew.bat` 编译、跑单测 |
|
||||
| **后端** | 同一台 PC | — | 同上 | Cyrene v2 主项目 |
|
||||
|
||||
> 后端现在跑在这台 PC 上;App 的「服务器地址」填 PC 上的后端地址(模拟器 `10.0.2.2`,真机走局域网 IP;**具体端口以主人给的为准**)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 版本组合(钉死,别随意升)
|
||||
|
||||
| 项 | 版本 | 备注 |
|
||||
|---|---|---|
|
||||
| AGP | **9.2.1** | 9.x 起**内置 Kotlin**,不能再应用 `org.jetbrains.kotlin.android` |
|
||||
| Kotlin | **2.4.0** | 由 Miuix 0.9.3 的元数据牵引 |
|
||||
| Compose BOM | **2026.05.01** | 它 pin 的 `androidx.compose:ui:1.11.2` 正好等于 Miuix 0.9.3 在 Android 上的映射 |
|
||||
| Miuix | **0.9.3** | 只有 0.9.x;AAR 要求 `minCompileSdk=37` |
|
||||
| Gradle | **9.7.1** | wrapper 已配腾讯镜像 |
|
||||
| JDK | 本机 17 / PC **21** | AGP 9 需要 17+ |
|
||||
| minSdk / compileSdk / targetSdk | 33 / 37 / 37 | 33 起是为了解锁 `miuix-blur` |
|
||||
| buildToolsVersion | **37.0.0** | 必须显式指定,否则 AGP 默认找 36.0.0 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 本机(aarch64 容器)
|
||||
|
||||
SDK:`~/Android/Sdk`
|
||||
|
||||
| 组件 | 来源 |
|
||||
|---|---|
|
||||
| `platforms/android-37.0` | 腾讯镜像 `platform-37.0_r02.zip` 手工解压 + **手写 `package.xml`** |
|
||||
| `build-tools/37.0.0` | **ARM64 原生版**,来自 [Commit451/android-arm-build-tools](https://github.com/Commit451/android-arm-build-tools)(release 资源需经 `https://ghproxy.net/` 代理下载),逐个校验 SHA256 |
|
||||
| `licenses/` | `android-sdk-license` / `android-sdk-preview-license`(标准哈希) |
|
||||
|
||||
本机专用配置放在 **`~/.gradle/gradle.properties`**(不进仓库):
|
||||
|
||||
```properties
|
||||
android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/37.0.0/aapt2
|
||||
android.builder.sdkDownload=false
|
||||
```
|
||||
|
||||
- 第一条:Google Maven 的 aapt2 只有 x86_64,aarch64 必须指向本地 ARM 版。
|
||||
- 第二条:本机访问不了 `dl.google.com`,禁止 AGP 联网拉 SDK 清单(否则会卡住)。
|
||||
|
||||
镜像:Maven → 阿里云(`settings.gradle.kts`);Gradle 发行包 → 腾讯(`gradle-wrapper.properties`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. Meowhome(Windows)
|
||||
|
||||
SDK:`C:\Users\Aska\AppData\Local\Android\Sdk`(**不在环境变量里**,是 Android Studio 默认位置)
|
||||
|
||||
- `build-tools/37.0.0` 已有(Windows 原生 `aapt2.exe` / `aidl.exe` / `zipalign.exe` / `d8.bat`)
|
||||
- `platforms/android-37.0` **本机没有**,已用腾讯镜像 `platform-37.0_r02.zip` 补装,并补了 `package.xml`
|
||||
- `JAVA_HOME` = `C:\Program Files\Microsoft\jdk-21.0.11.10-hotspot\`
|
||||
|
||||
项目目录:`D:\Project\Code\Uni\Cyrene-For-Android`
|
||||
|
||||
```bat
|
||||
git clone ssh://git@git.yeij.top:2222/AskaEth/Cyrene-For-Android.git -b android .
|
||||
echo sdk.dir=C:/Users/Aska/AppData/Local/Android/Sdk> local.properties
|
||||
gradlew.bat assembleDebug
|
||||
```
|
||||
|
||||
> `local.properties` 在 `.gitignore` 里,每台机器自己建。
|
||||
|
||||
PC 网络:`dl.google.com` 与腾讯镜像均可达(HTTP 200),但**按约定仍走国内镜像**。
|
||||
|
||||
### 4.1 工作副本同步记录(2026-10-03)
|
||||
|
||||
Windows 侧工作副本改为**从本机整树同步**(不再 `git clone`),已含 gitignored 的 `devdocs/`:
|
||||
|
||||
- HEAD = `9bc45dc`(= `origin/android`),工作树干净;83 个源码/资源文件与本机逐字节一致(`local.properties` 例外,各机自建)。
|
||||
- 验收:`gradlew.bat testDebugUnitTest --no-daemon` → **BUILD SUCCESSFUL**(JDK 21,49s)。
|
||||
- 坑:仓库 `core.filemode` 在 Windows 侧要设 `false`(值为 Linux 的 `true` 时,`gradlew` 会因丢失可执行位一直显示 `M`,内容其实没变)。
|
||||
- 同步方法:源码走 `tar | ssh` 解包,**中文文件名的 `devdocs/` 必须用 `scp`**(Windows 自带 bsdtar 解不出 UTF-8 文件名,会写出乱码副本)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 历史:上一阶段的真机流程(已停用)
|
||||
|
||||
> 这一节是上一阶段「把手机当运行目标」的做法,**现在不需要了**,留档备查。
|
||||
|
||||
- 手机(OnePlus PLK110 / Android 16)已 root(KernelSU),`su -c` 可用。
|
||||
- 装 APK(手机上的 Termux sshd,端口 2022):
|
||||
```bash
|
||||
ssh -p 2022 aska@127.0.0.1 'cat > ~/cyrene.apk' < app-debug.apk
|
||||
ssh -p 2022 aska@127.0.0.1 'su -c "cp /data/data/com.termux/files/home/cyrene.apk /data/local/tmp/ \
|
||||
&& chmod 644 /data/local/tmp/cyrene.apk && pm install -r /data/local/tmp/cyrene.apk"'
|
||||
```
|
||||
- 截图 / UI 自动化:
|
||||
```bash
|
||||
su -c "screencap -p /data/data/com.termux/files/home/shot.png; chmod 644 /data/data/com.termux/files/home/shot.png"
|
||||
# 取回:ssh ... 'cat .../shot.png' > local.png(scp 会被 Termux 启动 banner 污染,不能用)
|
||||
```
|
||||
**坑**:中文输入法会把 `//` 打成 `、`、`.` 打成 `。`,用 `input text` 填 URL 会失败。
|
||||
|
||||
---
|
||||
|
||||
## 6. 日常流程
|
||||
|
||||
1. **本机**改代码(+ `devdocs/` 写文档,不进仓库)
|
||||
2. `git commit` → `git push origin android`
|
||||
3. **PC**:`cd /d D:\Project\Code\Uni\Cyrene-For-Android && git pull && gradlew.bat assembleDebug`
|
||||
4. **取回**:`scp Meowhome:D:/Project/Code/Uni/Cyrene-For-Android/app/build/outputs/apk/debug/app-debug.apk .`
|
||||
5. **装真机**(见 §5),看效果
|
||||
|
||||
---
|
||||
|
||||
## 7. 踩坑记录
|
||||
|
||||
| 现象 | 原因 / 解法 |
|
||||
|---|---|
|
||||
| Maven 依赖拉不下来 | `dl.google.com` / `maven.google.com` 不通 → 仓库加阿里云镜像 |
|
||||
| `aapt2: 可执行文件格式错误` | aarch64 机器上跑的是 Google 的 x86_64 二进制 → 换 ARM64 版 + `aapt2FromMavenOverride` |
|
||||
| `Failed to find Platform SDK with path: platforms;android-37.0` | 手工解压的 SDK 包缺 `package.xml` → 补上(从 `android-35` 的模板改) |
|
||||
| `Failed to find Build Tools revision 36.0.0` | AGP 默认 build-tools 版本 → `buildToolsVersion = "37.0.0"` |
|
||||
| `The 'org.jetbrains.kotlin.android' plugin is no longer required` | AGP 9 内置 Kotlin → 移除该插件 |
|
||||
| `Unresolved reference 'KotlinSerializationConverterFactory'` | Retrofit 3 用 `@file:JvmName` 暴露,Kotlin 侧要用扩展函数 `json.asConverterFactory(...)` |
|
||||
| `MiuixIcons.Sidebar` 解析不到 | 实际是 `MiuixIcons.Basic.Sidebar`(文档与 API 有出入,以 AAR 为准) |
|
||||
| `/tmp` 放东西撑内存 | 本机 `/tmp` 是 tmpfs(内存盘)→ 临时文件放 `~/.cache/` |
|
||||
| 填 `http://…` 登录报「网络不可用」 | Android 9+ 默认禁止明文 HTTP,OkHttp 抛 `UnknownServiceException`(属 `IOException`)被映射成无网 → 加 `res/xml/network_security_config.xml` 放行 cleartext + 信任用户 CA |
|
||||
| 模拟器后台启动设了超时 → 到点被收割 | **别给要常驻的进程设超时**。踩过一次:1800s 上限到点把模拟器杀了,`adb reverse` 隧道一起没了 → 模拟器一律不加超时 |
|
||||
| `adb shell … /sdcard/…` 报 `ls: C:/Program: No such file or directory` | Git Bash 把设备路径当 Windows 路径改写 → 命令前加 `MSYS_NO_PATHCONV=1` |
|
||||
| 往 8080 打 `/api/v1/*` 拿到一张 HTML 404 | **这台机器的 8080 是个 PHP 应用**(`X-Powered-By: PHP/8.4.1`),Cyrene 在 **10942**。别把「看起来像后端的 HTML 404」当成「后端起不来」。详见 `05` 的后端一节 |
|
||||
| 模拟器里填 `10.0.2.2` 不通 | 这个 AVD **没有网络接口** → 用 `adb reverse` + `127.0.0.1`。详见 `05` 的模拟器一节 |
|
||||
| 提交时满屏 `LF will be replaced by CRLF` | 纯提示、无害(`core.autocrlf` 归一化);既有文件也一样,非某次改动引入 |
|
||||
@@ -1,418 +0,0 @@
|
||||
> **归档**:本文件由 `05-开发进度与交接.md` 拆出,存放 **W4–W7 起的各历史批次详细记录**与已过时的验收现场记录(原样搬运、按原文顺序,只读追溯)。
|
||||
> 当前状态、待办、家务、纪律与「★ 恢复锚点」各节都在 `05-开发进度与交接.md`;锚点里说的「上面各批次」即指本文件。
|
||||
|
||||
### 本轮追加(2026-10-04 深夜 · 三个 worktree 并行 + 合并验收)
|
||||
|
||||
**合并后 353 测试全绿**,正好 `272+11+33+37`——加法和实际一致,就是「文件集互不相交」成立的证据。合并**零冲突**(merge commit:`51ea890`/`b44e8d9`/`664fd8d`)。
|
||||
|
||||
- **D4 未读**(`f413f8d`):抽屉角标(Miuix `Badge`,放**标题行内**、带 `content-desc="未读 N"`、溢出 `99+`)
|
||||
+ 已读游标。**已实机端到端验证(服务端证据)**:API `unread=6` 且角标「未读 6」→ 点该行 →
|
||||
logcat `POST /sessions/web/read (2-byte body)` → `200` → API `unread=0`、角标消失。
|
||||
读标记策略:**只在两处**推进——`select()`(进入会话)与「`message_persisted` 命中当前会话」;
|
||||
**不在 `refresh()` 里做**(会把设置/管理页打开期间到达的消息静默吞掉)。
|
||||
- **A2 `interrupt scope`**(`b780695`):`WsOutbound.Interrupt` 增可选 `scope`;设置项「她忙时发消息」=
|
||||
**追加队列**(默认档,**根本不发 `interrupt`**)/ **打断**(`scope:"current"`:只取消在跑回合、**保留队列**)。
|
||||
默认档**逐字节等于旧帧**,有测试钉住。**设置项渲染已实机验证**。
|
||||
⚠️ 契约里 `all` 是「**清空 pending**」,所以「追加队列」**不能**映射到 `all`(那样自相矛盾)。
|
||||
> ⚠️ **补记(W25,`feat/W25-interrupt-scope`)**:`b780695` 只做了「**设置项 + 帧形状**」——
|
||||
> **没有任何发送路径读这个偏好**,所以那之后「打断」与「追加队列」行为完全一样(开关空转),
|
||||
> `ChatSocket.sendInterrupt` 也一条帧都没发过。现已接上:判据 `interruptScopeForSend(mode, busy)`,
|
||||
> **默认档恒不发帧**(线上逐字节不变)、选「打断」且**会话正忙**才发 `scope:"current"`、
|
||||
> **命中空闲会话不发**(服务端 no-op,白发只多一次往返);「正忙」只认服务端 `session_busy` 的
|
||||
> `busyMode != null`,**不用** `queueDepth`(队列非零 ≠ 有回合在跑)。接线点在
|
||||
> `ChatViewModel.deliver()` 的通道分支之前,每次投递**最多一条**打断帧。
|
||||
- **B1 `/upload`**(`cc2f266`):图片分流——**重编码后 >64 KiB 走上传(历史只留 URL),否则内联 base64**。
|
||||
阈值理由是「内联的成本是**发多少次**而非发一次」:服务端把 `image` 原样落库 ⇒ 每页历史/每次 WS 回放
|
||||
都重传。✅ **已实机端到端验证**(2026-10-04 深夜):209 KB 图经 Photo Picker 发出 → logcat
|
||||
`POST /api/v1/upload (96764B)` → `201 Created` → `POST /api/v1/chat (243B body)`(243 字节 ⇒ 带的是
|
||||
**URL 不是 base64**)→ 落库 `image` = `…/api/v1/files/<32hex>.png` → **冷启动 force-stop 重开后仍渲染**
|
||||
(Coil 网络 fetcher 正常)→ 且**模型真的读懂了图**(那张图是模拟器截图,她逐条点评了图里的设置项)。
|
||||
⚠️ 未变:`url` 绑死请求时的 `r.Host`(换服务器地址后旧图取不回,`02/F6①`);上传产物**无删除端点**(`02/F7`)。
|
||||
|
||||
**下一步(顺序已定)**:① ✅ **已完成**(W3 上传路径实机验收,见上)→ ② **分享入口**(`ACTION_SEND` /
|
||||
`ACTION_SEND_MULTIPLE` + `singleTop`/`onNewIntent` + pending-share 持有者 + 登录后落地)→
|
||||
③ 搜索 / 编辑删除 / `mode` / `after` → ④ 概览「按路径」可读性 → ⑤ 超限走文件(按 v0.59)。
|
||||
|
||||
> ⚠️ **更正上文「当前状态」**:`664fd8d` 已过时。最新提交 **`a6b4bae`**(= `origin/android`,四个模块全部已推)。
|
||||
|
||||
**并行批次 W4–W7 —— ✅ 全部完成、已合并、已推送**(2026-10-04 深夜)。按开发纲要「多个并行子代理协同」
|
||||
切分:四个 worktree、**文件集互不相交**(`D:/Project/Code/Uni/Cyrene-Android-W{4,5,6,7}`,分支
|
||||
`feat/W4-overview` / `feat/W5-persona` / `feat/W6-documents` / `feat/W7-share`,全部从 `664fd8d` 切出):
|
||||
|
||||
| 模块 | 内容 | 测试增量 | 合并点 |
|
||||
|---|---|---|---|
|
||||
| W4 | 概览页「按路径」26 条的可读性重做(分组/折叠,Miuix) | 353 → 370(+17) | `f91a855` → `a6b4bae` |
|
||||
| W5 | 新建会话时**选角色**(`POST /sessions {personaId}`,v0.42) | 376 → 408(+32) | `217cfe3` → `557cdb2` |
|
||||
| W6 | 文档/文件进对话(`documents`)**数据层 + 纯逻辑**,不碰 UI/VM | 408 → 468(+60) | `095d6e7` → `a80dac7` |
|
||||
| W7 | **分享到 App**(`ACTION_SEND(_MULTIPLE)` + `singleTop`/`onNewIntent` + 进程级暂存) | 353 → 376(+23) | `ae4e4e5` → `4834905` |
|
||||
|
||||
**四轮合并在主树跑的都是 `assembleDebug` + `testDebugUnitTest --rerun-tasks`**(不是 FROM-CACHE),
|
||||
**最终 485 测试 / 0 失败 / 0 错误**(= 353 + 17 + 32 + 60 + 23,加法逐轮吻合——这正是「文件集互不相交」
|
||||
成立的证据)。每轮我都自己读了 `app/build/test-results/testDebugUnitTest/*.xml` 求和,**不采信子代理自述**。
|
||||
|
||||
- **A1 / D14 完成**(`d12a920`,2026-10-04):`WsEvent` 的所有结构化流式事件类**透传帧壳上的 `sessionId`**
|
||||
(此前解析器把该字段丢掉),`ChatViewModel.onEvent` 用 `WsEvent.isForSession` **按会话过滤**——
|
||||
`sessionId` 为空仍放行(行为与修复前一致)。回归测试 `data/ws/WsEventSessionScopeTest.kt`(+16)。
|
||||
**D14「同一时刻只有一个会话在流式」退役**;核实结论:它**从来只是文档约定**,代码里从未强制
|
||||
(`ChatSocket.sendInterrupt` 至今零调用方)
|
||||
- **M0–M5 全部完成**,M4 完整(多气泡 / 旁白 / 思维链 / 工具卡 / 图片)
|
||||
- 交互打磨 B 完成(回车发送、文本选择模式、自动跟随底部、IME 修复)
|
||||
- 设置页 C-1 完成(主题 / 旁白 / 思维链默认展开 / 关于)
|
||||
- **C-2 管理员只读监控完成并已实跑验证**:概览(metrics + trace)/ 人格 / 记忆 / 后台思考
|
||||
四页只读,按 RBAC 分档自动降级(viewer 只能进概览 + 人格),管理区为可加页的壳 + 独立 `Scaffold`
|
||||
- **B4 完成**(`09a3222`):接入 `GET /api/v1/meta/enums` 权威枚举表,删掉客户端硬编码的
|
||||
`platform` / `mood` / `mode` 映射表——顺带修掉一处既有漂移(客户端把 `web` 显示成「Web」,
|
||||
权威表是「网页」)
|
||||
- **B2 完成**(`f557654`):接入 `/auth/refresh`,**登录口令不再落盘**(迁移对用户无感);登出补调
|
||||
`/auth/logout`;可恢复的 401 不再登出;并发 401 由单飞刷新 + 代际守卫兜住
|
||||
- **A4 / D5 / B3 完成**(`c09a211`):会话列表改用**服务端标题**(本地「会话 N」序号机器整块退役)、
|
||||
置顶(主会话与次级都可)、最后消息预览;并补上**抽屉打开时静默刷新**——此前只在初始化 /
|
||||
新建 / 删除时拉取,所以这些内容要重启 App 才可见;顺带修掉「快照指纹闸门没实装」与
|
||||
「预览剥标签导致旁白与正文粘连」两处既有缺陷
|
||||
- **`history` 串台已修**(`2a49c25`):WS 首帧 `history` 是**主会话**的回放,此前被无过滤地并进
|
||||
每一个 `ChatViewModel` → 进次级会话会看到主会话的消息。现按**消息自身的 `sessionId`** 过滤
|
||||
(`historyForSession`,空值放行)
|
||||
- **工具执行三帧已接**(2026-10-04):后端契约 v0.50 把 `tool_execution` 拆成
|
||||
`start` / `update` / `end`(载荷 `{tool, phase, id?, delta?}`,**无 `index`**,不属于模型流)。
|
||||
此前本端只映射 `.update` 且读的是 `tool_call.*` 的 DTO(`start` / `end` 落到未知类型分支被
|
||||
**静默丢弃**,而 `.update` 后端**没有生产者**)→ 工具执行状态根本到不了 UI。现三帧各有事件类
|
||||
与解码 DTO,`ChatViewModel` 按 **`id`** 把它们关联到工具卡(`toolExecutionTargetIndex`;`id`
|
||||
缺席时只在**无歧义**时才落位,绝不默认落第一张),工具卡标题/配色由 `phase` 驱动
|
||||
`running → 执行中` / `done → 完成` / `error → 失败`。回归测试
|
||||
`data/ws/WsEventToolExecutionTest.kt` + `ui/chat/ToolExecutionCorrelationTest.kt`
|
||||
|
||||
**待做**
|
||||
1. **遗留(本端,均为既有问题)**:
|
||||
- **① 连接所有权**:每个 `ChatScreen` 的 VM 各建一条 `ChatSocket`,而 VM 存在 **Activity 级
|
||||
`ViewModelStore`** 里,所以**访问过的每个会话都永久留一条连接**(含各自的 `OkHttpClient`
|
||||
与连接池),**删会话也不回收**;后端还会把每帧广播给每条连接。**已不再串台**(`d12a920`
|
||||
加了会话闸),现在纯粹是资源 / 生命周期成本。自然解法:**共享一条 socket + 按会话路由**。
|
||||
- **② `ChatSocket.sendInterrupt` 零调用方**——「切会话时打断」这个出口从未接线,是死 API。
|
||||
(原 ② 的工具执行帧解析缺口已修,见「当前状态」最后一条。)
|
||||
2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠,
|
||||
待主人定
|
||||
3. 子会话流程(主人已确认搁置)
|
||||
4. 图片 / 工具卡实机验证(后端暂未配置工具)
|
||||
2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠,
|
||||
待主人定
|
||||
3. 子会话流程(主人已确认搁置)
|
||||
4. 图片 / 工具卡实机验证(后端暂未配置工具)
|
||||
|
||||
**接下来可做的大块**(后端都已就绪,几乎全是纯客户端欠账):
|
||||
|
||||
1. **REST `POST /api/v1/chat`(`stream:true`)** —— 当前性价比最高的一步:
|
||||
- 解决「**长文发不出去**」:WS 帧硬上限 4096 字节,且**超限是直接断连**(不是报个错),
|
||||
而客户端**只有 WS**
|
||||
- 解决「**图片完全发不了**」:大图走 REST + base64(**先缩放 / 重编码**,见 `02/G3`)
|
||||
- 是「分享到 App」的前置(`02/F1–F4`)
|
||||
- **已侦察确认**:REST 发起的回合**会**在已连接的 WS 上流式回来(`stream:true` 与
|
||||
`stream:false` 都会,同一个 orchestrator + 广播路径),所以能做到「**REST 发、WS 收**」,
|
||||
**不牺牲流式体验**
|
||||
- ⚠️ 两个坑必须一起处理:`stream:true` 走**有损总线**且**先回 200**,可能静默什么都没发生
|
||||
(`02/G2`)→ 以 `message_persisted` 为准 + **绝不自动重试**;全链路**无幂等**(`02/G4`)
|
||||
2. ~~**顺带解除 A1 / D14**:后端 v0.36 起流帧**已带 `sessionId` + `turnId`**,是本端 `WsEvent`
|
||||
把字段丢了(`02/G1`)→ 透传并按会话过滤,即可退役「同一时刻只有一个会话在流式」这条长期约束~~
|
||||
→ ✅ **已完成**(2026-10-04):`WsEvent` 全部流式事件类透传 `sessionId`,`ChatViewModel.onEvent`
|
||||
按 `isForSession` 过滤(`null` 仍放行);回归测试 `data/ws/WsEventSessionScopeTest.kt`。
|
||||
D14 **确认只是文档约定**,代码里从未强制。**未做**:连接所有权(每 VM 一条 socket、旧 socket 不关)
|
||||
仍是遗留问题
|
||||
3. 其余纯客户端欠账:A3 `mode` / D2 会话内搜索 / D3 消息编辑删除 / A5 正向增量 /
|
||||
D4 未读(后端 v0.37 已实现)
|
||||
4. **M6 打磨**(空态错误态、字体放大、冷启动 <1.5s、内存泄漏)与 **P1 语音**
|
||||
(先真机 spike,验不过就换触发方案)
|
||||
|
||||
## ② 分享到 App —— 三条路径全部实机通过(2026-10-04 深夜)
|
||||
|
||||
APK = 主树 `a6b4bae` 的产物(`app/build/outputs/apk/debug/app-debug.apk`,46 MB),模拟器 `Pixel_10_Pro_XL`,
|
||||
后端 `adb reverse` 到 `127.0.0.1:10942`。
|
||||
|
||||
| 路径 | 证据 |
|
||||
|---|---|
|
||||
| **装的 APK 真的在分享候选里** | `pm query-activities -a android.intent.action.SEND -t text/plain` 回 `top.yeij.cyrene.app.MainActivity` 且 `launchMode=LAUNCH_SINGLE_TOP`;`-t image/png` 同样在 |
|
||||
| 分享菜单出现 Cyrene | `am start -a android.intent.action.SEND …` → 系统 sharesheet 列出 **Cyrene**(第一条),点中即投递 |
|
||||
| **热启动 · 文本** | 落进会话的是**原样**的 user 气泡(正文一个标记都没加),她读懂了内容;走的 **WS**(logcat 里没有 `POST /chat`,符合「短文本走 WS」) |
|
||||
| **热启动 · 图片** | `POST /api/v1/upload (96764B)` → `201` → `POST /api/v1/chat (243B)` → `200`(243 字节 = **URL 形态**);落库 `image` = `http://127.0.0.1:10942/api/v1/files/6b442b20….png`;气泡**渲染出了图**,且她**读懂了图里的内容** |
|
||||
| **未登录态先暂存** | 登出 → 把分享 intent 投给 App → 界面**仍是登录页**、logcat **零 POST/GET**(只暂存、不发) |
|
||||
| **登录后自动落地** | 登录(`POST /auth/login` → `200`)后**无需任何手动操作**,那条分享文本自动落进会话(DB 实查),她还回了话 |
|
||||
|
||||
**测试痕迹已清理**:临时会话已删;误落进主会话 `web` 的两条测试消息用 `DELETE /api/v1/messages/{id}` 删除
|
||||
(各回 200),主会话已复原。**服务端另有 5 个孤儿上传文件**(`/api/v1/files/{name}`,无删除端点,见 `02/F7`)。
|
||||
|
||||
### 本轮的坑(下个会话直接用)
|
||||
|
||||
- **`am start` 的 extra 值里不能带空格**:`--es k "a b"` 会被**远端 shell 再拆一次**,`am` 把第二个词当成
|
||||
`pkg`,报 `unable to resolve Intent … pkg=…`。写成无空格字符串,或整体加引号。
|
||||
- **`uiautomator dump` 会静默失败并留下旧文件**:转场/动画期间它可能 dump 不出来,这时 `cat /sdcard/ui.xml`
|
||||
拿到的是**上一次的快照**(本轮据此误判过「还停在分享菜单上」)。对策:**每次用唯一文件名**
|
||||
(`/sdcard/ui$(date +%s%N).xml`)并检查输出是否为 `UI hierchary dumped to: …`。
|
||||
- **sharesheet 里的「Direct Share」卡片(`Share with Cyrene` 那种圆角推荐行)对 `input tap` 无反应**:
|
||||
本轮三次点击都没用,而同一菜单里的**普通应用行**(`Cyrene`)一点就中。⇒ 验收时优先点普通应用行;
|
||||
或直接 `am start -n top.yeij.cyrene/.app.MainActivity -a …`,输出里的
|
||||
`intent has been delivered to currently running top-most instance` 就说明走的是 `onNewIntent`(热启动)。
|
||||
- 抽 `uiautomator` 属性时别用 `s/.*(text|content-desc)="([^"]*)"…/`:`.*` 贪婪,会匹配到后面那个
|
||||
`content-desc` 并把值抽成空。**只匹配 ` text="…"`**(带前导空格)才稳。
|
||||
|
||||
### 本轮之后的新欠账(客户端侧,按优先级)
|
||||
|
||||
1. **可观测性下行帧没接**:契约 v0.55 有 `observability_metrics` / `observability_trace` / `observability_log`
|
||||
(owner/admin,订阅 = 读一次对应 REST),客户端**完全没有 handler**,概览页仍是「进入 / 手动刷新各拉一次
|
||||
REST」。接上才能实时刷新(要动 `WsEvent`)。
|
||||
2. **文档附件的 VM/UI 接线**(W6 只做到数据层):清单在 `02` 的「接线时要改哪几处」一节
|
||||
(`AppContainer` → `ChatViewModel` 的待发附件与回滚 → `sendViaRest` 带 `documents` → `MessageBubble` 渲染 chips)。
|
||||
**两个产品决定要主人拍板**:正文转文件前要不要先问用户;转成文件后正文还发不发。
|
||||
3. **分享的已知边界**:进程被杀会丢待落地内容(持有者在内存里);未登录期间界面**不提示**「有一条分享在等登录」;
|
||||
分享一律落在**当前会话**(不能选会话/角色);多图 = 多回合(4 张 = 4 条消息)。
|
||||
4. **搜索 / 编辑删除 / `after`** 仍是欠账(`mode` 已随轮次落库,客户端 `A3` 已完成)。
|
||||
|
||||
### 交付物
|
||||
|
||||
- 可安装 debug APK:`app/build/outputs/apk/debug/app-debug.apk`(主树 `a6b4bae` 构建,**已实机跑过上面全部路径**)
|
||||
- 仓库:`android` @ `a6b4bae` = `origin/android`,工作区干净,**485 测试 / 0 失败**
|
||||
|
||||
## W8–W10 批次 + 芯片修复(2026-10-04 深夜)
|
||||
|
||||
**四个模块全部合并、已推 `origin/android`,主树每一轮都跑 `assembleDebug` + `testDebugUnitTest --rerun-tasks`**(不是 FROM-CACHE),逐轮测试数我自己读 JUnit XML 求和:
|
||||
|
||||
| 模块 | 内容 | 测试 | 提交 |
|
||||
|---|---|---|---|
|
||||
| W8 | 设置页「版本」行可点 → **关于页**(应用名 / 版本取自 `BuildConfig` / 简介 / 作者 `AskaEth`) | 485 → 487 | `3ed2377` → `f24877d` |
|
||||
| W10 | 接上**可观测性三帧**(`observability_metrics/trace/log`),概览页实时刷新 + 「最近日志」区块 | 487 → 516 | `b5e6f78` → `93941cd` |
|
||||
| W9 | **文档附件全链路接线**:选文件入口(SAF)/ 待发芯片 / 上传 → `chat.documents` / 气泡芯片 / 分享进来的**文件**走文档通道 | 516 → 567 | `30180ab` → `ab76798` |
|
||||
| — | 契约 v0.59 已发布 → 更正 W6 那批「未发布」注释(纯注释) | 567 | `2973391` |
|
||||
| — | `devdocs/` **纳入版本库**(`.gitignore` 移除该条) | — | `2369dd2` |
|
||||
| — | **附件芯片改换行排布**(修主人发现的「多附件被裁到屏幕外」) | 567 | `ca5596a` |
|
||||
|
||||
**已实机验证**:关于页(点版本进 → 返回一次到设置、再一次到聊天,层级正确);附件芯片换行(主会话那条**三附件**消息现在渲染成三行、三枚芯片与各自的状态行全部可见)。
|
||||
|
||||
### 踩到的坑(重要,别再重复)
|
||||
|
||||
- **模拟器「未响应」弹窗的根因是 AVD 内存配小了**:`hw.ramSize=2048` / `vm.heapSize=256`,而镜像是 **API 37 / 16k page / 1344×2992@480dpi**。冷启 2 分钟后 guest `load average` 冲到 **52.96**,`system_server` / `SystemUI` 被反复挤压 → 不停弹 `isn't responding`。**修法(不动 AVD 配置)**:启动时加 `-memory 6144`;另把闲置的 Gradle daemon 停掉(`gradlew --stop`,两个 daemon 白占 4 GB)→ 宿主空闲内存 5 GB → 13.9 GB,6 GB 后 guest 负载降到 ~20、**ANR 归零**。若要固化,改 `config.ini` 的 `hw.ramSize`(那文件在项目外,需主人点头)。
|
||||
- **在跑构建时 `adb reboot` 会把模拟器卡死在开机中途**:`sys.boot_completed` 永远空、`dev.bootcomplete` 空、logcat 里 init 反复报 `Could not find 'aidl/activity' for ctl.interface_start`(= `system_server` 没起来),此时 `adb install` 会**永远挂住**。**修法:`adb emu kill` 后冷启**,别指望它自己好。
|
||||
- **`uiautomator dump` 会静默失败并留下旧文件** → 每次用唯一文件名;**`am start` 的 extra 值不能带空格**(远端 shell 会再拆一次);sharesheet 的 **Direct Share 卡片点不动 `input tap`**,点普通应用行或用 `-n` 直投。
|
||||
- **双击坐标复用会点错**:本轮「记住了」的坐标在两次会话里分别落到了「添加文件附件」(意外开了 DocumentsUI,反而算半个正面证据)和「与昔涟」顶栏标题上。**每次都从新 dump 取 bounds**,并按 x/y 区间消歧(抽屉行 vs 顶栏标题同名)。
|
||||
- **提交信息里别用反引号**:`git commit -m "…`文件名`…"` 在双引号里会被 bash 当命令替换执行,信息被吃掉一段(本轮真踩了,已用 `-F 消息文件` amend 修回)。**含反引号的提交信息一律走 `-F`。**
|
||||
|
||||
### 契约侧(已登记 `02`)
|
||||
|
||||
- **`api-client.md` 已发布 v0.59**(§1.2 文档存储与摄取契约 + 端点表 16/17)。我们实现的形状**逐条一致**(`file` 部件名、201/200+`deduped`、20 MiB、≤5、只收 `name`、相对 `url`、嗅探基类型、`documents` 数组含 `chars`/`truncated`/`status`、0 个附件时该键省略、4 个错误码);白名单在契约里也已更正为 **22 项**。
|
||||
- 新登记的 `02` 项:`F7`(上传产物无删除端点 ⇒ 客户端无法清理孤儿)、可观测性订阅**会被「没有在线 WS 连接」剪掉**(客户端必须持连接)、日志**没有历史引导**(本端还没接 `GET /admin/observability/logs`,所以「最近日志」只显示进页面之后产生的)。
|
||||
- **`G5` 已由后端落地**(`pending_full` 带 `clientMsgId`)⇒ 队列满时客户端从此能**精确定位是哪条气泡被丢**;本端还没接(现状仍会让那条气泡永远挂「发送中…」)——**这是下一批的候选**。
|
||||
|
||||
### 主人给了一个调试用 `X-API-Key`(**owner 级**,值不写进任何文件)
|
||||
|
||||
`GET /api/v1/sessions`、`/api/v1/admin/observability/{metrics,logs}` 全部 200 —— 联调可以**不再用口令登录**(更省事也更干净)。用法:`curl -H "X-API-Key: <值>"`。
|
||||
|
||||
### 新欠账(按优先级)
|
||||
|
||||
1. **附件预览 / 打开**(主人 2026-10-04 问的):`DocumentRepository.download(name)` 在 W6 就写好了、**零调用方**;消息 DTO 只带引用与统计(契约刻意),所以预览要**按需拉**。要做三件:① `FileProvider`(**manifest 现在没有任何 provider**,`file://` 交给别的 App 会抛 `FileUriExposedException`)② 下载到 `cacheDir/documents/<name>`(名字是内容寻址 + 服务端 `immutable` ⇒ 缓存永不失效)③ 预览要**截断并如实说明**(20 MiB 文本不能一把塞进 `Text`)。**两个决定待主人拍**:点芯片的默认动作(App 内预览 / 交给别的 App / 两者都给)与截断阈值。⚠️ `/documents/{name}` **要 Bearer**,所以**不能**照图片那样让 Coil 直接啃(Coil 用的不是挂了 `AuthInterceptor` 的那个 client)。
|
||||
2. **`pending_full` 的 `clientMsgId` 接线**(后端已就绪,见上)。
|
||||
3. **`GET /admin/observability/logs` 接线**(给「最近日志」补历史引导)。
|
||||
4. 搜索 / 编辑删除 / `after` 仍未动;可观测性 `observability_audit` 后端仍未做(审计仍是 REST)。
|
||||
|
||||
## W11–W12 批次 + 一个实机抓到的真 bug(2026-10-04 深夜)
|
||||
|
||||
| 模块 | 内容 | 测试 | 提交 |
|
||||
|---|---|---|---|
|
||||
| W12 | 「最近日志」接上 `GET /admin/observability/logs` 引导读取(历史 + 帧增量;**viewer 有历史、无增量**) | 567 → 591 | `642bdb1` → `a7dbee8` |
|
||||
| W11 | **附件预览**:点芯片开弹层(云端按需拉、256 KB / 5000 行先到者为准、分块渲染)+「用其它应用打开」「保存到下载目录」(FileProvider) | 567 → 610 | `27b6910` → `60b0eac` |
|
||||
| — | **修**:保存附件被 SAF 补成双扩展名(`.md` → `.md.txt`) | 634 | `ffa7ed2` |
|
||||
|
||||
**合并后 634 测试 / 0 失败**(= 567 + 24 + 43,加法逐轮吻合)。
|
||||
|
||||
### 这两块的实机验证(这次抓得很细)
|
||||
|
||||
- **W12**:概览页三路并发 `GET /admin/observability/{logs?limit=200, metrics, trace?limit=100}` **全 200** —— logs 的 REST 引导**确实发出去了**,这就是 W10 那个「只显示进页面之后」缺口的闭环证据。
|
||||
- **W11 附件预览**(用的就是 `E2E-DOC` 会话里那条真实附件):
|
||||
- 点芯片 → 弹层里**显示的正是从服务端取回的内容**(我那张纸条的正文);logcat 硬证据:
|
||||
`GET /api/v1/documents/809320….md` → **200(95 字节)**;
|
||||
- **零 `FileUriExposedException`**;
|
||||
- 「用其它应用打开」在这台**裸模拟器没有能读 `text/plain` 的应用**时,如实回了一句「没有应用能打开……可以『保存到下载目录』后再处理」,**没有静默失败**;
|
||||
- 「保存到下载目录」→ SAF 的建议名 = **内容寻址存储名**(界面里同时如实写明「原始文件名不会返回给客户端」)→ 落盘 95 字节、内容与弹层一致。
|
||||
- **抓到的真 bug(已修)**:保存那条一开始落盘成 `…e.md.txt` —— `CreateDocument` 传的是**嗅探基类型**(`.md` 也报 `text/plain`),SAF 就按它补了 `.txt`。**修法:保存这条路传 `*/*`**(没有可补的扩展名),而**打开那条路继续用 mime**(那里要的正是「能读懂这段内容的类型」)。重测落盘为 `…e.md`。
|
||||
|
||||
### 新坑
|
||||
|
||||
- **GMS 自己会 ANR**:模拟器上 `Google Play services isn't responding` 会挡在界面前面(**与我们的 App 无关**,6 GB 内存也照样偶发)。对策:`adb shell am force-stop com.google.android.gms`,立刻清掉接着干活。
|
||||
- **`uiautomator dump` 的静默失败要包重试**:本轮把它包成「重试 3 次 + 检查文件非空」才稳定下来。
|
||||
- **「预览 / 打开 / 保存」三条出口的类型选择是不一样的**(见上),别互相套用。
|
||||
|
||||
### 仍欠(按顺序)
|
||||
|
||||
1. **`F7` / 原始文件名**:契约不给 `originalName` ⇒ 保存只能用内容寻址的存储名;已把这件事**写在界面上**(不编假名)。若后端补 `originalName`,客户端只需改一处。
|
||||
2. **`pending_full` 的 `clientMsgId` 接线**(后端已就绪 —— `05` 旧条目里的 G5)。
|
||||
3. 搜索 / 消息编辑删除 / `after`。
|
||||
4. `observability_audit` 帧(后端未做,审计仍是 REST)。
|
||||
|
||||
> 另:`E2E-DOC` 测试会话**暂时保留**当后端异常的现场证据(纯附件回合没有助手消息 + 两次 `knowledge` 工具失败,见 `02` 的最新登记);对面回执说可删就删。
|
||||
|
||||
## W13–W14 批次(2026-10-04 深夜)
|
||||
|
||||
| 模块 | 内容 | 测试 | 提交 |
|
||||
|---|---|---|---|
|
||||
| W13 | 队列满时**定位并处置那条待发气泡**(`pending_full` / 定向 `chat_busy` 的 `clientMsgId`)——修「气泡永远挂发送中…」 | 634 → 664 | `3b4a790` → `9854cc4` |
|
||||
| W14 | 点气泡里的图片进**全屏查看**(双击/捏合缩放 + 拖动 + 边界钳制,返回键只关弹层)+「用其它应用打开 / 保存到下载目录」 | 664 → 706 | `557ef3f` → `f109846` |
|
||||
|
||||
**合并后 706 测试 / 0 失败**(= 634 + 30 + 42,加法吻合)。`android` = `origin/android`。
|
||||
|
||||
### W13 的判定顺序(**证据强度优先,改之前先读**)
|
||||
|
||||
1. `clientMsgId` 命中某条待发气泡:`messageId` **非空 ⇒ 收编**(那条其实早就落库/被认领了,不能当失败);**缺席 ⇒ 回滚**(正文/图片/附件一起还给输入栏 + 一句人话)。
|
||||
2. `clientMsgId` **有值但不命中 ⇒ Ignore**:带键的帧只属于某一次具体发送,键对不上就不是本端这条。**同一帧到两次也落这一档** —— 这是「广播 + 定向双到达」的幂等来源(不靠去重表,靠状态本身:气泡已经不在待发列表里了)。
|
||||
3. `clientMsgId` **缺席 ⇒ 不猜**(老服务端 / 那条消息本就没带键):待发为空 → Ignore;**恰好一条 → 回滚它**;**两条及以上 → 只提示、一个气泡都不动**。沿用 `toolExecutionTargetIndex` 那条先例。
|
||||
|
||||
### ⚠️ W13 顺手查出的两条「文档与实现不符」
|
||||
|
||||
- `PendingMessage` 的 KDoc 写着「手动重试可以复用同一个 id」,但 `emitAndDeliver` **每次发送都新铸 `localId`** ⇒ 若某次失败其实是「服务端已收下、客户端判成失败」,用户手动重试**会变成第二条消息**(重复)。**要不要做「回滚后按内容指纹复用原键」是产品决定,本模块没擅自改**(改了才对得上 KDoc,但那是行为变更)。
|
||||
- REST `409 chat_busy` 的旧文案已与 WS 侧**统一成同一句**(同一个 code 不该有两种说法)。
|
||||
- 另:`WsEvent` 里**没有新增独立事件类**,而是扩了既有 `Error` 并保留 `code` 原文——因为定向 `error{code:"chat_busy"}` 与既有 `error` 是**同一个 `type`**,一个 type 无法用两个事件类表达。
|
||||
|
||||
### W14 实机验收(都在这台模拟器上点过)
|
||||
|
||||
- 弹层渲染正常(图片本体 + 两条出口 + 「原始文件名不会返回给客户端」那句诚实说明);**返回键只关弹层**,回到聊天、没退到抽屉/桌面。
|
||||
- 保存落盘 `748a6a73….png`(**96,585 字节,没有被补成 `.png.png`**)—— W11 那条「保存一律传 `*/*`」的结论在图片上同样成立。
|
||||
- ⚠️ **取字节那条路 logcat 看不到**:W14 对能力 URL(`/api/v1/files/{name}`,免鉴权)用的是**裸 OkHttp 客户端**(不发 Bearer、**也不挂 logging**)⇒ 证据改为**设备上的缓存文件**:`cache/documents/748a6a73….png`(96,585 字节,与保存出来的一致)。
|
||||
- **未验**:手势手感(捏合焦点 / 双击判定 / touch slop)只有单测保证;Coil 的 `File` 分支没在真机确认;**待发图**有 20 MiB 上限(发送时 `ChatImagePreparer` 会缩到 ≤5 MiB,所以**落库后**的同一张图正常 —— 只有待发气泡那一段是死胡同)。
|
||||
|
||||
### 后端回执七要点(已入库 `9e03986`)
|
||||
|
||||
- **大附件那道坎修好了**(`51c1672`):输入护栏上限不再硬编码 262144,改为按推导预算(1M 窗口 ⇒ **699,050 字符**)+新 env `GUARDRAIL_MAX_INPUT_CHARS` ⇒ **可以恢复大文件路径**;「多份各自撞注入预算」还差一刀(G2b 在跑)。**这也解释了主会话那条 726 KB 的 `.txt` 回合为什么回 `input_length_exceeded`**。
|
||||
- **`X-API-Key` 是纯内存表**:进程重启即全部失效 —— **实测已经 `401`**(后端在这期间重启过)。要长期用 key 需等 K1 落 SQLite,或请主人再签发一个。
|
||||
- IM 侧:bridge 摄取已通,但适配器只上报 URL/ID 不带字节 ⇒ 真机目前只会得到「无法读取:渠道只给了远程引用」。
|
||||
- 面板将新增只读 `GET /api/v1/admin/system/storage`,契约升 **v0.60**(纯新增,客户端忽略未知字段即可)。
|
||||
|
||||
## W15–W16 批次(2026-10-04 深夜)
|
||||
|
||||
| 模块 | 内容 | 测试 | 提交 |
|
||||
|---|---|---|---|
|
||||
| W15 | 手动重试同一条内容时**复用原幂等键**(改过的内容照旧铸新键) | 706 → 729 | `af11a94` → `8b90703` |
|
||||
| W16 | **会话内搜索 D2**(顶栏入口 + 搜索面板 + 点结果定位) | 729 → 737 | `23b46f9` → 见 merge commit |
|
||||
|
||||
**合并后 737 测试 / 0 失败**(= 706 + 23 + 31,加法吻合)。`android` = `origin/android`。
|
||||
|
||||
### W15:三条必须记住的取舍(**改之前先读**)
|
||||
|
||||
1. **指纹是「值比较」不是哈希**:摘要一旦碰撞,两条**不同**的消息会共用一个键、后一条被服务端判重**静默丢掉**。比较只发生在进程内,值比较碰撞面为零、也没有收益可图。
|
||||
2. **正文一个字都不归一**:只多敲一个空格也算「改过」。反过来(归一后再比)会让**用户真打算发的两条消息并成一条**,而「改了却复用旧键 ⇒ 服务端判重 ⇒ 编辑被静默丢掉」才是真正要防的那一半。
|
||||
3. **晚到的 `message_persisted` 刻意不清键**:那条帧恰恰是「服务端其实收下了、客户端当时判成失败」的**证据**;键留着,用户的重试才会被判重而不是落成第二条。**谁按「成功即清」的字面去那里加一句 `forget`,就把这个 case 打回原样。**
|
||||
4. 附件只看**本地 URI(有序)**、不看 `displayName`/`sizeBytes`:那两项是元数据,同一份文件两次查询完全可能一次报得出、一次报不出,不影响「是不是同一份文件」。
|
||||
|
||||
**待主人确认的产品取舍**:记忆是**单槽**(只记最近一次失败那条)。正常流程下更早失败那条的内容早已不在输入栏,所以单槽够用;要改成多槽说一声。
|
||||
|
||||
### W16:搜索的落地形状
|
||||
|
||||
- 入口在**聊天页顶栏**;面板**叠在整页之上**(自带顶栏 + 输入框自动聚焦 + IME 搜索键 + 旁边的放大镜按钮);结果 = 角色 / 时间 / 命中片段(命中子串加底色);**四态分明**(未搜 / 搜索中 / **空结果** / **失败**,失败**绝不**画成「没找到」)。
|
||||
- 片段先过 `domain/parse` 的 `parse()` 再拼回**纯文本**(正文/旁白/动作全保留),**绝不把 `<p>`/`<action>` 当富文本**;只在纯文本上加底色。
|
||||
- **分页**:滚到末尾自动 `before + limit`;翻页失败**不清空已有结果**、尾部红字 + 重试、**不自动重试**。
|
||||
- **点结果**:在已加载窗口内 → 关面板 + 滚动定位 + 横幅;**不在窗口内 → 如实说**(按时间分三种措辞)。**没有插临时气泡**——列表是 `reverseLayout`,凭空插一条几千条前的消息会出现在「刚发出的位置」、与自己的时间戳自相矛盾,还会带偏 `followBottom`。要真正并进消息窗得改 `ChatViewModel`(本模块不许碰)⇒ **列为下一批**。
|
||||
- **状态归属**:`SessionSearchViewModel`,按会话各一份(`viewModel(key = "session-search:<id>")`),随 Activity 存活(关面板不丢结果)。
|
||||
- **返回键**:`ChatScreen` 内新增的 `BackHandler(enabled = searchOpen)` 注册在 `ChatHomeScreen` 那几条之后 ⇒ 优先级更高,**先关搜索**;搜索没开时 disabled,既有抽屉/管理页/设置页层级**一个字节没动**。
|
||||
- **未验**:UI 全部没实机跑过(自动聚焦 + IME、高亮对比度、滚动落点、图标长相都只有编译 + 推演);`q` 只验到 Retrofit 层(对本地假服务端断言原始 URI),**服务端是否真按契约实现 `q` 没验**。
|
||||
|
||||
### 新登记(见 `02`)
|
||||
|
||||
W16 报上来六条**契约没写透**的口径,其中两条值得扫一眼:**`before` 是含还是不含游标**(契约只对 `after` 明说)、**`q` 匹配的是含标记的原始 `content`**(搜 `action` 会命中、但我们**不画高亮**,不假装命中)。
|
||||
|
||||
## 装机验收轮(2026-10-04 深夜 · 设备证据存档)
|
||||
|
||||
主人要求「每项都要**设备证据**,不能只看编译过了」。设备:模拟器 `Pixel_10_Pro_XL`(`-memory 6144`),App 的服务器地址 `http://127.0.0.1:10942`,`adb reverse tcp:10942 tcp:10942`。
|
||||
|
||||
### ② W18 `.pdf` 非文本拒收 —— ✅ 通过
|
||||
|
||||
用 API 造了一份**带文本层的真 PDF**(自拼对象 + 正确 xref,563 字节,文本 `Cyrene PDF e2e: meow-314`)→ `POST /documents` → `POST /chat` 挂进会话 `E2E-PDF`。落库形状:
|
||||
`documents: [{"name":"0efe0a2a….pdf","mime":"application/pdf","size":563,"chars":24,"status":"extracted"}]`
|
||||
(**`chars:24` 正好等于那份 PDF 里那句文本的长度** ⇒ 文本层真被抽取;也顺带证明我拼的 PDF 是合法的。)
|
||||
|
||||
设备上点那枚芯片,弹层显示:
|
||||
|
||||
```
|
||||
附件预览
|
||||
0efe0a2a13c2583568904105946f91a6.pdf
|
||||
PDF · 563 B · 24 字 · 已读入
|
||||
这是 PDF,App 内不预览这类内容,请用「用其它应用打开」或「保存到下载目录」
|
||||
用其它应用打开 / 保存到下载目录 / 关闭
|
||||
服务端只保留了内容寻址的存储名…原始文件名不会返回给客户端
|
||||
```
|
||||
|
||||
⇒ **正文区是那句人话,不是一屏二进制** —— 洞堵住了。截图 `app/build/e2e-shots/w18-pdf-preview.png`。
|
||||
(同时印证了登记进 `02` 的那条「措辞别扭」:芯片说**已读入**、弹层说**不预览**,两句都真。)
|
||||
|
||||
### ③ W15 重试复用幂等键 —— ✅ **可验部分**通过(另半边由单测背书)
|
||||
|
||||
会话 `E2E-RETRY`:
|
||||
|
||||
| 步骤 | 结果 |
|
||||
|---|---|
|
||||
| **硬断开**(`adb kill-server` 掐断 reverse 上的在途连接)→ 点发送 | **失败回滚**:正文 `W15-retry-2` **被还回输入框**,没有变成卡死的「发送中…」气泡 |
|
||||
| 恢复隧道 → 等 WS 重连 → **原样重发** | 服务端**只有 1 条**该内容的消息(**不重复**) |
|
||||
| 落库 | `clientMsgId: local-7c99433b-…` —— 幂等键确实在链路上 |
|
||||
|
||||
⚠️ **如实标明边界**:「复用**同一个** id」那半边在本机**证不出来** —— 那次失败根本没到服务端,所以 id 从未被服务端见过,重试是「铸新键 + 正常收下」,结果同样是**一条**。那半边的证据只有单测(W15 的 13+10、W17 的 16、W21 的 +7,其中一条专门钉住「失败记键 → 重发复用 → duplicate → 屏幕上只剩一条」)。**不许把它说成「设备上验过了」。**
|
||||
|
||||
### ④ W19 消息编辑删除 —— ✅ 通过
|
||||
|
||||
| 环节 | 证据 |
|
||||
|---|---|
|
||||
| 角色门 | 同一条 `⋯`(`content-desc="消息操作"`):**user** 消息 → 「编辑 + 删除」;**assistant** 消息 → **只有「删除」** |
|
||||
| 编辑弹层 | 标题「编辑这条消息」、内容框里是**原始 content**、右下角**码点计数 `11 / 4000`** |
|
||||
| 编辑执行 | logcat **`PATCH /api/v1/messages/696cdf76…`(32 字节)→ `200`**,恰好一次;服务端同一条 id 内容变成 `W15-retry-2-EDITED`(**原位改、无第二条气泡**) |
|
||||
| 二次确认 | 「删除这条消息?」/「它会从所有端移除,且不可恢复。」 |
|
||||
| 删除执行 | logcat **`DELETE /api/v1/messages/f9c53d22…` → `200`**(56 字节);服务端那条 assistant **已消失**、界面气泡消失 |
|
||||
|
||||
### ① W16 会话内搜索 —— ✅ **通过**(⚠️ 并附一条我自己制造的假警报与教训)
|
||||
|
||||
**结论先改**:这一项**本来就是好的**。下文保留那条「不可用」的原委,以免重犯。
|
||||
|
||||
真正的设备证据(都取到了):
|
||||
|
||||
| 环节 | 证据 |
|
||||
|---|---|
|
||||
| 入口 | 顶栏右上角放大镜 `content-desc="搜索会话里的消息"`;它的**可点父节点**是 `[1164,162][1308,306]`(144px = 48dp 标准触控区) |
|
||||
| 打开面板 | **像素确认**:不透明盖满整页、顶栏「搜索会话」+ ✕、输入框**自动聚焦**(蓝色边框)且 IME 弹起、中间「输入关键词,搜索这个会话里的消息」 |
|
||||
| 搜索 | 输入 `W15` 提交 → logcat **`GET …/messages?limit=30&q=W15` → `200`(583 字节)**;顶栏「**2 条命中**」+ 两行结果(`我 / W15-retry-2-EDITED / 10-04 13:32`、`我 / W15-key-test / 10-04 13:30`) |
|
||||
| 点结果定位 | 面板关闭 + 顶部横幅「**已定位到这条消息**」 |
|
||||
| 返回键层级 | 再开面板(dump 数到「搜索会话」= 1)→ 按返回 → **像素确认回到聊天页**(面板关、IME 收起、消息与输入栏都在),`topResumedActivity` 仍 `MainActivity`(没退到抽屉/桌面) |
|
||||
|
||||
**⚠️ 那条假警报的原委(值得记,别再犯)**:我先前连点两次入口后 dump,都「看到聊天页、且多出一个 `EditText [36,339][1176,509]`」,于是判「面板没渲染、尺寸塌了」。**真相是 `uiautomator dump` 在「面板弹出 + IME 动画」期间会静默失败,而我读到了上一次的旧文件**——把「一个正常的输入框」读成了「面板塌掉的残骸」。改用**像素**(`screencap`)一看,面板好端端盖着整页。
|
||||
|
||||
**代价与教训**:我为此派了两个代理去修「非 bug」(W20 零输出挂死 14 分钟、W22 被停掉),**都零改动、没污染仓库**。两条纪律:
|
||||
1. **动代码之前先用像素确认症状**(`uiautomator dump` 只用来取坐标,不用来判定「界面有没有变化」);
|
||||
2. 当**症状与读出来的结构矛盾**时(根容器 `fillMaxSize()` + 不透明底色 ⇒ 结构上不可能「看不见」),**先怀疑自己的测量**,再怀疑代码。
|
||||
|
||||
**另记一处「不是错但不该这么写」**:`SessionSearchPanel.kt:105` 在 `Column` 里给子项用了 `Modifier.fillMaxSize()`(正确写法是 `weight(1f)`)。实测**当前不影响观感**(面板内容居中正常),所以**我没有改**——没有证据的改动只会引入风险,留在这里备案。
|
||||
|
||||
### 本轮新增的坑(客户端侧方法论,值得单独记)
|
||||
|
||||
1. **`adb reverse --remove tcp:10942` 断不开已经建立的连接**(reverse 规则只影响**新建**连接)⇒ 拿它造「发送失败」是**无效**的(那次发送照样成功落库)。要真断得用 `adb kill-server` / `start-server`。
|
||||
2. **两个子代理同时跑 Gradle 会抢 `~/.gradle/caches` 的文件锁**,表现就是**零输出挂死**(本次 W20 静默 14 分钟就是它)。⇒ 给子代理加「**某条命令 3 分钟无输出就报告**」的约束;也别让它们跑多轮 `--rerun-tasks`。
|
||||
3. **「文件还在」≠「消息还在」**(后端已确认:他们现在有 GC,未引用且超 7 天的文档会被删;`GET /api/v1/files/<name>` 只证明字节在)。⇒ 别拿它当存活证据。
|
||||
4. **`mysql`/`uiautomator dump` 在转场期会静默失败并留下旧文件** ⇒ 唯一文件名 + 重试 3 次 + 检查非空(这条早就记过,本轮又踩到一次)。
|
||||
|
||||
### 验收用的会话(**都留着,未清理**)
|
||||
|
||||
`E2E-DOC`(纯附件回合没助手消息的现场)、`E2E-PDF`(带 PDF 附件、回合正常)、`E2E-RETRY`(含一条**被编辑过**的消息 `W15-retry-2-EDITED`)。主人说清再清。
|
||||
|
||||
## W27(2026-10-04 深夜 · 死代码清理 + `03` 四处过时表述更正)
|
||||
|
||||
**两处清理(都不改行为)**
|
||||
|
||||
- **删 `AdminAccess.availablePages()`**(`domain/admin/AdminAccess.kt`):全仓(含 `app/src/test`)**调用点 0 处**——
|
||||
`app/src/main` 里只有它自己的声明,测试里 2 处(`AdminAccessTest` 的那 1 条用例,一并删掉)。
|
||||
它已被 UI 那套做法取代:`ui/admin/AdminHomeScreen.kt:101,116` 遍历 `AdminPage.entries` + 每行 `canAccess` 决定是否禁用。
|
||||
- **`ChatImageBudget.SERVER_MAX_CHARS` 不再自己重写膨胀公式**:原来它把 `base64Chars` 的
|
||||
`((n + 2) / 3) * 4` 又内联写了一遍(同一公式两份真相)⇒ 改为
|
||||
`val SERVER_MAX_CHARS = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS`。
|
||||
⚠️ 代价:该常量由 `const val` 变 `val`(`const` 不允许调用函数)。**取值逐位不变 = `6990572`**
|
||||
(5 MiB ⇒ base64 膨胀 6990508 字符 + 前缀余量 64),由 `ChatSendPolicyTest` 的「独立重算 → 相等」断言继续钉住。
|
||||
|
||||
**`devdocs/03` 四处就地更正**(一律 ~~删除线~~ 保留原文 + 「⚠️ 更正」括注,不动别人的回执段落)
|
||||
|
||||
1. §4「重连补全」——`02/A5` 的 `after` 正向增量**早已接入**(`loadAfter` + `ChatViewModel.catchUp`,`28c3142`)。
|
||||
2. §4「忙碌/队列 UI 映射」——`session_interrupted{dropped}` 那句:客户端**只发** `scope:"current"`,
|
||||
该档 `dropped` **恒为 0**、`all` **从不发** ⇒「丢弃 N 条」在今天的客户端里**不可达**。
|
||||
3. §5「mode 回落」——`mode` **已落库**(后端 `b686d5f`),`MessageBubble.kt:110` 按 `mode` 分支。
|
||||
4. §4「打断模式」——「限制一次只发一条」**已由 W25 修掉**(补记本就写在案,本轮补上依据 `cfdc094` / `feat/W25-interrupt-scope`)。
|
||||
|
||||
> ⚠️ **顺手查到但本轮没动**:`data/ws/WsEvent.kt:272` 的 `SessionInterrupted` KDoc 也写着
|
||||
> 「当前任务取消、pending 清空,`dropped` 为丢弃条数」——与上面第 2 条同一个过时前提。
|
||||
> 它不在本轮允许改动的文件清单里,留给下一个动那个文件的会话顺手更正。
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,96 +0,0 @@
|
||||
# 06 — 开发工作迁至 Meowhome(交接说明)
|
||||
|
||||
> **开发全程在 Meowhome(Windows PC)上进行**:写码、编译、跑单测都在那边。
|
||||
> 后端(Cyrene v2 主项目)**也已迁到同一台 PC**。
|
||||
> 本目录(`devdocs/`)已随本次交接复制到 PC 的项目目录下。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么迁
|
||||
|
||||
旧开发机是一台性能较弱的 **aarch64 Linux 容器**,编译会把桌面卡死。之前的流程是「旧机写码 → 推仓库 → PC 编译」两段式;现在整段开发都放 PC,省掉来回同步和 SSH 搬运。
|
||||
|
||||
---
|
||||
|
||||
## 2. 开发机:Meowhome
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 接入 | `ssh Meowhome`(`cd.yeij.top:40022`,用户 `Aska`,密钥 `~/.ssh/id_ed25519_meowhome`) |
|
||||
| 系统 | Windows(主机名 `AEiva`,AMD64) |
|
||||
| 项目目录 | `D:\Project\Code\Uni\Cyrene-For-Android` |
|
||||
| Android SDK | `C:\Users\Aska\AppData\Local\Android\Sdk`(已装 `platforms/android-37.0` + `build-tools/37.0.0`) |
|
||||
| `JAVA_HOME` | `C:\Program Files\Microsoft\jdk-21.0.11.10-hotspot\` |
|
||||
| 构建 | `cd /d D:\Project\Code\Uni\Cyrene-For-Android & gradlew.bat assembleDebug` |
|
||||
| 单测 | `gradlew.bat testDebugUnitTest` |
|
||||
| 镜像 | Maven→阿里云、Gradle 发行包→腾讯(都已在仓库配置里,开箱即用) |
|
||||
|
||||
`local.properties`(不进仓库)已建好,指向上面的 SDK。
|
||||
|
||||
> `D:\...\Cyrene-For-Android` 里还有一份**未被 git 跟踪**的 `devdocs\`(本次交接复制过来的),`git status` 不会显示它。
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端
|
||||
|
||||
**Cyrene v2 主项目也已迁到这台 PC**,不再跑在别处。
|
||||
|
||||
App 里的「服务器地址」指向本机后端即可:
|
||||
|
||||
- 模拟器:走 `10.0.2.2`
|
||||
- 真机 / 局域网设备:走 PC 的局域网 IP
|
||||
|
||||
**具体地址与端口以主人给的为准,不要自己假设。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 仓库
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 分支 | **`android`**(不用 `main`,`dev` 是 4 个月前的弃用项目) |
|
||||
| 当前提交 | `9bc45dc`,工作区干净,已与 origin 同步 |
|
||||
| 远端 | `ssh://git@git.yeij.top:2222/AskaEth/Cyrene-For-Android.git` |
|
||||
| git 身份 | **`AskaEth <i@yeij.top>`**(PC 端如未配置,执行 `git config user.name AskaEth` / `git config user.email i@yeij.top`) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 文档清单(都在 `devdocs/`,不进仓库)
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `DESIGN.md` | 总览与已定决策 |
|
||||
| `01-功能拆解与分步路线.md` | 分步路线 |
|
||||
| `02-前后端协同事项.md` | **后端缺口登记**——开发中一旦遇到后端不支持或「文档写了但没实现」,**当场写进这里并反馈主人** |
|
||||
| `03-客户端细化开发方案.md` | 细化方案 + 里程碑 |
|
||||
| `04-构建环境.md` | 环境、镜像、踩坑记录 |
|
||||
| `05-开发进度与交接.md` | 进度 + 易忘的实现约定 |
|
||||
| `06-移到-PC-开发说明.md` | 本文 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 已完成 / 待办
|
||||
|
||||
**已完成**
|
||||
- M0 工程骨架|M1 登录(含明文 HTTP 放行、自动登录、静默重登、401 登出)
|
||||
- M2 只读主会话(历史 / 倒序 / 上拉分页 / 来源角标)
|
||||
- M3 发送 + 流式(乐观消息、增量解析、光标、自动置底)
|
||||
- M4 完整:`<p>` 多气泡、`<action>/<narration>` 旁白、思维链卡片(自动折叠 + 保留 10 分钟)、工具卡、图片(Coil 3)
|
||||
- M5 侧边栏 + 会话列表(DataStore 快照、序号映射、切换/新建/删除)
|
||||
- 交互打磨:回车发送、文本选择模式(全选只作用于当前气泡)、IME 修复、返回键逐层回退
|
||||
- App 设置页:主题(跟随系统/浅色/深色)、显示旁白、思维链默认展开、版本、登出
|
||||
- 列表项统一为 Miuix `BasicComponent` + `PreferenceCard`(MD3 圆角卡片)
|
||||
|
||||
**待办**
|
||||
- **C-2 管理员只读监控**(概览 / 后台思考 / 记忆 / 人格)——独立导航图 + 独立 `Scaffold`,只读
|
||||
- 子会话流程(主人已确认先搁置,暂不完善)
|
||||
- 图片 / 工具卡实机验证(后端暂未配置工具)
|
||||
|
||||
---
|
||||
|
||||
## 7. 旧开发机
|
||||
|
||||
已停用,仅作历史参考:
|
||||
|
||||
- 编译配置(`~/.gradle/gradle.properties` 里的 `aapt2FromMavenOverride`、`daemon=false`)与 SDK 补装记录见 `04-构建环境.md`
|
||||
- 那台机器的 `/tmp` 是内存盘(tmpfs),别往里放东西
|
||||
@@ -1,87 +0,0 @@
|
||||
# W50「她主动说的话实时到安卓」——验收证据与避坑
|
||||
|
||||
> 状态:**W50 已合并并验证**(`android` 分支 `afee5f6`,已推)。本文件是该批次的**证据与操作细节**;
|
||||
> 状态与待办仍以 `05-开发进度与交接.md` 的锚点补记二十七为准(那里只放结论与指针)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 验收判据与证据(真机 = 模拟器 `emulator-5554`,假后端)
|
||||
|
||||
场景是怎么造出来的:`python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一"`
|
||||
(它按真后端的顺序**先落一行 assistant 历史行、再广播带同一 `id`/`createdAt` 的 `proactive_message` 帧**)。
|
||||
|
||||
| 判据 | 操作 | 证据 | 结果 |
|
||||
|---|---|---|---|
|
||||
| ① 主会话**实时**出现 | 注入后 5s 取 dump | 广播回执 `delivered=1`;dump 里有 `[78,406][400,472] \| 主动消息验收一`,其下 `发送至 网页` | ✅ |
|
||||
| ② 次级会话**不**出现 | 抽屉 → 「新会话」 | 该页 dump 里 `grep -c 主动消息验收一` ⇒ **0**(页面显示「还没有消息」) | ✅ |
|
||||
| ③ 刷新/重启后**仍在且只一条** | ①切回主会话 ②同 `id` 重投一帧 ③`force-stop` 后重启 | ①计数 **1** ②重投后仍 **1**(按 `id` upsert 生效)③重启后 **1** | ✅ |
|
||||
| ④ 无 `id`(后端落库失败)**不静默丢**、也不假装落库 | `--without-id` 注入 → 再重启 | 注入后计数 **1**(本地气泡);重启后 **0**(历史里没有它),而落库那条仍 **1** | ✅ |
|
||||
|
||||
截图(主会话单条助手气泡 + 左下对齐)与 dump 一致,渲染无重叠。判据以 **dump 计数**为准(比截图更硬,也更省上下文)。
|
||||
|
||||
## 2. 环境坑(这次花掉的时间都在这,别再重复)
|
||||
|
||||
1. **`input text` / `input keyevent` 打出的标点会变全角**(`:`→`:`、`.`→`。`、`/`→`/`)。
|
||||
原因:模拟器**系统 locale 是 zh-CN**,Gboard 走中文标点;字母数字不受影响。
|
||||
**绕法(不必改 locale)**:`normalizeBaseUrl`(`app/.../data/api/ApiClientFactory.kt:109`)在缺 scheme 时
|
||||
会补 `http://` ⇒ 服务器地址栏只填**纯字母** `localhost`,再用 `adb reverse tcp:80 tcp:<后端端口>`
|
||||
把设备的 80 端口接到后端。**附带好处:切假后端/真后端只要改 reverse,一个字都不用重打。**
|
||||
2. **`adb reverse tcp:80` 需要 root**:`adb root` 在本 AVD(userdebug)可用,
|
||||
但它**会重启 adbd ⇒ 所有 `adb reverse` 丢失**,之后必须重新加(包括 `tcp:10942`)。
|
||||
3. **点太快会 ANR 被系统强杀**:实测 `ANR in top.yeij.cyrene … Waited 5065ms for MotionEvent(… (367.0, 780.0))`
|
||||
紧跟 `Force finishing activity`。App 冷启动要跑类校验(日志里每条 100~170ms),
|
||||
**等 25~35 秒再动**;并把三个动画开关置 0(`window_animation_scale` / `transition_animation_scale` / `animator_duration_scale`)。
|
||||
4. **屏幕会自己黑**(`svc power stayon true` + `screen_off_timeout` 都不一定挡得住):
|
||||
交互前 `input keyevent 224`,并用 `dumpsys window | grep mCurrentFocus` **确认前台是本 App**——
|
||||
黑屏时点击会落到桌面(这次白跑过两轮),回到桌面后必须重新 `am start`。
|
||||
5. **`adb shell … /sdcard/…` 的路径会被 MSYS 改写**(变成 `C:/Program Files/Git/sdcard/…`)⇒ 命令前
|
||||
`export MSYS_NO_PATHCONV=1`。
|
||||
6. **Python 输出 GBK 会炸**(`UnicodeEncodeError: 'gbk' codec can't encode character`)⇒
|
||||
`export PYTHONIOENCODING=utf-8`(`ws_probe.py` 里那段 `reconfigure` 就是同一件事)。
|
||||
7. **`uiautomator dump` 时好时坏**(`ERROR: null root node returned by UiTestAutomationBridge`):失败就重试;
|
||||
文字/坐标判据一律优先用 dump(`bounds | text`),截图只在需要看观感时用。
|
||||
8. **滑动手势会被系统返回手势吃掉**:从屏幕底部附近起滑会直接回桌面。真要滚动就避开底部区域
|
||||
(例如 `input swipe 540 1500 540 800 300`),别用 `1900 → 700`。
|
||||
9. **登录页的「服务器地址」留空时,App 会回落到上一次记住的地址**(不是报错、也不是用界面上那句示例 `http://192.168.1.10:8080`)。
|
||||
我为此白追了一个小时「请求怎么打到真后端去了」:那一次地址栏恰好是空的(我清过),而当时的 reverse 指向真后端。
|
||||
**教训**:凡是要换后端,先 `uiautomator dump` 确认地址栏里**真有值**(`http://localhost/`),别信「刚才填过」。
|
||||
10. **「登出」是设置页最底部的一个图标按钮**(`ui/settings/SettingsScreen.kt:160`,图标用的是 `MiuixIcons.Back`、
|
||||
只有 `contentDescription = "登出"`,红字):截图上它看起来像「返回」,非常难找。找法:dump 里搜 `content-desc="登出"`
|
||||
(位置在「关于 → 版本」下面 32dp,约 `y≈2109` 处),或先把设置页滑到底。
|
||||
⚠️ 这条同时是个**观感问题**(主人可能根本不知道它在哪),要不要换成带文字的危险行条目属于产品决定。
|
||||
11. 验收结束时的状态:`adb root` 仍是开的;reverse 为 `tcp:80 → tcp:10942` 与 `tcp:10942 → tcp:10942`
|
||||
(都是真后端);App 拿的是假后端签发的 token ⇒ 会在真后端的 401 上登出,登录页地址栏留着 `localhost`、用户名留着上一次填的。
|
||||
|
||||
## 3. 复现整套验收的最短路径
|
||||
|
||||
```bash
|
||||
export PATH="$PATH:/c/Users/Aska/AppData/Local/Android/Sdk/platform-tools"
|
||||
export MSYS_NO_PATHCONV=1 PYTHONIOENCODING=utf-8
|
||||
|
||||
# 1) 假后端 + 反向端口(80 ⇒ 假后端,地址栏填 localhost 即可)
|
||||
python devtools/fake-backend/fake_backend.py --port 18090 &
|
||||
adb root && adb reverse tcp:80 tcp:18090
|
||||
|
||||
# 2) App 侧:清数据 → 登录页填 localhost / 任意用户名密码 → 登录(假后端不校验)
|
||||
adb shell pm clear top.yeij.cyrene
|
||||
adb shell am start -n top.yeij.cyrene/.app.MainActivity # 等 25~35s 再动
|
||||
|
||||
# 3) 注入 + 看判据(四条判据见第 1 节)
|
||||
python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一"
|
||||
python devtools/fake-backend/proactive_inject.py --port 18090 --text "落库失败验收" --without-id
|
||||
```
|
||||
|
||||
## 4. 这个批次顺带发现的客户端缺陷(已随 W50 修掉)
|
||||
|
||||
`proactive_message` 载荷里的 `timestamp` 在服务端是 Go `time.Time` ⇒ 线上是 **RFC3339 字符串**,
|
||||
而客户端旧声明是 `Long?` ⇒ kotlinx 解码抛异常、被 `WsEvent.parse` 的 `runCatching`
|
||||
(`app/.../data/ws/WsEvent.kt:654`)**吞成 null** ⇒ 整帧在成形之前就没了。
|
||||
也就是说:**只加一条 `onEvent` 分支根本碰不到帧**。现在按字面量收(字符串/数字都吃);
|
||||
该字段全仓零消费点,排序去重一律用 `createdAt`。
|
||||
|
||||
## 5. W50 留下的已知窄尾巴(**只在「落库失败」那一档可达**)
|
||||
|
||||
降级气泡是一条 `role=assistant` 的 `messages` 行,而删除入口的判据 `canDeleteMessage` 只看 role ⇒
|
||||
长按能删它,删除会用本地 id(`proactive-local:` 前缀)打服务端并失败报错。
|
||||
**没修的理由**:要收口得给每条消息加「是否服务端行」的标志位,会动 `ChatDisplayItem` 与气泡渲染,
|
||||
超出该批次范围。**最省的收口方式**:在 UI 层按 `proactive-local:` 前缀屏蔽动作入口。
|
||||
@@ -1,120 +0,0 @@
|
||||
# 给后端会话的交接(2026-10-04)
|
||||
|
||||
> 来自 **Android 客户端会话**。我们共享 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 作为交换登记处。
|
||||
> 你在 **v0.56** 里逐条落地了 A2 / G1 / G2 / G3 / G4 / F3,并在 changelog 里点名引用编号——**这套流程运转得很好**。
|
||||
> 本文件是接续:把我**复核到的现状**、**仍未闭环的项**、**我接下来要做的客户端改动**一次交代清。
|
||||
|
||||
---
|
||||
|
||||
## 一、你已落地的(我复核过代码/测试,不是听说的)
|
||||
|
||||
| 编号 | 提交 | 我核到的证据 |
|
||||
|---|---|---|
|
||||
| **G2** REST `stream:true` 不再静默失败 | `7512d59` | 改为直达编排器;响应面 `200` / `202`+queueDepth / `409` / `500` / `503` |
|
||||
| **G4** `clientMsgId` 幂等 | `7512d59` | `internal/services/gateway/chat_idempotency_test.go` 有去重断言 |
|
||||
| **G3** `chat.image` 上限 | v0.56 | `rest.go` 的 `chatImageTooLargeCode`(文案指路 `/upload`) |
|
||||
| **F3** 视觉链路 URL 取回 | v0.56 | `internal/runtime/multimodal/vision/resolver.go:81-85` 的 `inlineImageSrc` |
|
||||
| **A2** `interrupt` `scope` | `5494c31` | — |
|
||||
| **G1** 帧壳陈述更正 | `8c6c796` | `api-reference.md` §6.2 / §6.6 |
|
||||
|
||||
另注意到你多做了 **`3d8822c`(回合内 steer + `steer_ack` 帧)**——客户端**尚未**接,先记下。
|
||||
|
||||
---
|
||||
|
||||
## 二、最要紧的一件:`multimodal: false` 是设计还是漏配?
|
||||
|
||||
**实测的生效配置**:`provider: anthropic` / `model: deepseek-flash` / **`multimodal: false`**。
|
||||
|
||||
**问题**:你自己那份 `DevDocs/reference/deepseek/guides-vision.md`(2026-10-03 抓取)**首句**就写着
|
||||
「`deepseek-flash` 模型支持在文本之外输入图片」。若该标志并非有意:
|
||||
|
||||
1. **图像被降级走视觉工具**,而 `resolveImages` **每回合**都跑(`loop/run.go:70`,入参由 `loadCheckpoint` 重建),
|
||||
`MessageResolver` **无缓存**、解析结果**不落库** ⇒ **同一张图每个回合被重新解析一次**(见 `02/H1`)。
|
||||
2. **模型只拿到一段文字描述,不是图本身** —— 这不只是「贵」,是**有损**。
|
||||
|
||||
**若置为 `multimodal: true`**:走 `KindMain`(`resolver.go:75-85`),**不调任何工具** ⇒ H1 结构上消失,且模型真看到图。
|
||||
**这是「改一个配置项」与「写缓存代码」的差别。**
|
||||
|
||||
### 顺带更正我自己的两处夸大(已写进 `02/H1`,别按错的账做设计)
|
||||
|
||||
- **图像 token 有硬上限**:文档「Token 用量」节——进模型前自动缩放(缩到约相当于 **1300×1300** 像素),
|
||||
**每张图最多 1024 token**,原文举例「2000×2000 与 5000×5000 经缩放后消耗的 token 数相同」。
|
||||
所以**不是**「大图吃一大块预算」。
|
||||
- 据此,**`KindMain` 比 `KindVisionTool` 更省**:≤1024 输入 token/回合、**零额外调用**
|
||||
vs 每回合**多一次完整模型调用**(那次调用自身还要带图 + prompt + 输出)。
|
||||
|
||||
**请回答**:该标志是设计选择还是漏配?若是后者,欢迎直接改,我这边无需改动。
|
||||
|
||||
---
|
||||
|
||||
## 三、仍未闭环的(按优先级)
|
||||
|
||||
1. **`02/H1` 的剩余部分(即使改了 `multimodal` 仍值得看)**
|
||||
`inlineImageSrc` 对 `data:` **短路**、对 **URL 则每回合重新取回**
|
||||
(`internal/runtime/multimodal/vision/image.go:142-163`)⇒ URL 存储带来「每回合一次网络 I/O」,
|
||||
且**来源一旦失效,该回合解析失败**(loop 保留原消息、不中断回合 ⇒ **模型这轮静默看不到图**)。
|
||||
建议:把「取回 + 内联」的结果**按内容哈希 memo**。
|
||||
|
||||
2. **`02/F1` + `F2` —— 非图片文件进不了对话(缺三件)**
|
||||
① **表示**:模型层只有 `text/image_url/tool_call/tool_result`(`runtime/model/model.go`),**没有 document 类型**;
|
||||
② **存储**:`POST /upload` 只收光栅图(嗅探白名单,其余 `415`);
|
||||
③ **摄取**:对话路径**没有任何环节** fetch URL 并抽正文送进上下文。
|
||||
旁证:IM 适配器层**已有** `adapter.ContentFile` / `MediaFile` 且各渠道都在填,
|
||||
但 **channelbridge 从不读 `msg.Media`**(`inbound.go` 只取 `msg.Content`,还丢弃无文本消息),所以**IM 侧也无先例可抄**。
|
||||
⇒ 主人提的「**分享到 App,超限走文件**」现在就卡在这里。
|
||||
|
||||
3. **`02/F4`(约定问题,动手前必须先定)**
|
||||
把 `[用户分享]` 这类标记**拼进 `content`** 违反项目红线(`design/11 §10.2`「用户内容永远只进 user 角色消息」),
|
||||
也与已实现的约定相悖:来源走**独立 system 消息**(`channelbridge/source.go:25-35`),身份走结构化 `SenderName`,
|
||||
源码注释原话「**文本标签既含糊,又构成提示词注入面**」。
|
||||
建议:来源走**来源 system 消息**或已有的 `deviceId` / `platform` 元数据;人类可见标记放**客户端渲染层**。
|
||||
|
||||
4. **`02/F5`(小)** `PATCH /sessions/{id}` 在 `title` **存在但空白**时回 `title is required`,读起来像「缺字段」。
|
||||
建议区分「字段缺失」与「值非法」两种情况。
|
||||
|
||||
5. **`02/E4` / `E5`(文档漂移,低优)**
|
||||
`/auth/logout` 的响应形状**文档 vs 文档 vs 代码三处互相打架**;`api-reference.md` §3.2 的会话字段已过时,
|
||||
且 `PATCH` / `read` **没有 §3.x 详情章节**。(§6.6 那处你已在 G1 修掉。)
|
||||
|
||||
6. **`02/D4`(未读)** `unreadCount` 已实现,但**已读游标** `POST /sessions/{id}/read` 客户端尚未接,
|
||||
所以「红点只增不减」。客户端侧待排,暂不需要你做什么。
|
||||
|
||||
---
|
||||
|
||||
## 四、来自你那套 DeepSeek 视觉文档的三条提醒(与新链路有交互,值得自查)
|
||||
|
||||
1. **图片只能出现在 `user` 消息**里;`system` / `assistant` 带图回 **400**。
|
||||
⇒ 历史重装配时若把图像部件落到非 user 角色,**整个回合会挂**。
|
||||
2. **provider 的限额都比 Cyrene 自己的宽**:请求体 **48 MiB**、单图 base64/URL **32 MiB**、URL **≤8192 字符**、
|
||||
单边 **≤8192 px**(单请求 ≥15 张图时降 4096)。
|
||||
⇒ **Cyrene 自己的 5 MiB(G3)才是真正的瓶颈**;要支持更大图,放宽这边比适配 provider 更直接。
|
||||
3. **`detail: low`**(缩到 512×512、更省 token)是**现成旋钮**,但由**服务端构造内容块时**决定,客户端设不了。
|
||||
|
||||
---
|
||||
|
||||
## 五、我接下来要做的客户端改动(便于你预判冲突)
|
||||
|
||||
1. **`tool_execution` 三事件**(进行中)
|
||||
客户端此前**只映射了 `.update`**——恰好是你**没有生产者**的那一帧;真正发出的 `.start` / `.end`
|
||||
落进未知类型被**静默丢弃** ⇒ **工具执行状态从未到过界面**。
|
||||
(`ws_bridge.go:191-194` 已写明 `id` 可关联 `tool_call.*`,我会**按 `id` 关联**,不按 `index`。)
|
||||
2. **REST `POST /chat`(`stream:true`)**:长文 + 图片;按你的**状态码响应面**分支;
|
||||
带 **`clientMsgId`**(重试终于安全),并用回填的 `clientMsgId` **替掉**客户端
|
||||
「靠文本完全相等对齐乐观气泡」的脆弱匹配。
|
||||
3. **接入 `interrupt` 的 `scope`**(A2 的客户端半边)。
|
||||
4. **图片策略**:小图 base64、大图走 **`/upload` + URL**(F3 修好后这条路才真正可用)。
|
||||
5. 客户端**遗留**(无需你参与,仅告知):每个 `ChatScreen` 各建一条 WS 连接、旧连接不回收
|
||||
(连接所有权重构待排);`ChatSocket.sendInterrupt` 至今零调用方。
|
||||
|
||||
---
|
||||
|
||||
## 六、协作方式上的两个请求
|
||||
|
||||
1. **改帧 / 字段形状时,请同步 `api-client.md` 并升契约版本号**——我 diff 的就是那个版本号。
|
||||
两次漂移(E4 / E5)都是「代码对了、文档没跟」,我照文档做就会做错。
|
||||
2. **`devdocs/` 不进 git**(已 gitignore),是磁盘上的共享文件。**你的回复直接写进 `02` 对应条目即可**,我读得到;
|
||||
若某项你要**拒绝**或**改口径**,也写进同一条目,**别静默不实现**。
|
||||
|
||||
---
|
||||
|
||||
*本文件由 Android 客户端会话生成;对面可直接在 `02-前后端协同事项.md` 里逐条回。*
|
||||
@@ -1,95 +0,0 @@
|
||||
# Cyrene-For-Android — Cyrene 安卓客户端设计文档(超详细讨论稿 v1)
|
||||
|
||||
> 配套 Cyrene v2(git.yeij.top/AskaEth/Cyrene_v2)的 Android 客户端。
|
||||
> **主人已确认的关键决策**(2026-09-08):
|
||||
> - 主界面 = **常规 AI APP 风格**(会话列表 + 聊天流)。
|
||||
> - **长按电源键唤醒 = 全屏语音助手**(系统级语音助手交互),**不要悬浮球**。
|
||||
> - UI **严格遵守 MIUIx / Miuix 设计风格**:官网 https://compose-miuix-ui.github.io/miuix/zh_CN/ ,组件库 https://compose-miuix-ui.github.io/miuix/zh_CN/components/ 。
|
||||
> - **先做纯文本版本**(P0);语音(P1)后续。
|
||||
> - 顶栏左上角**汉堡菜单**展开**侧边栏**,**预留 IoT 入口 + App 设置页入口**。
|
||||
> - 后端契约详见 `Cyrene v2` 的 `Docs/api-reference.md` + `DevDocs/design/21-安卓app语音助手.md`(本文件为端侧细化)。
|
||||
> 命名:仓库作者 `AskaEth`,日常名 `YeiJi / 叶酱`;App 名 `Cyrene`,角色 `昔涟`。
|
||||
|
||||
---
|
||||
|
||||
## 0. 目录与工程形态
|
||||
- **单 Module**:`app`(Kotlin + Jetpack Compose),Gradle 版本目录。
|
||||
- 依赖:`compose-miuix-ui`(Miuix 组件)、`androidx.lifecycle.viewmodel-compose`、`Retrofit + OkHttp + kotlinx-serialization`、`okhttp`(WebSocket)、`EncryptedSharedPreferences`(JWT)、`hilt`(DI,可选)。
|
||||
- `BuildConfig`:`BASE_URL`(默认可 `http://<host>:8080`,release 换 HTTPS)。
|
||||
- 包结构:`com.askaeth.cyrene.{app,core,data,domain,platform,ui}`(`app`=Activity/导航;`core`=Miuix 主题/Auth;`data`=Retrofit/WS/仓库;`domain`=会话/消息模型;`platform`=语音/权限/电源键;`ui`=各 Screen/组件)。
|
||||
|
||||
## 1. 总体信息架构(功能分类)
|
||||
```
|
||||
Cyrene-For-Android
|
||||
├─ A. 聊天(主界面,AI APP 风格)
|
||||
│ ├─ 会话列表:主会话「与昔涟」(web) + 次级会话(web:<uuid>),可新建/删除/恢复
|
||||
│ └─ 聊天页:气泡流(<p>多气泡、<action>/<narration> 旁白灰斜体仅chat、思维链/工具卡折叠、图片)、输入框+发送、语音按钮(P0占位)
|
||||
├─ B. 侧边栏(汉堡抽屉)
|
||||
│ ├─ 会话/聊天
|
||||
│ ├─ [预留] IoT(设备/场景)入口
|
||||
│ ├─ [预留] App 设置入口
|
||||
│ ├─ [可选] 记忆 / 子代理 / 自主思考(只读)
|
||||
│ └─ 关于 / 登出
|
||||
├─ C. 语音助手(长按电源键唤醒,P1)— 全屏,无悬浮球
|
||||
│ ├─ 展开: 聆听动画 → 已识别文本 → 昔涟回复 → 再听/TTS
|
||||
│ └─ 系统级: AccessibilityService/系统语音助手回调 → 启动助手 Activity(全屏)
|
||||
├─ D. [预留] IoT:设备/场景列表(占位)
|
||||
└─ E. [预留] App 设置:主题(Miuix 深/浅)、通知、语音偏好(ASR/TTS 端侧 vs 后端)、关于
|
||||
```
|
||||
|
||||
## 2. 页面路由(单 Activity + 导航)
|
||||
- `LoginScreen`(首启无 token)→ `HomeScreen`(会话列表,含顶栏汉堡)→ `ChatScreen({sessionId})`(聊天)
|
||||
- `AssistantActivity`(P1 全屏语音助手,长按电源键拉起;无悬浮球,就一个全屏页)
|
||||
- `IotScreen` / `SettingsScreen`(占位页,P0 展示"规划中")
|
||||
|
||||
## 3. 数据层(对照现有契约,P0 纯文本即用)
|
||||
- **认证**:`POST /api/v1/auth/login {username,password}` → `{token,expiresAt,role}`(`role=owner`,单账号)。JWT `Authorization: Bearer`+`X-API-Version: 1`;`EncryptedSharedPreferences` 存 token;**REST 401 → `auth_expired` 清 token → 回登录**;WS 握手失败同理。
|
||||
- **会话**:`GET /api/v1/sessions`(主会话 `id="web"` 恒在);`POST /api/v1/sessions` 建次级;`GET /sessions/{id}/messages?limit=50&before=<ms>&platform=`(**倒序热分页**,`platform=` 过滤);`DELETE /sessions/{id}`(主会话删=403)。
|
||||
- **发消息**:`POST /api/v1/chat {sessionId,text,deviceId,stream?}`(`stream:true`→转 WS;忙时 `202 {queued,queueDepth}`、队满 `409 chat_busy`)。
|
||||
- **WS** `/ws/chat?token=`:上行 `chat`/`interrupt`;下行 `message.start|delta|end`、`thinking.*`、`tool_call.*`、`message_persisted`、`history`(握手后首帧主会话最近 50 条)、`mood`、`session_busy`/`pending_*`/`session_idle`、`voice_output`(P1)。`message_persisted`/`history` 载荷为 `historyMessage`。
|
||||
- **消息 DTO**:`{id,sessionId,role,content,mode?,mood?,image?,thinking?,platform?,deviceId?,senderName?,adapterId?,createdAt}`;`thinking` 仅 owner 可见;主流 `mode` 有 `chat`(含 `<p>`/`<action>/<narration>` 需端侧解析)。
|
||||
- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5 允许自定可读前缀),`POST /chat`/ws `chat` 携带 → 落库标注来源;主会话 `web` 与 Web 共享上下文(multiplatform 同步)。
|
||||
|
||||
## 4. 消息渲染(聊天页核心)
|
||||
- 按 `mode` 决定形态:`chat` 用气泡;其它 `mode`(backthink/工具/审计等 refs)卡片化或折叠。
|
||||
- **`chat` 文本解析**:把 content 按 **`<p>`** 拆成多条气泡(每段一条);`<action>…</action>`/`<narration>…</narration>` 解析为**灰斜体小字旁白**(依附气泡,不独立成气泡);`*…*`/`*…*` 兼容。**非 chat 消息不解析**这些标签。
|
||||
- `thinking`(仅 owner):可折叠「思维链」面板;`tool_call` →「工具卡」(折叠,可看参数/结果)。
|
||||
- 流式:`message.delta` 追加到当前气泡末尾 + 光标;`message.end` 收口。
|
||||
- 图片:`image`(base64 data: URI 或 URL)→ Coil 加载。
|
||||
|
||||
## 5. 语音助手(P1,长按电源键 全屏,无悬浮球)— 端侧与后端协同
|
||||
- **触发**:① 系统级语音助手回调(`VOICE_INTERACTION` / `AssistantService`)或 ② `AccessibilityService` 监听 `KEYCODE_POWER` **长按**(Android 对电源键长按有限制,需在系统「电源键双击/长按」设置里把本 App 设为语音助手,或通过系统辅助「voice assistant」入口)。落地:**声明 `VOICE_INTERACTION_SERVICE`**,长按电源键由系统拉起本 App 的 `AssistantActivity`(全屏)。
|
||||
- **链路**:`AssistantActivity` 聆听(`MediaRecorder`/`AudioRecord`)→ 录音交给 **ASR**(**端侧 or 后端**,见开放问题;后端当前 ASR/TTS 是占位,需先补 design/21 后端补强或端侧 whisper)→ 出文本 → `POST /api/v1/chat {text,deviceId}`(或 WS 流式)→ 昔涟回复 → **TTS** 合成朗读(端侧 e.g. `TextToSpeech` 或后端)。全程单页无悬浮球。
|
||||
- **保活/后台**:可选前台服务(语音持续/推送),权限最小化;无需悬浮球全局浮层。
|
||||
|
||||
## 6. MIUIx / Miuix 映射(紧跟 compose-miuix-ui)
|
||||
- 用 `compose-miuix-ui` 的 `MiuixTheme` + 组件:`TopAppBar`(汉堡/标题)、`Scaffold`/`Drawer`(侧边栏)、气泡用 `BubbleView`/卡片、`TextField`(输入)、`Button`(发送)、`Switch`/`List`(设置)、`Snackbar`/`Dialog`。**禁用原生 Material 组件**,全 Miuix 风格(圆角、毛玻璃、液态玻璃、强调色)。
|
||||
- 顶栏左上汉堡 → 侧边栏(`Drawer`);侧边栏含「IoT」「App 设置」占位入口(P0 点开显示"规划中")。
|
||||
|
||||
## 7. P0(纯文本)MVP 任务清单
|
||||
1. 工程搭建:Gradle + Compose + Miuix 依赖 + `BuildConfig.BASE_URL` + Hilt(可选)。
|
||||
2. `core`:Miuix 主题、AuthManager(EncryptedPrefs 存 JWT、401→auth_expired→登出)。
|
||||
3. `data`:Retrofit(login/sessions/messages/chat)、WS `ChatSocket`(`/ws/chat?token=`,事件分发到 StateFlow)、`ApiClient`(`Authorization: Bearer`+`X-API-Version`)。
|
||||
4. `domain`:`Conference`/`ChatMessage` 模型、`HistoryMessage` 映射、`<p>`/`<action>/<narration>` 解析器。
|
||||
5. `ui`:`LoginScreen` → `HomeScreen`(会话列表)→ `ChatScreen`(气泡流 + 流式 + 思维链/工具卡 + 输入框)。顶栏汉堡侧边栏(IoT/设置/登出)。
|
||||
6. 设备标识:`deviceId=voiceassist:myapp:v1`。
|
||||
7. 验证:登录 → 拉主会话历史 → 发消息 → WS 流式收到回复 → 跨端(web/微信)同步显示。
|
||||
|
||||
## 8. 长线 roadmap
|
||||
- **P0(纯文本)**:⑦ 完成 → 可日常聊天,多端同步。
|
||||
- **P1(语音助手)**:后端补 ASR/TTS(design/21)→ 电源键长按唤起全屏助手 → 聆听/ASR/chat/TTS 朗读闭环 → 无悬浮球。
|
||||
- **P2(智能)**:IoT 入口真实现(设备/场景)、App 设置功能化(主题/通知/语音偏好)、主动推送 + 系统通知、多角色(persona)切换、记忆/子代理/自主思考只读面板、多会话管理增强。
|
||||
|
||||
## 9. 开放问题(待与主人讨论 / 后续敲定)
|
||||
1. **[已定案] 长按电源键**:设置 App 为**系统默认语音助手**(`VOICE_INTERACTION_SERVICE` / `AssistantService`),由系统在长按电源键(或系统语音助手入口)时拉起本 App 的 `AssistantActivity`(全屏),无悬浮球。厂商(MIUI)自家更顺,需在系统「语音助手 / 电源键」设置里把本 App 设为默认。
|
||||
2. **[已定案] ASR/TTS 后端化**:**不在端侧**——因第三方适配器(微信 / KOOK 等 IM 渠道语音)**无法在端侧实现**,且为大后期**语音 / 视频通话**打基础。语音闭环统一走后端真实 ASR / TTS。**依赖**:后端 `voice` 链路当前是降级占位(cloud endpoint 空 → 降级、silent 空音频),需先接通真实 ASR / TTS(本会话 TTS/ASR 进程在做)。Android 语音 P1 依赖这项完成后接入。
|
||||
3. **Miuix 组件精确映射**:到 `components` 页逐组件对照,列出"我们用哪个"。
|
||||
4. **多角色**:现在登录恒 owner,暂无双角色切换;`GET /personas` 可只读展示人格列表。
|
||||
5. **会话管理 UI**:次级会话的新建/切换/删除入口。
|
||||
6. **Hilt vs 手写 DI**:视复杂度定。
|
||||
|
||||
## 10. 红线(必须遵守)
|
||||
- JWT 存储加密(EncryptedPrefs),不落明文/不打日志;401 即登出。
|
||||
- 身份靠 actor 元数据 + owner.yaml 白名单(登录即 owner),不靠提示词。
|
||||
- 语音/音频:权限最小化、显式授权;**不做悬浮球全局浮层**。
|
||||
- 严格 Miuix,禁原生 Material;`Docs/api-reference.md` 契约随实现更新。
|
||||
@@ -306,6 +306,135 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
|
||||
## 8. 端点速查
|
||||
|
||||
### 8.1 语音夹具(接收侧:`voice` 键 + `GET /api/v1/media/{name}`)
|
||||
|
||||
**它解决什么问题**:生产库里**一条带 `voice` 的真消息都没有**(拉 200 条 0 命中),而语音的 `voice`
|
||||
键**只在四个 REST 读取面**填充(契约 v0.79 §4.4 覆盖率)—— ⇒ 想验「语音气泡 + 播放」,必须由服务端
|
||||
造一条**带 `voice` 键的历史消息**。
|
||||
|
||||
`POST /__control/messages` 多两个参数(其余不变):
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|---|---|---|
|
||||
| `voice` | `transcribed` / `empty` / `failed` / `nocopy` / `none`(默认) | 见下表 |
|
||||
| `duration` | 毫秒(默认 `2000`);`0` | `0` ⇒ **不打 `durationMs` 键**(验「缺席就不显示时长」那一档) |
|
||||
|
||||
| `voice=` | `source` / `status` | 音频副本 | 设备上该看到什么 |
|
||||
|---|---|---|---|
|
||||
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文 = 你给的 `text` |
|
||||
| `empty` | `asr` / `empty` | 有 | 播放键照在;正文为空 ⇒ 多一句「没有识别出文字」 |
|
||||
| `failed` | `asr` / `failed` | 有 | 播放键照在;正文由你用 `text=` 给(假后端**不**替你编服务端那句话) |
|
||||
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信 `weixin-ilink` 那条路) |
|
||||
|
||||
服务端那一条:`GET /api/v1/media/{name}` 只认夹具名(`fakevoice0000000000000000000000.wav`),
|
||||
回的是一段**真的 2 秒 440 Hz WAV**(启动时用 `wave` 现生成、16-bit PCM 单声道 8 kHz,不落盘),
|
||||
其余名字一律 `404 media_not_found`。
|
||||
|
||||
```bash
|
||||
B=http://127.0.0.1:18080
|
||||
|
||||
# 1) 灌一条有副本的语音(正文是转写文本)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&text=%E6%98%8E%E5%A4%A9%E5%87%A0%E7%82%B9%E5%87%BA%E5%8F%91"
|
||||
|
||||
# 2) 确认库里那条真的带 voice 键(形状 = 契约 §4.4)
|
||||
curl -s "$B/api/v1/sessions/web/messages?limit=1"
|
||||
|
||||
# 3) 副本字节本身(浏览器/播放器都能验:2 秒 440 Hz)
|
||||
curl -s -o voice.wav "$B/api/v1/media/fakevoice0000000000000000000000.wav"
|
||||
|
||||
# 4) 另外三档(各灌一条,屏幕上应看得出四档的差别)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=empty"
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=failed&text=%E8%BF%99%E6%AE%B5%E8%AF%AD%E9%9F%B3%E6%B2%A1%E8%83%BD%E8%BD%AC%E6%88%90%E6%96%87%E5%AD%97"
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=nocopy"
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&duration=0" # 不显示时长
|
||||
```
|
||||
|
||||
**在 App 上验**(App 已按第 2 节指向本工具):灌完之后**重进聊天页**(或用 `POST /__control/ws-close`
|
||||
踢一次重连触发补拉),那几条语音就会以语音气泡出现。
|
||||
|
||||
> ⚠️ 本工具的 `history` 握手回放**会把 `voice` 一起回放**(它直接回内存里那份消息对象),
|
||||
> 而**真后端不会**(合同 §4.4:`history` 回放不带 `voice`)。所以在这里「重进页面就看到语音气泡」
|
||||
> 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段,
|
||||
> 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。
|
||||
|
||||
### 8.2 图片夹具(灌一条带图的历史消息:`image_file`)
|
||||
|
||||
**它解决什么问题**:要验「点开一张图 → 全屏预览」,手里就得有一条**带 `image` 键**的历史消息;
|
||||
而预览里那些从**字节**现读的东西(分辨率、EXIF 拍摄日期、格式)必须来自**原样的字节**。
|
||||
相册选择器(`PickVisualMedia`)会把选中项**转码**一遍,转码后的副本**没有 EXIF**(实测:一张带
|
||||
`DateTimeOriginal` 的 41 KB JPEG,经它拿到手变成 23 KB 且不含 APP1)——用它验不了拍摄日期这一项。
|
||||
|
||||
本工具不代取外链、也不起静态文件服务,所以给的是 `data:` URI:客户端
|
||||
(`ImageSource.Inline` → `ImageDataUri.bytes`)解出来的就是文件**逐字节相同**的那份内容。
|
||||
|
||||
`POST /__control/messages` 多一个参数(其余不变):
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|---|---|---|
|
||||
| `image_file` | 本地图片路径,扩展名限 `.jpg` / `.jpeg` / `.png` / `.gif` / `.webp` / `.bmp` | 读成 base64 `data:` URI 打进这条消息的 `image` 键。扩展名不在白名单 ⇒ `400`;文件为空 ⇒ `400`(**不**退回 `application/octet-stream`:错的类型会让场景看起来和实际不是一回事) |
|
||||
|
||||
```bash
|
||||
B=http://127.0.0.1:18080
|
||||
|
||||
# 灌一条带图的消息(role=user 才长得像「自己发的一张图」)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%20probe&image_file=D:/tmp/w70/with_exif.jpg"
|
||||
```
|
||||
|
||||
灌完之后**重进聊天页**,那条消息就是一个图片气泡;点它即进 `ImagePreviewDialog`。
|
||||
|
||||
> ⚠️ `data:` 只适合**小图**:整张图的 base64 会随这条消息进每一次历史响应(本工具没有缩略图链路)。
|
||||
> 40 KB 的图没问题,别拿 20 MB 的来灌。
|
||||
>
|
||||
> ⚠️ 若 App 本机已经有一份**本地历史**(此前连过真后端),新灌的消息可能与它并存 / 不被立刻看到:
|
||||
> 要让「服务端历史」成为唯一真相,得清 App 数据重登(`adb shell pm clear top.yeij.cyrene`)——
|
||||
> 这一步会连登录态一起清掉,动手前先想清楚。
|
||||
|
||||
### 8.3 纯附件 / 无正文消息(`documents=` + `text=` 留空)
|
||||
|
||||
**它解决什么问题**:**纯附件消息**(`documents` 非空、`content` 为空)这条形状以前**灌不出来** ——
|
||||
本工具不实现 upload / 摄取链路(§11),`PATCH /api/v1/messages/{id}` 又要求 `content` 非空。
|
||||
于是「没有正文的消息」这一类(纯附件 / 纯图片 / 没转出字的语音)在本机一条都造不出,
|
||||
W72 修的那条「气泡外侧的时间」也就无从验收。
|
||||
|
||||
两个参数一起用就能造出来:
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|---|---|---|
|
||||
| `text` | **空串**(`text=`) | 与「不带 `text`」是**两件事**:不带 ⇒ 沿用默认正文 `seed #N`;`text=` ⇒ 这条 `content` 就是 `""`。带非空值时照旧(支持 `{n}` 占位) |
|
||||
| `documents` | `extracted` / `listed` / `unreadable` / `none`(默认) | 往这条消息的 `documents` 键打**一枚**芯片。三档见下(`none` = 不打这个键,后端本来就是空即省略) |
|
||||
|
||||
| `documents=` | `status` | 设备上那枚芯片(文案出处 `DocumentChips.statusLabel`) |
|
||||
|---|---|---|
|
||||
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
|
||||
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
|
||||
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`;`reason` 那句原话留给附件预览弹层 |
|
||||
|
||||
```bash
|
||||
B=http://127.0.0.1:18080
|
||||
|
||||
# 1) 纯附件消息:content 为空 + 一枚附件芯片
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=extracted"
|
||||
|
||||
# 2) 纯图片消息:同一个招牌的另一半(content 为空,只有 image 键)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&image_file=D:/tmp/w70/with_exif.jpg"
|
||||
|
||||
# 3) 没转出字的语音(正文为空、只有语音形态)
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&voice=empty"
|
||||
|
||||
# 4) 确认那条真的没有正文、也没被塞一个默认正文
|
||||
curl -s "$B/api/v1/sessions/web/messages?limit=1"
|
||||
|
||||
# 5) 对照:真的空气泡(三样都没有)——它走的是 ContentlessNote 那条路,不是本夹具的重点
|
||||
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=none"
|
||||
```
|
||||
|
||||
灌完之后**重进聊天页**(或 `POST /__control/ws-close` 踢一次重连触发补拉)就能看到。
|
||||
`documents` 与 `voice` 不同:真后端**也会**在握手 `history` 回放与 `message_persisted` 里带上它
|
||||
(同一个 DTO,`MessageDto.kt:13` 那一段),所以这里回放出来没有「比真后端更容易」的问题。
|
||||
|
||||
> ⚠️ 这两个参数**只是往消息上打那两个键**,不是摄取链路:文件没有真的被读、正文没有真的进上下文,
|
||||
> 所以附件预览弹层里「服务端读到的那段正文」在本机永远是空的。
|
||||
|
||||
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
|
||||
|
||||
| 端点 | 备注 |
|
||||
@@ -322,13 +451,14 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
| `GET /api/v1/personas` | 用户侧角色目录(不含 admin 专属 `thinking`) |
|
||||
| `PATCH` / `DELETE /api/v1/messages/{id}` | 编辑 / 删除单条,并广播 `message_edited` / `message_deleted` |
|
||||
| `POST /api/v1/chat` | **额外**实现的 REST 写路径(长文 / 图片 / 附件走这条),回 202 + `ChatAckDto` 形状 |
|
||||
| `GET /api/v1/media/{name}` | 语音副本播放端点(契约 §1.2 **U19**):只认夹具名,回 2 秒 WAV;其余 `404 media_not_found`。⚠️ 真后端要 Bearer `owner\|admin`,本工具**不校验鉴权** ⇒ 这里验不到鉴权那一半(见 8.1) |
|
||||
| `GET /ws/chat?token=…` | 手写 WebSocket;握手后立刻发 `history`(回放条数可配) |
|
||||
|
||||
`/__control/**`(同样包信封,但载荷是本工具自己的状态,不是产品契约):
|
||||
|
||||
| 端点 | 作用 |
|
||||
|---|---|
|
||||
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=` | 免费灌 N 条(`text` 支持 `{n}` 占位) |
|
||||
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=&documents=` | 免费灌 N 条(`text` 支持 `{n}` 占位;**`text=` 空串 = 这条没有正文**);`voice=` 灌一条**语音消息**(见 8.1);`image_file=` 灌一条**带图的消息**(见 8.2);`documents=` 灌一条**带附件的消息**(见 8.3) |
|
||||
| `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
|
||||
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
|
||||
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
|
||||
@@ -344,8 +474,11 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
|
||||
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
|
||||
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
|
||||
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。
|
||||
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。
|
||||
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
|
||||
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
|
||||
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
|
||||
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
|
||||
|
||||
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
|
||||
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
|
||||
@@ -361,6 +494,7 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
| `SessionSummaryDto`(`title` / `lastMessage` / `personaId` 可整键缺席) | `app/.../data/api/dto/SessionDto.kt:16-50` |
|
||||
| 会话排序 `pinned DESC, main DESC, created_at ASC` | `SessionDto.kt:8-9` + 线上夹具 `SessionDtoRealPayloadTest.kt:36-38` |
|
||||
| `HistoryMessageDto` | `app/.../data/api/dto/MessageDto.kt:13-46` |
|
||||
| `voice` 键(7 个键名 / 三态 / 四者同生共死)与 `GET /api/v1/media/{name}` | **契约**:`Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」+ §1.2 **U19**(v0.79 已实现)。本工具按它造夹具,**不代表**真后端的逐个字节 |
|
||||
| `after` 升序 / `before` 倒序 / `q` 与 `after` 互斥 | `app/.../data/api/SessionsApi.kt:71-101` + `MessageRepository.kt:79-101` |
|
||||
| limit 默认 50 / 上限 200 | `app/.../data/repository/MessageRepository.kt:149-150` |
|
||||
| `EnumTableDto` 的 6/5/9 张表与 `unknown:"display-raw"` | 线上夹具 `MetaDtoRealPayloadTest.kt:24`(逐字) |
|
||||
@@ -382,9 +516,13 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
|
||||
- **`platform` 过滤**是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传 `null`
|
||||
(`MessageRepository.kt:42,99`),所以这条路径没被真正走过。
|
||||
- **`q` 搜索**是 ASCII 大小写不敏感的子串匹配;`%` / `_` / `\` 不做通配(我根本不解析通配符)。
|
||||
- **不实现**:admin 面全部端点、`documents` / `upload`、`voice_input` 的落库、`mode_switch` 的语义、
|
||||
`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧**只记录、不处置**。因此 App 里
|
||||
管理员页面、附件上传这类路径在这个假后端上跑不通。
|
||||
- **不实现**:admin 面全部端点、`documents` / `upload` 的**摄取链路**(`POST /upload` 与「文件真的被读、
|
||||
正文真的进上下文」那一段;`/__control/messages` 的 `documents=` **只是往消息上打那个键**,见 8.3)、
|
||||
`voice_input` 的落库、
|
||||
`mode_switch` 的语义、`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧
|
||||
**只记录、不处置**。因此 App 里管理员页面、附件上传这类路径在这个假后端上跑不通。
|
||||
⚠️ `GET /api/v1/media/{name}` **实现了**(语音播放那一半,见 8.1),但**不校验 Bearer**
|
||||
—— 真后端那条是 `owner|admin` 端点(契约 U19)。
|
||||
- **主动消息只能靠 `proactive_message` 帧造**:本工具不模拟 `backthink`(真后端也没有 HTTP 入口),
|
||||
所以「她自己决定什么时候开口」这件**服务端**行为在这里验不到 —— 能验的是「帧到了客户端之后」那一段
|
||||
(见第 7 节场景五)。同理,本工具**不会**自动补发 `message_persisted`:真后端走 IM 桥的主动消息会额外
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
@@ -25,6 +27,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import wave
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
@@ -114,6 +117,155 @@ def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
# —— 语音夹具(接收侧那条路:`voice` DTO 键 + `GET /api/v1/media/{name}`)———————
|
||||
#
|
||||
# 为什么要造假音频:**本端没有别的造法**。生产库里一条带 `voice` 的真消息都没有(拉 200 条 0 命中),
|
||||
# 而语音的 `voice` 键**只在四个 REST 读取面**填充、实时帧与握手回放都没有(契约 v0.79 §4.4 覆盖率)
|
||||
# ⇒ 想验「语音气泡 + 播放」,必须有一条**带 voice 键的历史消息**,而它只能由服务端造。
|
||||
#
|
||||
# 这份夹具是**按客户端解出来的形状**写的(`VoiceDto.kt` / `HistoryMessageDto.voice`),
|
||||
# 不是真后端的权威形状;它只负责让那条路在设备上跑起来。
|
||||
VOICE_FIXTURE_NAME = "fakevoice0000000000000000000000.wav"
|
||||
VOICE_FIXTURE_MIME = "audio/wav"
|
||||
VOICE_FIXTURE_DURATION_MS = 2000
|
||||
|
||||
|
||||
def make_voice_wav(duration_ms: int = VOICE_FIXTURE_DURATION_MS, freq: float = 440.0, rate: int = 8000) -> bytes:
|
||||
"""生成一段**真的能播**的 WAV(16-bit PCM 单声道,首尾各 20ms 淡入淡出)。
|
||||
|
||||
为什么是真音频而不是几个字节:这条路验的就是「MediaPlayer 放得出来」。给一串随便的字节,
|
||||
客户端只会走到「这段语音本机播不了」那一档——那验的是失败分支,不是主路径。
|
||||
纯标准库(`wave` + `math`),零依赖;每次启动现生成,不落盘(本工具不留持久数据)。
|
||||
"""
|
||||
frames = max(1, int(rate * duration_ms / 1000))
|
||||
fade = max(1, int(rate * 0.02))
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as handle:
|
||||
handle.setnchannels(1)
|
||||
handle.setsampwidth(2)
|
||||
handle.setframerate(rate)
|
||||
samples = bytearray()
|
||||
for index in range(frames):
|
||||
gain = min(1.0, index / fade, (frames - index) / fade)
|
||||
value = int(12000 * gain * math.sin(2 * math.pi * freq * index / rate))
|
||||
samples += struct.pack("<h", value)
|
||||
handle.writeframes(bytes(samples))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS):
|
||||
"""按控制端点给的种类造一个 `voice` 键的值;`none` ⇒ 不打这个键(返回 None)。
|
||||
|
||||
`kind` 的取值(都对着契约 §4.4 那几档,供设备上一档一档看):
|
||||
|
||||
| kind | `source` / `status` | 音频副本 | 设备上该看到什么 |
|
||||
|---|---|---|---|
|
||||
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文即转写文本 |
|
||||
| `empty` | `asr` / `empty` | 有 | 同上,但正文为空 ⇒ 多一句「没有识别出文字」 |
|
||||
| `failed` | `asr` / `failed` | 有 | 播放键照在,正文是服务端那行提示(假后端不会替你编,用 `text=` 给) |
|
||||
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信那条路) |
|
||||
"""
|
||||
if kind in (None, "", "none"):
|
||||
return None
|
||||
name = VOICE_FIXTURE_NAME if kind != "nocopy" else None
|
||||
duration = duration_ms if duration_ms > 0 else None
|
||||
copy_keys = (
|
||||
{"name": name, "mime": VOICE_FIXTURE_MIME, "size": len(VOICE_WAV), "url": "/api/v1/media/{}".format(name)}
|
||||
if name
|
||||
else {}
|
||||
)
|
||||
source = "platform" if kind == "nocopy" else "asr"
|
||||
status = {"transcribed": "transcribed", "empty": "empty", "failed": "failed", "nocopy": "transcribed"}[kind]
|
||||
payload = {"source": source, "status": status}
|
||||
payload.update(copy_keys)
|
||||
if duration is not None:
|
||||
payload["durationMs"] = duration
|
||||
return payload
|
||||
|
||||
|
||||
# 夹具音频只生成一次(启动时),`size` 与真正发出去的字节因此是同一份。
|
||||
VOICE_WAV = make_voice_wav()
|
||||
|
||||
|
||||
IMAGE_MIME_BY_EXTENSION = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
|
||||
def image_fixture(path: str) -> str:
|
||||
"""本地图片文件 → base64 `data:` URI(`/__control/messages` 的 `image_file` 用它灌一条带图的消息)。
|
||||
|
||||
mime 按扩展名给,白名单与客户端 upload / 附件那一套同源(png / jpeg / gif / webp / bmp);
|
||||
认不出的扩展名**直接报错**而不是编一个 `application/octet-stream`——客户端在字节那一步还会
|
||||
自己嗅探(`ImageFormats.sniff`),这里给错只会让场景看起来和实际不是一回事。
|
||||
|
||||
为什么给 `data:` 而不是 URL:本工具**不代取外链**、也不起静态文件服务。`data:` 让客户端
|
||||
(`ImageSource.Inline` → `ImageDataUri.bytes`)拿到的是**原样的字节**——预览里那些从字节里
|
||||
现读的东西(分辨率、EXIF 拍摄日期)因此与源文件逐字节一致,不会被中间商转码掉。
|
||||
"""
|
||||
extension = os.path.splitext(path)[1].lower()
|
||||
mime = IMAGE_MIME_BY_EXTENSION.get(extension)
|
||||
if mime is None:
|
||||
raise ValueError(
|
||||
"unknown image extension {!r} (expected one of {})".format(
|
||||
extension, ", ".join(sorted(IMAGE_MIME_BY_EXTENSION))
|
||||
)
|
||||
)
|
||||
with open(path, "rb") as handle:
|
||||
raw = handle.read()
|
||||
if not raw:
|
||||
raise ValueError("empty image file: {}".format(path))
|
||||
return "data:{};base64,{}".format(mime, base64.b64encode(raw).decode("ascii"))
|
||||
|
||||
|
||||
# —— 附件夹具(`documents` DTO 键)———————————————————————————————
|
||||
#
|
||||
# 为什么要造它:**纯附件消息**(`documents` 非空、`content` 为空)是「气泡外侧那枚时间」新补的那一档,
|
||||
# 而它在库里**没有别的造法** —— 本工具不实现 upload / 摄取链路(见 README §11),
|
||||
# `PATCH /api/v1/messages/{id}` 又要求 `content` 非空 ⇒ 一条这样的消息以前根本灌不出来。
|
||||
#
|
||||
# 形状按客户端解出来的写(`HistoryMessageDto.documents` → `DocumentRefDto.kt:67`)。与真后端的
|
||||
# 一致处:**空 / 缺失就整键省略**(后端 `omitempty`),客户端把那两种形状都归一成空列表。
|
||||
DOCUMENT_FIXTURE_NAME = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt"
|
||||
|
||||
DOCUMENT_FIXTURE_REASON = "这个夹具不实现真正的摄取,所以没有可注入的正文"
|
||||
|
||||
|
||||
def documents_fixture(kind: str):
|
||||
"""按控制端点给的种类造一个 `documents` 键的值;`none` ⇒ 不打这个键(返回 None)。
|
||||
|
||||
`kind` 三档对应芯片上看得见的那三种样子(文案出处 `DocumentChips.statusLabel`):
|
||||
|
||||
| kind | `status` | 设备上那枚芯片 |
|
||||
|---|---|---|
|
||||
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
|
||||
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
|
||||
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`,`reason` 那句原话留给预览弹层 |
|
||||
|
||||
只给**一枚**芯片:造这一档要验的是「有附件、没有正文时那枚时间挂在哪一块」,不是芯片排的换行。
|
||||
"""
|
||||
if kind in (None, "", "none"):
|
||||
return None
|
||||
if kind not in ("extracted", "listed", "unreadable"):
|
||||
raise KeyError(kind)
|
||||
payload = {
|
||||
"name": DOCUMENT_FIXTURE_NAME,
|
||||
"mime": "text/plain",
|
||||
"size": 12,
|
||||
"status": kind,
|
||||
}
|
||||
if kind == "extracted":
|
||||
payload["chars"] = 10
|
||||
if kind == "unreadable":
|
||||
payload["reason"] = DOCUMENT_FIXTURE_REASON
|
||||
return [payload]
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
@@ -129,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
|
||||
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
|
||||
return "<redacted sha256:{}>".format(digest)
|
||||
|
||||
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
|
||||
BODY_SUMMARY_STRING_MAX = 200
|
||||
|
||||
def summarize_body(raw) -> dict | None:
|
||||
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
|
||||
|
||||
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
|
||||
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
|
||||
数组长度),只把超长字符串换成 `<N chars>`。
|
||||
|
||||
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
|
||||
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
|
||||
也不记大块内容。
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {"bytes": len(raw), "note": "not json"}
|
||||
return _summarize_value(parsed)
|
||||
|
||||
def _summarize_value(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _summarize_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_summarize_value(item) for item in value]
|
||||
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
|
||||
return "<{} chars>".format(len(value))
|
||||
return value
|
||||
|
||||
|
||||
class JsonlLog:
|
||||
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
|
||||
@@ -136,11 +319,17 @@ class JsonlLog:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.lock = threading.Lock()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
self.dir = os.path.dirname(path)
|
||||
os.makedirs(self.dir, exist_ok=True)
|
||||
|
||||
def append(self, record: dict) -> None:
|
||||
line = json.dumps(record, ensure_ascii=False)
|
||||
with self.lock:
|
||||
# 目录**每次写都补一次**:README 说「删掉 `out/` 即清空」,而人在服务跑着的时候删掉
|
||||
# 它就再也不会自己回来——之后每一条请求都会在写日志时抛 FileNotFoundError,
|
||||
# 表现成「假后端活着但什么都不回」(W76 实测踩到)。重建目录只是一次 stat,
|
||||
# 代价可以忽略;让它自己长回来,比要求人先停服务再删要可靠。
|
||||
os.makedirs(self.dir, exist_ok=True)
|
||||
with open(self.path, "a", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(line + "\n")
|
||||
|
||||
@@ -276,10 +465,17 @@ class Store:
|
||||
documents=None,
|
||||
created_at=None,
|
||||
message_id=None,
|
||||
voice=None,
|
||||
image=None,
|
||||
) -> dict:
|
||||
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
|
||||
|
||||
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
|
||||
[voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)——
|
||||
只有控制端点能造出来,真后端的入站语音副本链本工具不模拟。
|
||||
[image] 非空 = 这条带一张图(契约 §1.1:base64 `data:` URI 或 http(s) URL,两种形状客户端
|
||||
都认,判据见 `ImageSource.of`)。本工具**不代取 URL**,所以只由控制端点灌 `data:` URI
|
||||
(见 `/__control/messages` 的 `image_file`)——与 `voice` 同一性质:造场景用,不是后端行为。
|
||||
"""
|
||||
with self.lock:
|
||||
session = self.sessions.get(session_id)
|
||||
@@ -307,6 +503,10 @@ class Store:
|
||||
message["mood"] = mood
|
||||
if documents:
|
||||
message["documents"] = documents
|
||||
if voice:
|
||||
message["voice"] = voice
|
||||
if image:
|
||||
message["image"] = image
|
||||
session.messages.append(message)
|
||||
return message
|
||||
|
||||
@@ -436,11 +636,15 @@ class Store:
|
||||
with self.lock:
|
||||
self.downstream_count += 1
|
||||
|
||||
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers) -> dict:
|
||||
"""把收到的请求头**记下来**(鉴权头不校验,但要看得见)。
|
||||
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
|
||||
"""把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
|
||||
|
||||
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
|
||||
也没有理由把它原文写进一个可能被 commit 的文件。
|
||||
|
||||
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
|
||||
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
|
||||
`out/http.jsonl` 里读得出来,不用再去猜。
|
||||
"""
|
||||
recorded_headers = {}
|
||||
for key, value in headers.items():
|
||||
@@ -460,6 +664,9 @@ class Store:
|
||||
"query": query,
|
||||
"headers": recorded_headers,
|
||||
}
|
||||
summary = summarize_body(body)
|
||||
if summary is not None:
|
||||
record["body"] = summary
|
||||
self.http_log.append(record)
|
||||
with self.lock:
|
||||
self.http_count += 1
|
||||
@@ -861,6 +1068,7 @@ MESSAGES_RE = re.compile(r"^/api/v1/sessions/([^/]+)/messages$")
|
||||
SESSION_RE = re.compile(r"^/api/v1/sessions/([^/]+)$")
|
||||
SESSION_READ_RE = re.compile(r"^/api/v1/sessions/([^/]+)/read$")
|
||||
MESSAGE_RE = re.compile(r"^/api/v1/messages/([^/]+)$")
|
||||
MEDIA_RE = re.compile(r"^/api/v1/media/([^/]+)$")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -882,17 +1090,38 @@ class Handler(BaseHTTPRequestHandler):
|
||||
query = parse_qs(parts.query, keep_blank_values=True)
|
||||
return parts.path, query
|
||||
|
||||
def handle_one_request(self):
|
||||
"""每个请求进来时**先把 body 缓存清掉**。
|
||||
|
||||
`protocol_version` 是 HTTP/1.1,同一条 keep-alive 连接上的多个请求由**同一个 Handler 实例**
|
||||
依次处理(`handle_one_request` 循环),所以 `_read_body` 的缓存必须按请求隔离。少了这一步,
|
||||
第 2 个请求会拿到第 1 个请求的 body(摘要记成上一份),而它自己那份**根本没人从 `rfile` 读走**
|
||||
——残留字节留在内核缓冲里,被下一个请求的请求行一并吃掉,`BaseHTTPRequestHandler` 于是回
|
||||
`501 Unsupported method ('{"sessionId":...}POST')`。客户端表现成「3 张图只投出去 2 张」,
|
||||
而真正的锅在这里。
|
||||
"""
|
||||
self._body_read = False
|
||||
self._body_cache = b""
|
||||
super().handle_one_request()
|
||||
|
||||
def _read_body(self):
|
||||
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
|
||||
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
|
||||
if getattr(self, "_body_read", False):
|
||||
return self._body_cache
|
||||
self._body_read = True
|
||||
self._body_cache = b""
|
||||
length = self.headers.get("Content-Length")
|
||||
if not length:
|
||||
return b""
|
||||
return self._body_cache
|
||||
try:
|
||||
size = int(length)
|
||||
except ValueError:
|
||||
return b""
|
||||
return self._body_cache
|
||||
if size <= 0:
|
||||
return b""
|
||||
return self.rfile.read(size) or b""
|
||||
return self._body_cache
|
||||
self._body_cache = self.rfile.read(size) or b""
|
||||
return self._body_cache
|
||||
|
||||
def _parse_json_body(self):
|
||||
raw = self._read_body()
|
||||
@@ -939,7 +1168,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def _handle(self, method: str):
|
||||
path, query = self._split()
|
||||
self.store.record_http(self._label(), method, path, query, self.headers)
|
||||
# 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
|
||||
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
|
||||
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
|
||||
body = (
|
||||
self._read_body()
|
||||
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
|
||||
else b""
|
||||
)
|
||||
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
|
||||
try:
|
||||
if path == "/ws/chat":
|
||||
self._handle_ws(method, query)
|
||||
@@ -1096,8 +1333,40 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._delete_message(message_id)
|
||||
return
|
||||
|
||||
match = MEDIA_RE.match(path)
|
||||
if match and method == "GET":
|
||||
self._serve_media(unquote(match.group(1)))
|
||||
return
|
||||
|
||||
self._fail(404, "not_found", "no such endpoint: {} {}".format(method, path))
|
||||
|
||||
def _serve_media(self, name: str) -> None:
|
||||
"""`GET /api/v1/media/{name}`(契约 v0.79 §1.2 **U19**,语音副本播放端点)。
|
||||
|
||||
⚠️ **真后端要 Bearer `owner|admin`**(语音是私密内容);本工具**不校验任何鉴权**(README 首条),
|
||||
所以这条端点在设备上验的是「客户端有没有把请求发对地方、字节能不能播」,**不**能验鉴权。
|
||||
|
||||
只认夹具那一个名字:其余(含穿越形态)一律 `404 media_not_found`——与真后端
|
||||
「名字非法 / 穿越 / 不存在统一同码」的取向一致,也方便顺手验一次失败分支。
|
||||
"""
|
||||
if name != VOICE_FIXTURE_NAME:
|
||||
self._fail(404, "media_not_found", "media not found")
|
||||
return
|
||||
data = VOICE_WAV
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", VOICE_FIXTURE_MIME)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Content-Disposition", 'inline; filename="{}"'.format(name))
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Cache-Control", "private, max-age=86400, immutable")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
log("http", "media {} bytes (voice fixture)".format(len(data)))
|
||||
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
||||
self.close_connection = True
|
||||
|
||||
def _create_session(self) -> None:
|
||||
body, raw = self._parse_json_body()
|
||||
if raw is not None and body is None:
|
||||
@@ -1399,13 +1668,40 @@ class Handler(BaseHTTPRequestHandler):
|
||||
prefix = self._param(query, "prefix", "seed")
|
||||
template = self._param(query, "text", None)
|
||||
platform = self._param(query, "platform", None)
|
||||
voice_kind = self._param(query, "voice", None)
|
||||
image_file = self._param(query, "image_file", None)
|
||||
try:
|
||||
voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS))
|
||||
except KeyError:
|
||||
self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none")
|
||||
return
|
||||
try:
|
||||
image = image_fixture(image_file) if image_file else None
|
||||
except (OSError, ValueError) as error:
|
||||
self._fail(400, "bad_request", "image_file unusable: {}".format(error))
|
||||
return
|
||||
try:
|
||||
documents = documents_fixture(self._param(query, "documents", None))
|
||||
except KeyError:
|
||||
self._fail(400, "bad_request", "documents must be one of extracted/listed/unreadable/none")
|
||||
return
|
||||
if session_id not in self.store.sessions:
|
||||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||||
return
|
||||
first_id = last_id = None
|
||||
for index in range(1, count + 1):
|
||||
content = template.format(n=index) if template else "{} #{}".format(prefix, index)
|
||||
message = self.store.add_message(session_id, role, content, platform=platform)
|
||||
# `text=` **空串**与「不带 text」是两件事:前者是「就要一条没有正文的消息」
|
||||
# (纯附件 / 纯图 / 无转写的语音都靠它造),后者走原来的 `seed #N` 默认正文。
|
||||
content = template.format(n=index) if template is not None else "{} #{}".format(prefix, index)
|
||||
message = self.store.add_message(
|
||||
session_id,
|
||||
role,
|
||||
content,
|
||||
platform=platform,
|
||||
voice=voice,
|
||||
image=image,
|
||||
documents=documents,
|
||||
)
|
||||
first_id = first_id or message["id"]
|
||||
last_id = message["id"]
|
||||
with self.store.lock:
|
||||
|
||||
Reference in new issue
Block a user